Initialize SHSF CLI project with core structure, configuration, and command handling
This commit is contained in:
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
pnpm-lock.yaml
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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! ")}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user