commit 6831669de717e3172d13d19cac86a413fae4b6fa Author: Space-Banane Date: Wed Mar 18 15:47:45 2026 +0100 Initialize SHSF CLI project with core structure, configuration, and command handling diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..0c1e2cf --- /dev/null +++ b/.github/copilot-instructions.md @@ -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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf14aed --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +build +pnpm-lock.yaml \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..df0cdd2 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..b80bb51 --- /dev/null +++ b/src/api.ts @@ -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 { + 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; +} diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..40be99b --- /dev/null +++ b/src/commands.ts @@ -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(); + 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); + } + } +} diff --git a/src/commands/health.ts b/src/commands/health.ts new file mode 100644 index 0000000..cc7864c --- /dev/null +++ b/src/commands/health.ts @@ -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)}`); + } + } +} diff --git a/src/commands/testing/helloworld.ts b/src/commands/testing/helloworld.ts new file mode 100644 index 0000000..d6ca4af --- /dev/null +++ b/src/commands/testing/helloworld.ts @@ -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! ")}`); + }, +}; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..fdfc945 --- /dev/null +++ b/src/config.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..247d4b4 --- /dev/null +++ b/src/index.ts @@ -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(); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b97ee1a --- /dev/null +++ b/tsconfig.json @@ -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"] +}