Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/reference/exit-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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.
59 changes: 59 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
}
7 changes: 5 additions & 2 deletions src/commands/root.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { Command } from 'commander';
import { resolveCliVersion } from '../version.js';
import { createDoctorCommand } from './doctor.js';

export function createRootCommand(version = resolveCliVersion()): Command {
return new Command()
.name('stellarforge')
.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());
}
53 changes: 53 additions & 0 deletions src/diagnostics/run.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
27 changes: 27 additions & 0 deletions src/diagnostics/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/errors/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
118 changes: 118 additions & 0 deletions tests/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
9 changes: 6 additions & 3 deletions tests/root-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down