From 0b2784911dc9319bc610cc4534a968ea7adad5dd Mon Sep 17 00:00:00 2001 From: kzhivotov Date: Sun, 22 Mar 2026 10:06:41 -0700 Subject: [PATCH] feat: hardening --- .github/workflows/ci.yml | 3 +++ bin/cli.js | 32 +++++++++++++++++++++++++++----- src/commands/add.js | 7 ++++--- src/commands/customize.js | 15 +++++++-------- src/commands/doc-init.js | 25 +++++++++++-------------- src/commands/doc-sync.js | 23 ++++++++++++----------- src/lib/context-builder.js | 1 + src/lib/errors.js | 18 ++++++++++++++++++ src/lib/runner.js | 2 +- 9 files changed, 84 insertions(+), 42 deletions(-) create mode 100644 src/lib/errors.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa1f7fd..1dad113 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: push: branches: [main] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/bin/cli.js b/bin/cli.js index eb9b751..677eb51 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { program } from 'commander'; +import { program, InvalidArgumentError } from 'commander'; import pc from 'picocolors'; import { readFileSync, readdirSync } from 'fs'; import { join, dirname } from 'path'; @@ -10,6 +10,20 @@ import { docInitCommand } from '../src/commands/doc-init.js'; import { docSyncCommand } from '../src/commands/doc-sync.js'; import { addCommand } from '../src/commands/add.js'; import { customizeCommand } from '../src/commands/customize.js'; +import { CliError } from '../src/lib/errors.js'; + +function parsePositiveInt(value, name) { + const n = parseInt(value, 10); + if (isNaN(n) || n <= 0) throw new InvalidArgumentError(`${name} must be a positive integer`); + return n; +} + +function parseTimeout(value) { return parsePositiveInt(value, 'timeout'); } +function parseCommits(value) { + const n = parsePositiveInt(value, 'commits'); + if (n > 50) throw new InvalidArgumentError('commits must be 50 or less'); + return n; +} const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATES_DIR = join(__dirname, '..', 'src', 'templates'); @@ -94,7 +108,7 @@ doc .argument('[path]', 'Path to repo', '.') .option('--dry-run', 'Preview without writing files') .option('--force', 'Overwrite existing skills') - .option('--timeout ', 'Claude timeout in seconds', '300') + .option('--timeout ', 'Claude timeout in seconds', parseTimeout, 300) .option('--mode ', 'Generation mode: all, chunked, base-only (skips interactive prompt)') .option('--strategy ', 'Existing docs: improve, rewrite, skip (skips interactive prompt)') .option('--domains ', 'Additional domains to include (comma-separated, e.g., "backtest,advisory")') @@ -107,11 +121,11 @@ doc .command('sync') .description('Update skills from recent commits') .argument('[path]', 'Path to repo', '.') - .option('--commits ', 'Number of commits to analyze', '1') + .option('--commits ', 'Number of commits to analyze', parseCommits, 1) .option('--install-hook', 'Install git post-commit hook') .option('--remove-hook', 'Remove git post-commit hook') .option('--dry-run', 'Preview without writing files') - .option('--timeout ', 'Claude timeout in seconds', '300') + .option('--timeout ', 'Claude timeout in seconds', parseTimeout, 300) .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') .option('--verbose', 'Show what Claude is reading/doing in real time') .action(docSyncCommand); @@ -131,12 +145,20 @@ program .description('Inject project-specific context into agents') .argument('', 'What to customize: agents') .option('--dry-run', 'Preview without writing files') - .option('--timeout ', 'Claude timeout in seconds', '300') + .option('--timeout ', 'Claude timeout in seconds', parseTimeout, 300) .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') .option('--verbose', 'Show what Claude is reading/doing in real time') .action(customizeCommand); +// Clean up spawned processes on interrupt +process.on('SIGINT', () => process.exit(130)); +process.on('SIGTERM', () => process.exit(143)); + program.parseAsync().catch((err) => { + if (err instanceof CliError) { + if (!err.logged) console.error(pc.red('Error:'), err.message); + process.exit(err.exitCode); + } console.error(pc.red('Error:'), err.message); process.exit(1); }); diff --git a/src/commands/add.js b/src/commands/add.js index d038530..1a27719 100644 --- a/src/commands/add.js +++ b/src/commands/add.js @@ -3,6 +3,7 @@ import { existsSync, readFileSync, copyFileSync, mkdirSync, readdirSync } from ' import { fileURLToPath } from 'url'; import pc from 'picocolors'; import * as p from '@clack/prompts'; +import { CliError } from '../lib/errors.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATES_DIR = join(__dirname, '..', 'templates'); @@ -47,7 +48,7 @@ export async function addCommand(type, name, options) { ${pc.dim('aspens add hook [name]')} ${pc.dim('aspens add agent --list')} `); - process.exit(1); + throw new CliError(`Unknown type: ${type}`, { logged: true }); } const resourceType = RESOURCE_TYPES[type]; @@ -78,7 +79,7 @@ export async function addCommand(type, name, options) { if (p.isCancel(picked)) { p.cancel('Aborted'); - process.exit(0); + return; } for (const pickedName of picked) { @@ -110,7 +111,7 @@ export async function addCommand(type, name, options) { Available ${type}s: ${available.map(a => ` ${pc.green(a.name)} — ${a.description}`).join('\n')} `); - process.exit(1); + throw new CliError(`Not found: ${name}`, { logged: true }); } addResource(repoPath, resourceType, name, available); diff --git a/src/commands/customize.js b/src/commands/customize.js index 3232952..40e064f 100644 --- a/src/commands/customize.js +++ b/src/commands/customize.js @@ -4,12 +4,13 @@ import pc from 'picocolors'; import * as p from '@clack/prompts'; import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js'; import { writeSkillFiles } from '../lib/skill-writer.js'; +import { CliError } from '../lib/errors.js'; const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep']; export async function customizeCommand(what, options) { const repoPath = resolve('.'); - const timeoutMs = parseInt(options.timeout) * 1000 || 300000; + const timeoutMs = (typeof options.timeout === 'number' ? options.timeout : 300) * 1000; const verbose = !!options.verbose; if (what !== 'agents') { @@ -19,7 +20,7 @@ export async function customizeCommand(what, options) { Usage: ${pc.green('aspens customize agents')} Inject project context into your agents `); - process.exit(1); + throw new CliError(`Unknown target: ${what}`, { logged: true }); } p.intro(pc.cyan('aspens customize agents')); @@ -27,14 +28,12 @@ export async function customizeCommand(what, options) { // Step 1: Find agents in the repo const agentsDir = join(repoPath, '.claude', 'agents'); if (!existsSync(agentsDir)) { - p.log.error('No .claude/agents/ found. Run aspens add agent first.'); - process.exit(1); + throw new CliError('No .claude/agents/ found. Run aspens add agent first.'); } const agents = findAgents(agentsDir, repoPath); if (agents.length === 0) { - p.log.error('No agent files found in .claude/agents/'); - process.exit(1); + throw new CliError('No agent files found in .claude/agents/'); } p.log.info(`Found ${agents.length} agent(s): ${agents.map(a => pc.yellow(a.name)).join(', ')}`); @@ -48,7 +47,7 @@ export async function customizeCommand(what, options) { if (!projectContext) { contextSpinner.stop(pc.yellow('No skills or CLAUDE.md found')); p.log.warn('Run aspens doc init first to generate skills, then customize agents.'); - process.exit(0); + return; } contextSpinner.stop('Project context loaded'); @@ -114,7 +113,7 @@ export async function customizeCommand(what, options) { if (p.isCancel(proceed) || !proceed) { p.cancel('Aborted'); - process.exit(0); + return; } // Allow .claude/agents/ paths in sanitizePath diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index 45535f9..b7da530 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -7,15 +7,15 @@ import { buildRepoGraph } from '../lib/graph-builder.js'; import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js'; import { writeSkillFiles } from '../lib/skill-writer.js'; import { installGitHook } from './doc-sync.js'; +import { CliError } from '../lib/errors.js'; // Read-only tools — Claude explores the repo itself const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep']; // Auto-scale timeout based on repo size function autoTimeout(scan, userTimeout) { - if (userTimeout) { - const parsed = parseInt(userTimeout) * 1000; - if (!isNaN(parsed) && parsed > 0) return parsed; + if (typeof userTimeout === 'number' && userTimeout > 0) { + return userTimeout * 1000; } const defaults = { 'small': 120000, 'medium': 300000, 'large': 600000, 'very-large': 900000 }; return defaults[scan.size?.category] || 300000; @@ -207,8 +207,7 @@ export async function docInitCommand(path, options) { const strategyMap = { 'improve': 'improve', 'rewrite': 'rewrite', 'skip': 'skip-existing' }; existingDocsStrategy = strategyMap[options.strategy] || options.strategy; if (!['improve', 'rewrite', 'skip-existing', 'fresh'].includes(existingDocsStrategy)) { - p.log.error(`Unknown strategy: ${options.strategy}. Use: improve, rewrite, or skip`); - process.exit(1); + throw new CliError(`Unknown strategy: ${options.strategy}. Use: improve, rewrite, or skip`); } } else if ((scan.hasClaudeConfig || scan.hasClaudeMd) && !options.force) { const strategy = await p.select({ @@ -222,7 +221,7 @@ export async function docInitCommand(path, options) { if (p.isCancel(strategy)) { p.cancel('Aborted'); - process.exit(0); + return; } existingDocsStrategy = strategy; @@ -240,8 +239,7 @@ export async function docInitCommand(path, options) { const modeMap = { 'all': 'all-at-once', 'chunked': 'chunked', 'base-only': 'base-only' }; mode = modeMap[options.mode] || options.mode; if (!['all-at-once', 'chunked', 'base-only'].includes(mode)) { - p.log.error(`Unknown mode: ${options.mode}. Use: all, chunked, or base-only`); - process.exit(1); + throw new CliError(`Unknown mode: ${options.mode}. Use: all, chunked, or base-only`); } } else if (effectiveDomains.length === 0) { p.log.info('No domains detected — generating base skill only.'); @@ -270,7 +268,7 @@ export async function docInitCommand(path, options) { if (p.isCancel(modeChoice)) { p.cancel('Aborted'); - process.exit(0); + return; } mode = modeChoice; @@ -287,7 +285,7 @@ export async function docInitCommand(path, options) { if (p.isCancel(picked)) { p.cancel('Aborted'); - process.exit(0); + return; } selectedDomains = effectiveDomains.filter(d => picked.includes(d.name)); mode = 'chunked'; @@ -304,11 +302,10 @@ export async function docInitCommand(path, options) { } if (allFiles.length === 0) { - p.log.error('No skill files generated.'); if (tokenTracker.calls > 0) { console.log(pc.dim(` ${tokenTracker.calls} Claude call(s) made, but no parseable output.`)); } - process.exit(1); + throw new CliError('No skill files generated.', { logged: true }); } // Step 4: Show what will be written @@ -339,7 +336,7 @@ export async function docInitCommand(path, options) { if (p.isCancel(proceed) || !proceed) { p.cancel('Aborted'); - process.exit(0); + return; } // Step 5: Write files @@ -536,7 +533,7 @@ async function generateAllAtOnce(repoPath, scan, repoGraph, selectedDomains, tim initialValue: true, }); if (p.isCancel(retry) || !retry) { - process.exit(1); + throw new CliError('Generation failed.', { logged: true }); } return generateChunked(repoPath, scan, repoGraph, selectedDomains, false, timeoutMs, strategy, verbose, model, findings); } diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index 654e0ee..0679c06 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -6,14 +6,15 @@ import * as p from '@clack/prompts'; import { scanRepo } from '../lib/scanner.js'; import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js'; import { writeSkillFiles } from '../lib/skill-writer.js'; +import { CliError } from '../lib/errors.js'; const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep']; export async function docSyncCommand(path, options) { const repoPath = resolve(path); - const timeoutMs = parseInt(options.timeout) * 1000 || 300000; + const timeoutMs = (typeof options.timeout === 'number' ? options.timeout : 300) * 1000; const verbose = !!options.verbose; - const commits = parseInt(options.commits) || 1; + const commits = typeof options.commits === 'number' ? options.commits : 1; // Install/remove hook mode if (options.installHook) { @@ -27,13 +28,11 @@ export async function docSyncCommand(path, options) { // Step 1: Check prerequisites if (!isGitRepo(repoPath)) { - p.log.error('Not a git repository. doc sync requires git history.'); - process.exit(1); + throw new CliError('Not a git repository. doc sync requires git history.'); } if (!existsSync(join(repoPath, '.claude', 'skills'))) { - p.log.error('No .claude/skills/ found. Run aspens doc init first.'); - process.exit(1); + throw new CliError('No .claude/skills/ found. Run aspens doc init first.'); } // Step 2: Get git diff @@ -136,8 +135,7 @@ ${truncate(claudeMdContent, 5000)} }); } catch (err) { syncSpinner.stop(pc.red('Failed')); - p.log.error(err.message); - process.exit(1); + throw new CliError(err.message); } // Step 6: Parse output @@ -186,7 +184,7 @@ ${truncate(claudeMdContent, 5000)} function isGitRepo(repoPath) { try { - execSync('git rev-parse --git-dir', { cwd: repoPath, stdio: 'pipe' }); + execSync('git rev-parse --git-dir', { cwd: repoPath, stdio: 'pipe', timeout: 5000 }); return true; } catch { return false; @@ -201,6 +199,7 @@ function getGitDiff(repoPath, commits) { cwd: repoPath, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, + timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'], }); return { diff, actualCommits: n }; @@ -217,6 +216,7 @@ function getGitLog(repoPath, commits) { cwd: repoPath, encoding: 'utf8', maxBuffer: 5 * 1024 * 1024, + timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], }).trim(); } catch { @@ -230,6 +230,7 @@ function getChangedFiles(repoPath, commits) { cwd: repoPath, encoding: 'utf8', maxBuffer: 5 * 1024 * 1024, + timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'], }); return output.trim().split('\n').filter(Boolean); @@ -325,6 +326,7 @@ function resolveAspensPath() { try { const resolved = execSync('which aspens', { encoding: 'utf8', + timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], }).trim(); if (resolved && existsSync(resolved)) return resolved; @@ -337,8 +339,7 @@ export function installGitHook(repoPath) { const hookPath = join(hookDir, 'post-commit'); if (!existsSync(join(repoPath, '.git'))) { - console.log(pc.red('\n Not a git repository.\n')); - process.exit(1); + throw new CliError('Not a git repository.'); } mkdirSync(hookDir, { recursive: true }); diff --git a/src/lib/context-builder.js b/src/lib/context-builder.js index 8c631f9..8e1106b 100644 --- a/src/lib/context-builder.js +++ b/src/lib/context-builder.js @@ -303,6 +303,7 @@ function getGitLog(repoPath) { return execSync('git log --oneline -20', { cwd: repoPath, encoding: 'utf8', + timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], }).trim(); } catch { diff --git a/src/lib/errors.js b/src/lib/errors.js new file mode 100644 index 0000000..00cddfc --- /dev/null +++ b/src/lib/errors.js @@ -0,0 +1,18 @@ +/** + * Error thrown by command handlers for expected failures (validation, missing prereqs, etc.). + * Caught at the top level in cli.js — avoids scattered process.exit() calls. + */ +export class CliError extends Error { + /** + * @param {string} message + * @param {{ exitCode?: number, logged?: boolean }} options + * - exitCode: process exit code (default 1) + * - logged: if true, the top-level handler won't re-print the message + */ + constructor(message, { exitCode = 1, logged = false } = {}) { + super(message); + this.name = 'CliError'; + this.exitCode = exitCode; + this.logged = logged; + } +} diff --git a/src/lib/runner.js b/src/lib/runner.js index ac66086..f9ec2d2 100644 --- a/src/lib/runner.js +++ b/src/lib/runner.js @@ -16,7 +16,7 @@ const ALLOWED_EXACT_FILES = ['CLAUDE.md']; */ function checkClaude() { try { - execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'pipe' }); + execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'pipe', timeout: 5000 }); } catch { throw new Error( 'Claude Code CLI not found. Install it first:\n' +