From 8e6b781797a56f4cb5492edeabc9f81a2c815bc7 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:21:17 +0100 Subject: [PATCH 01/14] feat: define doctor diagnostic model --- src/diagnostics/types.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/diagnostics/types.ts 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; +} From 72b560821e02e738fabaa3d095f45863b1d2bb2c Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:21:25 +0100 Subject: [PATCH 02/14] feat: add doctor diagnostic aggregation --- src/diagnostics/run.ts | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/diagnostics/run.ts diff --git a/src/diagnostics/run.ts b/src/diagnostics/run.ts new file mode 100644 index 0000000..b833a47 --- /dev/null +++ b/src/diagnostics/run.ts @@ -0,0 +1,52 @@ +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), + }; +} From 6383875d7673a467582efa07b986c1c0d84d9b20 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:21:32 +0100 Subject: [PATCH 03/14] feat: add doctor command shell --- src/commands/doctor.ts | 60 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/commands/doctor.ts diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000..b87b570 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,60 @@ +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, + ); + }); +} From d50ea201f5b3f6ad724c42d5a86f86a690858647 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:21:56 +0100 Subject: [PATCH 04/14] feat: add diagnostic exit code --- src/errors/errors.ts | 1 + 1 file changed, 1 insertion(+) 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]; From 25b0f1b89581c826dc3a583fc12f92998f3a3bc0 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:22:02 +0100 Subject: [PATCH 05/14] feat: register doctor command --- src/commands/root.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/root.ts b/src/commands/root.ts index 6bc162f..5cd0928 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,10 @@ 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) + .addCommand(createDoctorCommand()); } From 7d2cafa0d8f4bc198b7884b295260d6e61a8e6bb Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:22:21 +0100 Subject: [PATCH 06/14] test: cover doctor aggregation and exit behavior --- tests/doctor.test.ts | 110 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/doctor.test.ts diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts new file mode 100644 index 0000000..7514797 --- /dev/null +++ b/tests/doctor.test.ts @@ -0,0 +1,110 @@ +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 }), + }; +} + +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'); + }); +}); From 4dfa0166135052493d173798aadd7df650e76b19 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:22:30 +0100 Subject: [PATCH 07/14] docs: document diagnostic exit code --- docs/reference/exit-codes.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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. From 3ee052fb067394501d37b2500e28579130025bb2 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:23:21 +0100 Subject: [PATCH 08/14] fix: satisfy exact optional diagnostic typing --- tests/doctor.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 7514797..2cb9e44 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -14,7 +14,13 @@ function check( return { id, label, - run: () => ({ id, label, status, message, remediation }), + run: () => ({ + id, + label, + status, + message, + ...(remediation === undefined ? {} : { remediation }), + }), }; } From 8f14bfde5d2f64a6145ad30ab9f4399b064a3286 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:24:08 +0100 Subject: [PATCH 09/14] chore: apply canonical CLI-016 formatting --- .github/workflows/format-cli-016.yml | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/format-cli-016.yml diff --git a/.github/workflows/format-cli-016.yml b/.github/workflows/format-cli-016.yml new file mode 100644 index 0000000..c4d2656 --- /dev/null +++ b/.github/workflows/format-cli-016.yml @@ -0,0 +1,45 @@ +name: Format CLI-016 + +on: + push: + branches: + - feat/cli-016-doctor-scaffold + paths: + - .github/workflows/format-cli-016.yml + +permissions: + contents: write + +jobs: + format: + if: github.repository == 'DigiNodes/stellarforge-cli' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout maintainer branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.13.0' + package-manager-cache: false + + - name: Install from committed lockfile + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Apply repository Prettier + run: node node_modules/prettier/bin/prettier.cjs --write src/commands/doctor.ts src/diagnostics/run.ts tests/doctor.test.ts + + - name: Commit formatting + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add src/commands/doctor.ts src/diagnostics/run.ts tests/doctor.test.ts + if git diff --cached --quiet; then + echo 'Formatting already canonical.' + exit 0 + fi + git commit -m 'style: format doctor scaffold' + git push origin HEAD:feat/cli-016-doctor-scaffold From d514c5bcd3d7bbd65a465cd395ff59d9a6879d1b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:24:23 +0000 Subject: [PATCH 10/14] style: format doctor scaffold --- src/commands/doctor.ts | 7 +++---- src/diagnostics/run.ts | 3 ++- tests/doctor.test.ts | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index b87b570..d95ab97 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -44,7 +44,8 @@ export function createDoctorCommand( ): Command { const checks = options.checks ?? []; const output = options.output ?? new TerminalOutput(); - const setExitCode = options.setExitCode ?? ((code) => (process.exitCode = code)); + const setExitCode = + options.setExitCode ?? ((code) => (process.exitCode = code)); return new Command('doctor') .description('Check the local StellarForge development environment.') @@ -52,9 +53,7 @@ export function createDoctorCommand( const report = runDiagnostics(checks); renderDiagnosticReport(report, output); setExitCode( - report.summary.fail > 0 - ? EXIT_CODES.diagnostic - : EXIT_CODES.success, + report.summary.fail > 0 ? EXIT_CODES.diagnostic : EXIT_CODES.success, ); }); } diff --git a/src/diagnostics/run.ts b/src/diagnostics/run.ts index b833a47..1297268 100644 --- a/src/diagnostics/run.ts +++ b/src/diagnostics/run.ts @@ -11,7 +11,8 @@ function failedCheckResult(check: DiagnosticCheck): DiagnosticResult { label: check.label, status: 'fail', message: 'Diagnostic check could not complete.', - remediation: 'Retry the command or inspect the relevant tool configuration.', + remediation: + 'Retry the command or inspect the relevant tool configuration.', }; } diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 2cb9e44..4f44656 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -38,7 +38,9 @@ describe('doctor command', () => { command.parse(['node', 'doctor']); expect(exitCode).toBe(EXIT_CODES.success); - expect(captured.stdoutText()).toContain('No diagnostics are registered yet.'); + expect(captured.stdoutText()).toContain( + 'No diagnostics are registered yet.', + ); expect(captured.stdoutText()).toContain( 'Summary: 0 passed, 0 warnings, 0 failed.', ); From 83d0a738d3b501ebda79c7b364caf87e330b0aa2 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:24:38 +0100 Subject: [PATCH 11/14] chore: remove temporary CLI-016 formatter --- .github/workflows/format-cli-016.yml | 45 ---------------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/format-cli-016.yml diff --git a/.github/workflows/format-cli-016.yml b/.github/workflows/format-cli-016.yml deleted file mode 100644 index c4d2656..0000000 --- a/.github/workflows/format-cli-016.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Format CLI-016 - -on: - push: - branches: - - feat/cli-016-doctor-scaffold - paths: - - .github/workflows/format-cli-016.yml - -permissions: - contents: write - -jobs: - format: - if: github.repository == 'DigiNodes/stellarforge-cli' - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Checkout maintainer branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22.13.0' - package-manager-cache: false - - - name: Install from committed lockfile - run: npm ci --ignore-scripts --no-audit --no-fund - - - name: Apply repository Prettier - run: node node_modules/prettier/bin/prettier.cjs --write src/commands/doctor.ts src/diagnostics/run.ts tests/doctor.test.ts - - - name: Commit formatting - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add src/commands/doctor.ts src/diagnostics/run.ts tests/doctor.test.ts - if git diff --cached --quiet; then - echo 'Formatting already canonical.' - exit 0 - fi - git commit -m 'style: format doctor scaffold' - git push origin HEAD:feat/cli-016-doctor-scaffold From 16cec8cb900f7d8e8aaa7488aa110d1ca1f4a017 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:26:18 +0100 Subject: [PATCH 12/14] fix: preserve bare root command success --- src/commands/root.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/root.ts b/src/commands/root.ts index 5cd0928..f021753 100644 --- a/src/commands/root.ts +++ b/src/commands/root.ts @@ -13,5 +13,6 @@ export function createRootCommand(version = resolveCliVersion()): Command { .exitOverride() .showHelpAfterError() .showSuggestionAfterError(true) + .action(() => undefined) .addCommand(createDoctorCommand()); } From 349a19290cf7d3c827883ca24d6f7055aa4f66fb Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:26:24 +0100 Subject: [PATCH 13/14] test: update root command expectations for doctor --- tests/root-command.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/root-command.test.ts b/tests/root-command.test.ts index a40bdc5..f782c6e 100644 --- a/tests/root-command.test.ts +++ b/tests/root-command.test.ts @@ -2,20 +2,21 @@ 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'); }); From 6c84a00ad3f0a6a90b7bcc928200a7f832535536 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:27:13 +0100 Subject: [PATCH 14/14] style: format root doctor command assertion --- tests/root-command.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/root-command.test.ts b/tests/root-command.test.ts index f782c6e..046a0fd 100644 --- a/tests/root-command.test.ts +++ b/tests/root-command.test.ts @@ -6,7 +6,9 @@ describe('StellarForge root command', () => { const program = createRootCommand('1.2.3'); expect(program.name()).toBe('stellarforge'); - expect(program.commands.map((command) => command.name())).toEqual(['doctor']); + expect(program.commands.map((command) => command.name())).toEqual([ + 'doctor', + ]); }); it('provides useful global help information', () => {