459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
import { cac } from 'cac';
|
|
import pc from 'picocolors';
|
|
import { createContext, type CliContext } from './context.js';
|
|
|
|
const CLI_VERSION = '2.0.0';
|
|
|
|
const HELP_HEADER = `
|
|
${pc.bold('apophis')} — Contract-driven API testing for Fastify
|
|
|
|
${pc.dim('Usage:')}
|
|
apophis <command> [options]
|
|
|
|
${pc.dim('Commands:')}
|
|
init Scaffold config, scripts, and example usage
|
|
verify Run deterministic contract verification
|
|
observe Validate runtime observe configuration and reporting setup
|
|
qualify Run scenario, stateful, protocol, or chaos-driven qualification
|
|
replay Replay a failure using seed and stored trace
|
|
doctor Validate config, environment safety, docs/example correctness
|
|
migrate Check and rewrite deprecated config or API usage
|
|
|
|
${pc.dim('Global Options:')}
|
|
--config <path> Config file path
|
|
--profile <name> Profile name from config
|
|
--generation-profile <name> Generation budget profile (built-in or config alias)
|
|
--cwd <path> Working directory override
|
|
--format <mode> Output format: human | json | ndjson (default: human)
|
|
--color <mode> Color mode: auto | always | never (default: auto)
|
|
--quiet Suppress non-error output
|
|
--verbose Enable verbose logging
|
|
--artifact-dir <path> Directory for artifact output
|
|
--workspace Run command across all workspace packages
|
|
|
|
${pc.dim('Other:')}
|
|
-v, --version Show version number
|
|
-h, --help Show help
|
|
|
|
${pc.dim('Examples:')}
|
|
apophis init --preset safe-ci
|
|
apophis verify --profile quick --routes "POST /users"
|
|
apophis observe --profile staging-observe --check-config
|
|
apophis qualify --profile oauth-nightly --seed 42
|
|
apophis replay --artifact reports/apophis/failure-*.json
|
|
apophis doctor
|
|
apophis doctor --workspace
|
|
apophis migrate --dry-run
|
|
`;
|
|
|
|
function getCommandHelp(command: string): string {
|
|
const helps: Record<string, string> = {
|
|
init: `
|
|
${pc.bold('apophis init')} — Scaffold config, scripts, and example usage
|
|
|
|
${pc.dim('Usage:')}
|
|
apophis init [options]
|
|
|
|
${pc.dim('Options:')}
|
|
--preset <name> Preset name (e.g. safe-ci, full)
|
|
--force Overwrite existing files
|
|
--noninteractive Skip all prompts, require explicit flags
|
|
|
|
${pc.dim('Examples:')}
|
|
apophis init --preset safe-ci
|
|
apophis init --force --noninteractive
|
|
`,
|
|
verify: `
|
|
${pc.bold('apophis verify')} — Run deterministic contract verification
|
|
|
|
${pc.dim('Usage:')}
|
|
apophis verify [options]
|
|
|
|
${pc.dim('Options:')}
|
|
--profile <name> Profile name from config
|
|
--generation-profile <name> Generation budget profile (built-in or config alias)
|
|
--routes <filter> Route filter pattern
|
|
--seed <number> Deterministic seed
|
|
--changed Filter to git-modified routes
|
|
|
|
${pc.dim('Examples:')}
|
|
apophis verify --profile quick
|
|
apophis verify --routes "POST /users" --seed 42
|
|
apophis verify --changed
|
|
`,
|
|
observe: `
|
|
${pc.bold('apophis observe')} — Validate runtime observe configuration and reporting setup
|
|
|
|
${pc.dim('Usage:')}
|
|
apophis observe [options]
|
|
|
|
${pc.dim('Options:')}
|
|
--profile <name> Profile name from config
|
|
--check-config Only validate, do not activate
|
|
|
|
${pc.dim('Examples:')}
|
|
apophis observe --profile staging-observe
|
|
apophis observe --check-config
|
|
`,
|
|
qualify: `
|
|
${pc.bold('apophis qualify')} — Run scenario, stateful, protocol, or chaos-driven qualification
|
|
|
|
${pc.dim('Usage:')}
|
|
apophis qualify [options]
|
|
|
|
${pc.dim('Options:')}
|
|
--profile <name> Profile name from config
|
|
--generation-profile <name> Generation budget profile (built-in or config alias)
|
|
--seed <number> Deterministic seed
|
|
|
|
${pc.dim('Examples:')}
|
|
apophis qualify --profile oauth-nightly --seed 42
|
|
`,
|
|
replay: `
|
|
${pc.bold('apophis replay')} — Replay a failure using seed and stored trace
|
|
|
|
${pc.dim('Usage:')}
|
|
apophis replay --artifact <path>
|
|
|
|
${pc.dim('Options:')}
|
|
--artifact <path> Path to failure artifact
|
|
|
|
${pc.dim('Examples:')}
|
|
apophis replay --artifact reports/apophis/failure-*.json
|
|
`,
|
|
doctor: `
|
|
${pc.bold('apophis doctor')} — Validate config, environment safety, docs/example correctness
|
|
|
|
${pc.dim('Usage:')}
|
|
apophis doctor [options]
|
|
|
|
${pc.dim('Options:')}
|
|
--mode <mode> Focus checks on a mode: verify | observe | qualify
|
|
--strict Treat warnings as failures
|
|
|
|
${pc.dim('Examples:')}
|
|
apophis doctor
|
|
apophis doctor --mode verify
|
|
apophis doctor --strict
|
|
`,
|
|
migrate: `
|
|
${pc.bold('apophis migrate')} — Check and rewrite deprecated config or API usage
|
|
|
|
${pc.dim('Usage:')}
|
|
apophis migrate [options]
|
|
|
|
${pc.dim('Options:')}
|
|
--check Detect legacy config without rewriting
|
|
--dry-run Show exact rewrites without writing
|
|
--write Perform rewrites
|
|
|
|
${pc.dim('Examples:')}
|
|
apophis migrate --check
|
|
apophis migrate --dry-run
|
|
apophis migrate --write
|
|
`,
|
|
};
|
|
|
|
return helps[command] || '';
|
|
}
|
|
|
|
function printInternalError(error: unknown): void {
|
|
console.error();
|
|
console.error(pc.red(' ╔══════════════════════════════════════════════════════════════╗'));
|
|
console.error(pc.red(' ║ INTERNAL APOPHIS ERROR ║'));
|
|
console.error(pc.red(' ╠══════════════════════════════════════════════════════════════╣'));
|
|
console.error(pc.red(` ║ ${String(error).slice(0, 56).padEnd(56)} ║`));
|
|
console.error(pc.red(' ╚══════════════════════════════════════════════════════════════╝'));
|
|
console.error();
|
|
console.error(pc.dim(' This is a bug in APOPHIS. Please report it with the full error'));
|
|
console.error(pc.dim(' message and the command you ran.'));
|
|
console.error();
|
|
}
|
|
|
|
function resolveRequestedFormat(argv: string[]): 'human' | 'json' | 'ndjson' {
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (!arg) continue;
|
|
if (arg === '--format' && argv[i + 1]) {
|
|
const value = argv[i + 1];
|
|
if (value === 'json' || value === 'ndjson') return value;
|
|
return 'human';
|
|
}
|
|
if (arg.startsWith('--format=')) {
|
|
const value = arg.slice('--format='.length);
|
|
if (value === 'json' || value === 'ndjson') return value;
|
|
return 'human';
|
|
}
|
|
}
|
|
return 'human';
|
|
}
|
|
|
|
function writeMachineRecord(
|
|
format: 'json' | 'ndjson',
|
|
payload: Record<string, unknown>,
|
|
): void {
|
|
if (format === 'json') {
|
|
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
return;
|
|
}
|
|
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
}
|
|
|
|
type CommandName = 'init' | 'verify' | 'observe' | 'qualify' | 'replay' | 'doctor' | 'migrate';
|
|
type CommandHandler = (args: string[], ctx: CliContext) => Promise<number>;
|
|
|
|
const commandLoaders: Record<CommandName, () => Promise<CommandHandler>> = {
|
|
init: async () => (await import('../commands/init/index.js')).handleInit,
|
|
verify: async () => (await import('../commands/verify/index.js')).handleVerify,
|
|
observe: async () => (await import('../commands/observe/index.js')).handleObserve,
|
|
qualify: async () => (await import('../commands/qualify/index.js')).handleQualify,
|
|
replay: async () => (await import('../commands/replay/index.js')).handleReplay,
|
|
doctor: async () => (await import('../commands/doctor/index.js')).handleDoctor,
|
|
migrate: async () => (await import('../commands/migrate/index.js')).handleMigrate,
|
|
};
|
|
|
|
async function loadHandler(command: string): Promise<CommandHandler | undefined> {
|
|
const loader = commandLoaders[command as CommandName];
|
|
return loader ? loader() : undefined;
|
|
}
|
|
|
|
export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
|
|
const cli = cac('apophis');
|
|
const requestedFormat = resolveRequestedFormat(argv);
|
|
const machineMode = requestedFormat === 'json' || requestedFormat === 'ndjson';
|
|
|
|
// Global flags
|
|
cli.option('--config <path>', 'Config file path');
|
|
cli.option('--profile <name>', 'Profile name from config');
|
|
cli.option('--generation-profile <name>', 'Generation budget profile (built-in or config alias)');
|
|
cli.option('--cwd <path>', 'Working directory override');
|
|
cli.option('--format <mode>', 'Output format: human | json | ndjson', { default: 'human' });
|
|
cli.option('--color <mode>', 'Color mode: auto | always | never', { default: 'auto' });
|
|
cli.option('--quiet', 'Suppress non-error output');
|
|
cli.option('--verbose', 'Enable verbose logging');
|
|
cli.option('--artifact-dir <path>', 'Directory for artifact output');
|
|
cli.option('--workspace', 'Run command across all workspace packages');
|
|
|
|
// Version
|
|
cli.version(CLI_VERSION);
|
|
|
|
// Override help to use our custom format
|
|
// Note: cac's help() returns the CAC instance for chaining, but we just want to print
|
|
cli.help = () => {
|
|
console.log(HELP_HEADER);
|
|
return cli;
|
|
};
|
|
|
|
// Prevent cac from handling --version (we handle it manually)
|
|
// cac.version() registers --version but we intercept it before cac processes it
|
|
|
|
// Commands
|
|
const commands = [
|
|
'init',
|
|
'verify',
|
|
'observe',
|
|
'qualify',
|
|
'replay',
|
|
'doctor',
|
|
'migrate',
|
|
];
|
|
|
|
for (const command of commands) {
|
|
const cmd = cli.command(command, getCommandHelp(command).split('\n')[1]?.trim() || `${command} command`);
|
|
|
|
// Add command-specific options
|
|
switch (command) {
|
|
case 'init':
|
|
cmd.option('--preset <name>', 'Preset name (e.g. safe-ci, full)');
|
|
cmd.option('--force', 'Overwrite existing files');
|
|
cmd.option('--noninteractive', 'Skip all prompts, require explicit flags');
|
|
break;
|
|
case 'verify':
|
|
cmd.option('--profile <name>', 'Profile name from config');
|
|
cmd.option('--generation-profile <name>', 'Generation budget profile (built-in or config alias)');
|
|
cmd.option('--routes <filter>', 'Route filter pattern');
|
|
cmd.option('--seed <number>', 'Deterministic seed');
|
|
cmd.option('--changed', 'Filter to git-modified routes');
|
|
break;
|
|
case 'observe':
|
|
cmd.option('--profile <name>', 'Profile name from config');
|
|
cmd.option('--check-config', 'Only validate, do not activate');
|
|
break;
|
|
case 'qualify':
|
|
cmd.option('--profile <name>', 'Profile name from config');
|
|
cmd.option('--generation-profile <name>', 'Generation budget profile (built-in or config alias)');
|
|
cmd.option('--seed <number>', 'Deterministic seed');
|
|
break;
|
|
case 'replay':
|
|
cmd.option('--artifact <path>', 'Path to failure artifact');
|
|
break;
|
|
case 'doctor':
|
|
cmd.option('--mode <mode>', 'Focus checks on a specific mode: verify | observe | qualify');
|
|
cmd.option('--strict', 'Treat warnings as failures');
|
|
break;
|
|
case 'migrate':
|
|
cmd.option('--check', 'Detect legacy config without rewriting');
|
|
cmd.option('--dry-run', 'Show exact rewrites without writing');
|
|
cmd.option('--write', 'Perform rewrites');
|
|
break;
|
|
}
|
|
|
|
cmd.action(async (options) => {
|
|
const ctx = createContext(options);
|
|
const handler = await loadHandler(command);
|
|
if (!handler) {
|
|
console.error(pc.red(`Unknown command: ${command}`));
|
|
return 2;
|
|
}
|
|
// Pass raw argv so doctor/migrate can parse extra flags
|
|
const result = await handler(argv, ctx);
|
|
// Ensure we always return a number (cac may swallow undefined)
|
|
return typeof result === 'number' ? result : 0;
|
|
});
|
|
}
|
|
|
|
try {
|
|
// Handle --help globally (before parsing)
|
|
if (argv.includes('-h') || argv.includes('--help')) {
|
|
const commandArg = argv.find(arg => commands.includes(arg));
|
|
if (commandArg) {
|
|
const helpText = getCommandHelp(commandArg);
|
|
if (helpText) {
|
|
if (machineMode) {
|
|
writeMachineRecord(requestedFormat, {
|
|
command: commandArg,
|
|
help: helpText,
|
|
});
|
|
} else {
|
|
console.log(helpText);
|
|
}
|
|
return 0;
|
|
}
|
|
}
|
|
if (machineMode) {
|
|
writeMachineRecord(requestedFormat, { help: HELP_HEADER });
|
|
} else {
|
|
cli.help();
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Handle --version (before parsing)
|
|
if (argv.includes('-v') || argv.includes('--version')) {
|
|
if (machineMode) {
|
|
writeMachineRecord(requestedFormat, { version: CLI_VERSION });
|
|
} else {
|
|
console.log(CLI_VERSION);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Check for unknown commands
|
|
const firstArg = argv[0];
|
|
if (firstArg && !firstArg.startsWith('-') && !commands.includes(firstArg)) {
|
|
if (machineMode) {
|
|
writeMachineRecord(requestedFormat, {
|
|
error: `Unknown command: ${firstArg}`,
|
|
availableCommands: commands,
|
|
next: 'Run "apophis --help" for usage information.',
|
|
});
|
|
} else {
|
|
console.error(pc.red(`Unknown command: ${firstArg}`));
|
|
console.error();
|
|
console.error(pc.dim('Available commands:'));
|
|
for (const cmd of commands) {
|
|
console.error(pc.dim(` ${cmd}`));
|
|
}
|
|
console.error();
|
|
console.error(pc.dim('Run "apophis --help" for usage information.'));
|
|
}
|
|
return 2;
|
|
}
|
|
|
|
// Handle unknown flags
|
|
const knownGlobalFlags = new Set([
|
|
'--config', '--profile', '--cwd', '--format', '--color',
|
|
'--generation-profile',
|
|
'--quiet', '--verbose', '--artifact-dir', '--workspace',
|
|
'-v', '--version', '-h', '--help',
|
|
]);
|
|
|
|
const commandSpecificFlags: Record<string, Set<string>> = {
|
|
init: new Set(['--preset', '--force', '--noninteractive']),
|
|
verify: new Set(['--profile', '--generation-profile', '--routes', '--seed', '--changed', '--workspace']),
|
|
observe: new Set(['--profile', '--check-config', '--workspace']),
|
|
qualify: new Set(['--profile', '--generation-profile', '--seed', '--workspace']),
|
|
replay: new Set(['--artifact']),
|
|
doctor: new Set(['--mode', '--strict', '--workspace']),
|
|
migrate: new Set(['--check', '--dry-run', '--write']),
|
|
};
|
|
|
|
const activeCommand = firstArg && commands.includes(firstArg) ? firstArg : undefined;
|
|
const activeCmdFlags = activeCommand ? commandSpecificFlags[activeCommand] : undefined;
|
|
const allowedFlags = activeCmdFlags
|
|
? new Set([...knownGlobalFlags, ...activeCmdFlags])
|
|
: knownGlobalFlags;
|
|
|
|
const unknownFlags: string[] = [];
|
|
for (const arg of argv) {
|
|
if (arg.startsWith('--') || (arg.startsWith('-') && arg.length > 1)) {
|
|
const flagName = arg.split('=')[0]!;
|
|
if (!allowedFlags.has(flagName)) {
|
|
unknownFlags.push(flagName);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (unknownFlags.length > 0) {
|
|
if (machineMode) {
|
|
writeMachineRecord(requestedFormat, {
|
|
error: `Unknown flag: ${unknownFlags[0]}`,
|
|
next: 'Run "apophis --help" for available options.',
|
|
});
|
|
} else {
|
|
console.error(pc.red(`Unknown flag: ${unknownFlags[0]}`));
|
|
console.error();
|
|
console.error(pc.dim('Run "apophis --help" for available options.'));
|
|
}
|
|
return 2;
|
|
}
|
|
|
|
// If no command provided, show help
|
|
if (!firstArg || firstArg.startsWith('-')) {
|
|
if (machineMode) {
|
|
writeMachineRecord(requestedFormat, { help: HELP_HEADER });
|
|
} else {
|
|
cli.help();
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Parse options for the command
|
|
const parsed = cli.parse(['node', 'apophis', ...argv], { run: false });
|
|
|
|
// Directly dispatch to handler (bypass cac's runMatchedCommand which has issues)
|
|
const handler = await loadHandler(firstArg);
|
|
if (!handler) {
|
|
console.error(pc.red(`Unknown command: ${firstArg}`));
|
|
return 2;
|
|
}
|
|
|
|
const ctx = createContext(parsed.options);
|
|
const result = await handler(argv, ctx);
|
|
return typeof result === 'number' ? result : 0;
|
|
} catch (error) {
|
|
if (machineMode) {
|
|
writeMachineRecord(requestedFormat, {
|
|
error: 'Internal APOPHIS error',
|
|
detail: String(error),
|
|
});
|
|
} else {
|
|
printInternalError(error);
|
|
}
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
// src/cli/core/index.ts is the CLI logic module. The direct entrypoint is src/cli/index.ts.
|
|
// Do NOT add a direct main() call here — that belongs in the entrypoint file only.
|