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