diff --git a/docs/reference/exit-codes.md b/docs/reference/exit-codes.md index 178d293..4c4c6da 100644 --- a/docs/reference/exit-codes.md +++ b/docs/reference/exit-codes.md @@ -11,16 +11,18 @@ StellarForge CLI uses predictable process exit codes so humans, scripts, and CI | `2` | Validation / usage failure | User input, command syntax, options, arguments, or other validated input was invalid. | | `3` | Subprocess failure | A required external process or tool failed. | | `4` | Internal failure | StellarForge detected a known internal failure condition that should not expose implementation details to the user. | +| `5` | Diagnostic failure | A diagnostic command completed normally but one or more required health checks failed. | These values are part of the CLI contract and should not be changed casually. New stable categories require deliberate review and documentation. ## Rendering Rules -- user-facing failures are written to stderr; +- user-facing failures are written to stderr unless a command intentionally renders a structured report to stdout; - successful informational output remains independent of numeric exit status; - validation and subprocess errors may expose only an explicitly safe user-facing message; - internal failures render a generic message while preserving the underlying cause internally; - unexpected exceptions render a generic message and do not expose the original error message or stack trace by default; +- diagnostic failures use exit code `5` when the command itself completed but its health assessment contains failed required checks; - Commander parser failures are mapped to exit code `2` without duplicating Commander's existing usage/error rendering; - `--help` and `--version` remain successful exits with code `0`. @@ -31,6 +33,6 @@ These values are part of the CLI contract and should not be changed casually. Ne - test failures must propagate meaningful failure status; - deployment failures must never return success; - interrupted/cancelled-operation semantics will be documented when cancellation support is implemented; -- future command-specific errors should use the centralized CLI error boundary instead of assigning ad hoc process exit codes. +- future command-specific errors should use the centralized CLI error boundary or a deliberately documented stable category instead of assigning ad hoc process exit codes. The source-level constants in the CLI error module and this reference must remain aligned. diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000..d95ab97 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,59 @@ +import { Command } from 'commander'; +import { EXIT_CODES } from '../errors/errors.js'; +import { runDiagnostics } from '../diagnostics/run.js'; +import type { + DiagnosticCheck, + DiagnosticReport, + DiagnosticResult, +} from '../diagnostics/types.js'; +import { TerminalOutput } from '../output/terminal.js'; + +export interface DoctorCommandOptions { + readonly checks?: readonly DiagnosticCheck[]; + readonly output?: TerminalOutput; + readonly setExitCode?: (code: number) => void; +} + +function formatResult(result: DiagnosticResult): string { + return `[${result.status.toUpperCase()}] ${result.label}: ${result.message}`; +} + +export function renderDiagnosticReport( + report: DiagnosticReport, + output: TerminalOutput, +): void { + if (report.results.length === 0) { + output.info('No diagnostics are registered yet.'); + } + + for (const result of report.results) { + output.info(formatResult(result)); + + if (result.remediation) { + output.info(` Remediation: ${result.remediation}`); + } + } + + output.info( + `Summary: ${report.summary.pass} passed, ${report.summary.warn} warnings, ${report.summary.fail} failed.`, + ); +} + +export function createDoctorCommand( + options: DoctorCommandOptions = {}, +): Command { + const checks = options.checks ?? []; + const output = options.output ?? new TerminalOutput(); + const setExitCode = + options.setExitCode ?? ((code) => (process.exitCode = code)); + + return new Command('doctor') + .description('Check the local StellarForge development environment.') + .action(() => { + const report = runDiagnostics(checks); + renderDiagnosticReport(report, output); + setExitCode( + report.summary.fail > 0 ? EXIT_CODES.diagnostic : EXIT_CODES.success, + ); + }); +} diff --git a/src/commands/root.ts b/src/commands/root.ts index 6bc162f..f021753 100644 --- a/src/commands/root.ts +++ b/src/commands/root.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { resolveCliVersion } from '../version.js'; +import { createDoctorCommand } from './doctor.js'; export function createRootCommand(version = resolveCliVersion()): Command { return new Command() @@ -7,9 +8,11 @@ export function createRootCommand(version = resolveCliVersion()): Command { .description( 'Open infrastructure for building production-ready Stellar applications.', ) - .usage('[options]') + .usage('[options] [command]') .version(version) .exitOverride() .showHelpAfterError() - .showSuggestionAfterError(true); + .showSuggestionAfterError(true) + .action(() => undefined) + .addCommand(createDoctorCommand()); } diff --git a/src/diagnostics/run.ts b/src/diagnostics/run.ts new file mode 100644 index 0000000..1297268 --- /dev/null +++ b/src/diagnostics/run.ts @@ -0,0 +1,53 @@ +import type { + DiagnosticCheck, + DiagnosticReport, + DiagnosticResult, + DiagnosticSummary, +} from './types.js'; + +function failedCheckResult(check: DiagnosticCheck): DiagnosticResult { + return { + id: check.id, + label: check.label, + status: 'fail', + message: 'Diagnostic check could not complete.', + remediation: + 'Retry the command or inspect the relevant tool configuration.', + }; +} + +function summarize(results: readonly DiagnosticResult[]): DiagnosticSummary { + let pass = 0; + let warn = 0; + let fail = 0; + + for (const result of results) { + if (result.status === 'pass') pass += 1; + if (result.status === 'warn') warn += 1; + if (result.status === 'fail') fail += 1; + } + + return { + pass, + warn, + fail, + total: results.length, + }; +} + +export function runDiagnostics( + checks: readonly DiagnosticCheck[], +): DiagnosticReport { + const results = checks.map((check) => { + try { + return check.run(); + } catch { + return failedCheckResult(check); + } + }); + + return { + results, + summary: summarize(results), + }; +} diff --git a/src/diagnostics/types.ts b/src/diagnostics/types.ts new file mode 100644 index 0000000..c5d2ce7 --- /dev/null +++ b/src/diagnostics/types.ts @@ -0,0 +1,27 @@ +export type DiagnosticStatus = 'pass' | 'warn' | 'fail'; + +export interface DiagnosticResult { + readonly id: string; + readonly label: string; + readonly status: DiagnosticStatus; + readonly message: string; + readonly remediation?: string; +} + +export interface DiagnosticCheck { + readonly id: string; + readonly label: string; + run(): DiagnosticResult; +} + +export interface DiagnosticSummary { + readonly pass: number; + readonly warn: number; + readonly fail: number; + readonly total: number; +} + +export interface DiagnosticReport { + readonly results: readonly DiagnosticResult[]; + readonly summary: DiagnosticSummary; +} diff --git a/src/errors/errors.ts b/src/errors/errors.ts index 90d1324..65d35b8 100644 --- a/src/errors/errors.ts +++ b/src/errors/errors.ts @@ -4,6 +4,7 @@ export const EXIT_CODES = { validation: 2, subprocess: 3, internal: 4, + diagnostic: 5, } as const; export type CliExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES]; diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts new file mode 100644 index 0000000..4f44656 --- /dev/null +++ b/tests/doctor.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { createDoctorCommand } from '../src/commands/doctor.js'; +import type { DiagnosticCheck } from '../src/diagnostics/types.js'; +import { EXIT_CODES } from '../src/errors/errors.js'; +import { createCapturedTerminalOutput } from './helpers/index.js'; + +function check( + id: string, + label: string, + status: 'pass' | 'warn' | 'fail', + message: string, + remediation?: string, +): DiagnosticCheck { + return { + id, + label, + run: () => ({ + id, + label, + status, + message, + ...(remediation === undefined ? {} : { remediation }), + }), + }; +} + +describe('doctor command', () => { + it('renders an empty scaffold without failing', () => { + const captured = createCapturedTerminalOutput(); + let exitCode = -1; + const command = createDoctorCommand({ + output: captured.output, + setExitCode: (code) => { + exitCode = code; + }, + }); + + command.parse(['node', 'doctor']); + + expect(exitCode).toBe(EXIT_CODES.success); + expect(captured.stdoutText()).toContain( + 'No diagnostics are registered yet.', + ); + expect(captured.stdoutText()).toContain( + 'Summary: 0 passed, 0 warnings, 0 failed.', + ); + expect(captured.stderrText()).toBe(''); + }); + + it('aggregates pass, warning, and failure results', () => { + const captured = createCapturedTerminalOutput(); + let exitCode = -1; + const command = createDoctorCommand({ + checks: [ + check('node', 'Node.js', 'pass', 'Supported version detected.'), + check('docker', 'Docker', 'warn', 'Docker is not available.'), + check( + 'stellar', + 'Stellar CLI', + 'fail', + 'Required CLI is missing.', + 'Install the official Stellar CLI.', + ), + ], + output: captured.output, + setExitCode: (code) => { + exitCode = code; + }, + }); + + command.parse(['node', 'doctor']); + + expect(exitCode).toBe(EXIT_CODES.diagnostic); + expect(captured.stdoutText()).toContain( + '[PASS] Node.js: Supported version detected.', + ); + expect(captured.stdoutText()).toContain( + '[WARN] Docker: Docker is not available.', + ); + expect(captured.stdoutText()).toContain( + '[FAIL] Stellar CLI: Required CLI is missing.', + ); + expect(captured.stdoutText()).toContain( + 'Remediation: Install the official Stellar CLI.', + ); + expect(captured.stdoutText()).toContain( + 'Summary: 1 passed, 1 warnings, 1 failed.', + ); + }); + + it('converts an unexpected check exception into a safe failed diagnostic', () => { + const captured = createCapturedTerminalOutput(); + let exitCode = -1; + const command = createDoctorCommand({ + checks: [ + { + id: 'unsafe', + label: 'Unsafe check', + run() { + throw new Error('SECRET_TOKEN=do-not-render'); + }, + }, + ], + output: captured.output, + setExitCode: (code) => { + exitCode = code; + }, + }); + + command.parse(['node', 'doctor']); + + expect(exitCode).toBe(EXIT_CODES.diagnostic); + expect(captured.stdoutText()).toContain( + '[FAIL] Unsafe check: Diagnostic check could not complete.', + ); + expect(captured.stdoutText()).not.toContain('SECRET_TOKEN'); + }); +}); diff --git a/tests/root-command.test.ts b/tests/root-command.test.ts index a40bdc5..046a0fd 100644 --- a/tests/root-command.test.ts +++ b/tests/root-command.test.ts @@ -2,20 +2,23 @@ import { describe, expect, it } from 'vitest'; import { createRootCommand } from '../src/commands/root.js'; describe('StellarForge root command', () => { - it('defines the stable root identity without registering product commands early', () => { + it('defines the stable root identity and registers the doctor command', () => { const program = createRootCommand('1.2.3'); expect(program.name()).toBe('stellarforge'); - expect(program.commands).toHaveLength(0); + expect(program.commands.map((command) => command.name())).toEqual([ + 'doctor', + ]); }); it('provides useful global help information', () => { const help = createRootCommand('1.2.3').helpInformation(); - expect(help).toContain('Usage: stellarforge [options]'); + expect(help).toContain('Usage: stellarforge [options] [command]'); expect(help).toContain( 'Open infrastructure for building production-ready Stellar applications.', ); + expect(help).toContain('doctor'); expect(help).toContain('-V, --version'); expect(help).toContain('-h, --help'); });