Initialize SHSF CLI project with core structure, configuration, and command handling

This commit is contained in:
Space-Banane
2026-03-18 15:47:45 +01:00
commit 6831669de7
10 changed files with 337 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# SHSF CLI Project Guidelines
## Architecture
- **Framework**: Built with `commander` for CLI management.
- **Dynamic Commands**: Commands are dynamically loaded from `src/commands/` using recursive directory scanning in [src/commands.ts](src/commands.ts).
- **API Client**: Centralized Axios instance in [src/api.ts](src/api.ts) with standardized headers and singleton pattern for configuration injection.
- **Configuration**: Managed via `loadConfig` in [src/config.ts](src/config.ts), typically using environment variables or local files.
## Code Style
- **TypeScript**: Strict typing is preferred.
- **ES Modules**: The project uses `"type": "module"`. Always use `.js` extensions in imports if required by the runtime/build (though TS usually handles this, be mindful of ESM requirements).
- **Output**: Use `chalk` for terminal styling. Consistent color coding:
- Green: Success/Healthy
- Red: Errors/Failures
- Yellow: Warnings/Pending
## Build and Test
- **Install**: `pnpm install`
- **Build**: `pnpm build` (runs `rimraf dist && tsc`)
- **Run**: `pnpm start [command]` or `node dist/index.js [command]`
- **Binary**: The CLI is named `shsf`.
## Conventions
- **New Commands**: To add a command, create a new file in `src/commands/` (or a subfolder). It must export a definition object (default or named) with `name`, `description`, and `action`.
- **Error Handling**: Follow the pattern in [src/commands/health.ts](src/commands/health.ts) for handling Axios errors (check for `error.response`, `error.request`, etc.).
- **No Global Scope**: Keep command logic within the `action` function or extracted to utility modules to maintain testability.
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
build
pnpm-lock.yaml
+32
View File
@@ -0,0 +1,32 @@
{
"name": "shsf-cli",
"version": "1.0.0",
"description": "",
"type": "module",
"bin": {
"shsf": "./dist/index.js"
},
"scripts": {
"build": "rimraf dist && tsc",
"start": "node dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.30.0",
"devDependencies": {
"@types/inquirer": "^9.0.9",
"@types/node": "^25.5.0",
"rimraf": "^6.1.3",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"dependencies": {
"axios": "^1.13.6",
"chalk": "^5.6.2",
"commander": "^14.0.3",
"dotenv": "^17.3.1",
"inquirer": "^9.3.8"
}
}
+25
View File
@@ -0,0 +1,25 @@
import axios, { AxiosInstance } from 'axios';
import { loadConfig } from './config.js';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pkg = require('../package.json');
let apiClient: AxiosInstance | null = null;
export async function getApiClient(): Promise<AxiosInstance> {
if (apiClient) return apiClient;
const config = await loadConfig();
apiClient = axios.create({
baseURL: config.SHSF_INSTANCE,
headers: {
'x-access-key': config.SHSF_TOKEN,
'Content-Type': 'application/json',
"User-Agent": "SHSF-CLI/" + pkg.version
},
});
return apiClient;
}
+64
View File
@@ -0,0 +1,64 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { program } from "./index.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function getCommandFiles(dir: string): string[] {
const files = fs.readdirSync(dir);
let commandFiles: string[] = [];
for (const file of files) {
const fullPath = path.join(dir, file);
if (fs.statSync(fullPath).isDirectory()) {
commandFiles = commandFiles.concat(getCommandFiles(fullPath));
} else if (file.endsWith('.ts') || file.endsWith('.js')) {
commandFiles.push(fullPath);
}
}
return commandFiles;
}
export async function resolveCommands() {
const commandsPath = path.join(__dirname, 'commands');
if (!fs.existsSync(commandsPath)) return;
const commandFiles = getCommandFiles(commandsPath);
// Group commands by their directory relative to 'commands'
const groupMap = new Map<string, any>();
groupMap.set('', program);
for (const absolutePath of commandFiles) {
const relativePathFromCommands = path.relative(commandsPath, absolutePath);
const pathParts = path.dirname(relativePathFromCommands).split(path.sep).filter(p => p !== '.');
let currentParent = program;
let currentGroupPath = '';
for (const part of pathParts) {
currentGroupPath = currentGroupPath ? path.join(currentGroupPath, part) : part;
if (!groupMap.has(currentGroupPath)) {
const groupCommand = currentParent.command(part).description(`${part} commands`);
groupMap.set(currentGroupPath, groupCommand);
}
currentParent = groupMap.get(currentGroupPath);
}
const relativeImportPath = './' + path.relative(__dirname, absolutePath).replace(/\\/g, '/');
const module = await import(relativeImportPath);
// Look for a definition object (e.g., healthDefinition) or a default export with name/description/action
const definition = module.default || Object.values(module).find((val: any) => val && val.name && val.action);
if (definition && definition.name && definition.action) {
currentParent
.command(definition.name)
.description(definition.description || '')
.action(definition.action);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import chalk from "chalk";
import { getApiClient } from "../api.js";
export const healthDefinition = {
name: "health",
description: "Perform a health check on the current SHSF instance.",
action: async () => {
await healthCheck();
},
};
async function healthCheck() {
const client = await getApiClient();
try {
const response = await client.get("/health");
if (response.status === 200 && response.data.status === "OK") {
console.log(
`${chalk.green("✓")} SHSF Status: ${chalk.bgGreen.black(" HEALTHY ")}`,
);
} else {
console.log(
`${chalk.yellow("!")} SHSF Status: ${chalk.bgYellow.black(" UNEXPECTED RESPONSE ")}`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
console.log(`Response Data: ${JSON.stringify(response.data)}`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} System Status: ${chalk.bgRed.black(" UNHEALTHY ")}`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
} else if (error.request) {
console.error(
`${chalk.red("✗")} SHSF Status: ${chalk.bgRed.black(" DISCONNECTED ")}`,
);
console.error(
`Error: ${chalk.yellow("Could not connect to the SHSF instance. Check your connection or SHSF_INSTANCE URL.")}`,
);
} else {
console.error(
`${chalk.red("✗")} SHSF Status: ${chalk.bgRed.black(" LOCAL ERROR ")}`,
);
console.error(`Error: ${chalk.yellow(error.message)}`);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import chalk from "chalk";
export const helloworldDefinition = {
name: "helloworld",
description: "Prints Hello, World! to the terminal.",
action: async () => {
console.log(`${chalk.green("✓")} ${chalk.bgGreen.black(" Hello, World! ")}`);
},
};
+80
View File
@@ -0,0 +1,80 @@
import os from 'os';
import path from 'path';
import fs from 'fs';
import dotenv from 'dotenv';
import chalk from 'chalk';
import inquirer from 'inquirer';
export interface SHSFConfig {
SHSF_INSTANCE: string;
SHSF_TOKEN: string;
}
const CONFIG_FILE_NAME = '.shsf_config';
export function getConfigPath(): string {
return path.join(os.homedir(), CONFIG_FILE_NAME);
}
async function promptForConfig(): Promise<SHSFConfig> {
console.log(chalk.cyan('\nSetting up SHSF configuration...\n'));
const answers = await inquirer.prompt([
{
type: 'input',
name: 'SHSF_INSTANCE',
message: 'Enter your SHSF instance URL (e.g., https://your-instance-api.yourdomain.com):',
validate: (input: string) => {
try {
new URL(input);
return true;
} catch {
return 'Please enter a valid URL (including http(s)://)';
}
},
},
{
type: 'password',
name: 'SHSF_TOKEN',
message: 'Enter your SHSF access token (masked):',
validate: (input: string) => input.trim().length > 0 || 'Token cannot be empty',
},
]);
const configContent = `# SHSF Configuration\nSHSF_INSTANCE=${answers.SHSF_INSTANCE}\nSHSF_TOKEN=${answers.SHSF_TOKEN}\n`;
const configPath = getConfigPath();
try {
fs.writeFileSync(configPath, configContent);
console.log(chalk.green(`\n✓ Configuration saved to ${configPath}\n`));
} catch (error: any) {
console.error(chalk.red(`\nError saving configuration: ${error.message}\n`));
process.exit(1);
}
return answers as SHSFConfig;
}
export async function loadConfig(): Promise<SHSFConfig> {
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
return await promptForConfig();
}
const configContent = fs.readFileSync(configPath, 'utf-8');
const parsed = dotenv.parse(configContent);
const instance = parsed.SHSF_INSTANCE;
const token = parsed.SHSF_TOKEN;
if (!instance || !token) {
console.error(chalk.yellow(`Missing required configuration in ${configPath}`));
return await promptForConfig();
}
return {
SHSF_INSTANCE: instance,
SHSF_TOKEN: token,
};
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
import { resolveCommands } from './commands.js';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pckg = require('../package.json');
export const program = new Command();
program
.name('shsf')
.description('SHSF CLI tool to manage your serverless functions.')
.version(pckg.version);
await resolveCommands();
program
.on('command:*', () => {
console.error(chalk.red(`\nInvalid command: ${program.args.join(' ')}\n`));
process.exit(1);
});
program.parse(process.argv);
if (!process.argv.slice(2).length) {
program.outputHelp();
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}