From 8a90d60246e04e6437d14f3fa46cecf1119bb4dd Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:28:58 +0100 Subject: [PATCH 1/9] feat: add numeric version comparison helpers --- src/diagnostics/version.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/diagnostics/version.ts diff --git a/src/diagnostics/version.ts b/src/diagnostics/version.ts new file mode 100644 index 0000000..9185b34 --- /dev/null +++ b/src/diagnostics/version.ts @@ -0,0 +1,35 @@ +export interface NumericVersion { + readonly major: number; + readonly minor: number; + readonly patch: number; +} + +export function parseNumericVersion(value: string): NumericVersion | null { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value.trim()); + + if (!match) return null; + + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + + if (![major, minor, patch].every(Number.isSafeInteger)) return null; + + return { major, minor, patch }; +} + +export function compareVersions( + left: NumericVersion, + right: NumericVersion, +): number { + if (left.major !== right.major) return left.major - right.major; + if (left.minor !== right.minor) return left.minor - right.minor; + return left.patch - right.patch; +} + +export function isAtLeast( + actual: NumericVersion, + minimum: NumericVersion, +): boolean { + return compareVersions(actual, minimum) >= 0; +} From 8a0007440fffad9335eca0b1a788ceb0b883dc7e Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:29:04 +0100 Subject: [PATCH 2/9] feat: add safe diagnostic subprocess adapter --- src/diagnostics/process.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/diagnostics/process.ts diff --git a/src/diagnostics/process.ts b/src/diagnostics/process.ts new file mode 100644 index 0000000..93a5431 --- /dev/null +++ b/src/diagnostics/process.ts @@ -0,0 +1,27 @@ +import { spawnSync } from 'node:child_process'; + +export interface CommandResult { + readonly status: number | null; + readonly stdout: string; + readonly stderr: string; + readonly error?: Error; +} + +export type RunCommand = ( + executable: string, + args: readonly string[], +) => CommandResult; + +export const runCommand: RunCommand = (executable, args) => { + const result = spawnSync(executable, [...args], { + encoding: 'utf8', + shell: false, + }); + + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + ...(result.error === undefined ? {} : { error: result.error }), + }; +}; From 79e553b58586c018f749ee69c8ff817d68ec1fed Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:29:17 +0100 Subject: [PATCH 3/9] feat: implement Node.js and npm diagnostics --- src/diagnostics/runtime.ts | 111 +++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/diagnostics/runtime.ts diff --git a/src/diagnostics/runtime.ts b/src/diagnostics/runtime.ts new file mode 100644 index 0000000..2848f29 --- /dev/null +++ b/src/diagnostics/runtime.ts @@ -0,0 +1,111 @@ +import type { DiagnosticCheck, DiagnosticResult } from './types.js'; +import { runCommand, type RunCommand } from './process.js'; +import { + compareVersions, + isAtLeast, + parseNumericVersion, + type NumericVersion, +} from './version.js'; + +const MINIMUM_NODE: NumericVersion = { major: 22, minor: 13, patch: 0 }; +const MAXIMUM_NODE_MAJOR = 25; +const MINIMUM_NPM: NumericVersion = { major: 10, minor: 9, patch: 0 }; + +function failure( + id: string, + label: string, + message: string, + remediation: string, +): DiagnosticResult { + return { id, label, status: 'fail', message, remediation }; +} + +export function createNodeDiagnostic( + nodeVersion = process.versions.node, +): DiagnosticCheck { + return { + id: 'node', + label: 'Node.js', + run(): DiagnosticResult { + const parsed = parseNumericVersion(nodeVersion); + + if (!parsed) { + return failure( + 'node', + 'Node.js', + 'Could not determine a valid Node.js version.', + 'Install a supported Node.js release (>=22.13.0 and <25).', + ); + } + + if (!isAtLeast(parsed, MINIMUM_NODE) || parsed.major >= MAXIMUM_NODE_MAJOR) { + return failure( + 'node', + 'Node.js', + `Unsupported Node.js version ${nodeVersion}.`, + 'Use Node.js >=22.13.0 and <25.', + ); + } + + return { + id: 'node', + label: 'Node.js', + status: 'pass', + message: `Supported version ${nodeVersion} detected.`, + }; + }, + }; +} + +export function createNpmDiagnostic( + execute: RunCommand = runCommand, +): DiagnosticCheck { + return { + id: 'npm', + label: 'npm', + run(): DiagnosticResult { + const result = execute('npm', ['--version']); + + if (result.error || result.status !== 0) { + return failure( + 'npm', + 'npm', + 'npm is unavailable or could not be executed.', + 'Install npm >=10.9.0 and ensure it is available on PATH.', + ); + } + + const version = result.stdout.trim(); + const parsed = parseNumericVersion(version); + + if (!parsed) { + return failure( + 'npm', + 'npm', + 'npm returned an unrecognized version.', + 'Install npm >=10.9.0 and verify `npm --version` returns a numeric version.', + ); + } + + if (compareVersions(parsed, MINIMUM_NPM) < 0) { + return failure( + 'npm', + 'npm', + `Unsupported npm version ${version}.`, + 'Upgrade to npm >=10.9.0.', + ); + } + + return { + id: 'npm', + label: 'npm', + status: 'pass', + message: `Supported version ${version} detected.`, + }; + }, + }; +} + +export function createRuntimeDiagnostics(): readonly DiagnosticCheck[] { + return [createNodeDiagnostic(), createNpmDiagnostic()]; +} From a143f13268b335cbc7a36f4f94d565889feb24e8 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:29:27 +0100 Subject: [PATCH 4/9] feat: register runtime diagnostics in doctor --- src/commands/doctor.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d95ab97..f9c14e6 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { EXIT_CODES } from '../errors/errors.js'; import { runDiagnostics } from '../diagnostics/run.js'; +import { createRuntimeDiagnostics } from '../diagnostics/runtime.js'; import type { DiagnosticCheck, DiagnosticReport, @@ -42,7 +43,7 @@ export function renderDiagnosticReport( export function createDoctorCommand( options: DoctorCommandOptions = {}, ): Command { - const checks = options.checks ?? []; + const checks = options.checks ?? createRuntimeDiagnostics(); const output = options.output ?? new TerminalOutput(); const setExitCode = options.setExitCode ?? ((code) => (process.exitCode = code)); From a064bdbf01c1ef79c181d393b9b5021d446b978f Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:29:44 +0100 Subject: [PATCH 5/9] test: keep explicit empty doctor scaffold coverage --- tests/doctor.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 4f44656..2ac938e 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -25,10 +25,11 @@ function check( } describe('doctor command', () => { - it('renders an empty scaffold without failing', () => { + it('renders an empty scaffold without failing when checks are explicitly empty', () => { const captured = createCapturedTerminalOutput(); let exitCode = -1; const command = createDoctorCommand({ + checks: [], output: captured.output, setExitCode: (code) => { exitCode = code; From adcffe392a7d407a108ffec0453f8322310decbb Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:30:01 +0100 Subject: [PATCH 6/9] test: cover Node.js and npm diagnostics --- tests/runtime-diagnostics.test.ts | 100 ++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/runtime-diagnostics.test.ts diff --git a/tests/runtime-diagnostics.test.ts b/tests/runtime-diagnostics.test.ts new file mode 100644 index 0000000..db00c93 --- /dev/null +++ b/tests/runtime-diagnostics.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { + createNodeDiagnostic, + createNpmDiagnostic, +} from '../src/diagnostics/runtime.js'; +import type { RunCommand } from '../src/diagnostics/process.js'; + +function commandResult( + status: number | null, + stdout = '', + stderr = '', + error?: Error, +) { + return { + status, + stdout, + stderr, + ...(error === undefined ? {} : { error }), + }; +} + +describe('Node.js diagnostic', () => { + it('passes a supported Node.js version', () => { + const result = createNodeDiagnostic('22.13.0').run(); + + expect(result.status).toBe('pass'); + expect(result.message).toContain('22.13.0'); + }); + + it('fails versions below the supported minimum', () => { + const result = createNodeDiagnostic('22.12.9').run(); + + expect(result.status).toBe('fail'); + expect(result.remediation).toContain('>=22.13.0'); + }); + + it('fails Node.js 25 and newer', () => { + const result = createNodeDiagnostic('25.0.0').run(); + + expect(result.status).toBe('fail'); + expect(result.remediation).toContain('<25'); + }); + + it('fails malformed versions without throwing', () => { + const result = createNodeDiagnostic('unknown').run(); + + expect(result.status).toBe('fail'); + expect(result.message).toContain('valid Node.js version'); + }); +}); + +describe('npm diagnostic', () => { + it('executes npm with an argument array and passes a supported version', () => { + const calls: Array<{ executable: string; args: readonly string[] }> = []; + const execute: RunCommand = (executable, args) => { + calls.push({ executable, args }); + return commandResult(0, '10.9.2\n'); + }; + + const result = createNpmDiagnostic(execute).run(); + + expect(calls).toEqual([{ executable: 'npm', args: ['--version'] }]); + expect(result.status).toBe('pass'); + expect(result.message).toContain('10.9.2'); + }); + + it('fails when npm is unavailable without exposing subprocess details', () => { + const execute: RunCommand = () => + commandResult( + null, + '', + 'SECRET_TOKEN=do-not-render', + new Error('spawn npm ENOENT SECRET_TOKEN=do-not-render'), + ); + + const result = createNpmDiagnostic(execute).run(); + + expect(result.status).toBe('fail'); + expect(result.message).toBe('npm is unavailable or could not be executed.'); + expect(JSON.stringify(result)).not.toContain('SECRET_TOKEN'); + }); + + it('fails npm versions below the supported minimum', () => { + const execute: RunCommand = () => commandResult(0, '10.8.9\n'); + + const result = createNpmDiagnostic(execute).run(); + + expect(result.status).toBe('fail'); + expect(result.remediation).toContain('>=10.9.0'); + }); + + it('fails unrecognized npm version output', () => { + const execute: RunCommand = () => commandResult(0, 'npm unknown\n'); + + const result = createNpmDiagnostic(execute).run(); + + expect(result.status).toBe('fail'); + expect(result.message).toBe('npm returned an unrecognized version.'); + }); +}); From 17de428577331a977df250aefa75d52ad2560bf2 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:30:25 +0100 Subject: [PATCH 7/9] test: exercise runtime doctor diagnostics end to end --- tests/executable.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/executable.test.ts b/tests/executable.test.ts index 01e6c3c..81c5a10 100644 --- a/tests/executable.test.ts +++ b/tests/executable.test.ts @@ -42,11 +42,22 @@ describe('StellarForge CLI executable', () => { expect(result.status).toBe(0); expect(result.stderr).toBe(''); - expect(result.stdout).toContain('Usage: stellarforge [options]'); + expect(result.stdout).toContain('Usage: stellarforge [options] [command]'); + expect(result.stdout).toContain('doctor'); expect(result.stdout).toContain('-h, --help'); expect(result.stdout).toContain('-V, --version'); }); + it('runs the default runtime doctor checks successfully in the supported toolchain', () => { + const result = runBuiltCli(['doctor']); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('[PASS] Node.js:'); + expect(result.stdout).toContain('[PASS] npm:'); + expect(result.stdout).toContain('Summary: 2 passed, 0 warnings, 0 failed.'); + }); + it('prints the package version from the built executable', () => { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version: string; From 1215adf00392eb0ac54be41dd7940976b746b1b3 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:30:33 +0100 Subject: [PATCH 8/9] chore: add CLI-017 release intent --- .changeset/runtime-doctor-diagnostics.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/runtime-doctor-diagnostics.md diff --git a/.changeset/runtime-doctor-diagnostics.md b/.changeset/runtime-doctor-diagnostics.md new file mode 100644 index 0000000..4f57062 --- /dev/null +++ b/.changeset/runtime-doctor-diagnostics.md @@ -0,0 +1,5 @@ +--- +"@stellarforge/cli": minor +--- + +Add Node.js and npm environment checks to `stellarforge doctor` with supported-version validation and actionable remediation guidance. From f75e0e9df117712a16aa17f284a2de7f56804388 Mon Sep 17 00:00:00 2001 From: Ahmed Mahmud Date: Tue, 8 Sep 2026 06:31:33 +0100 Subject: [PATCH 9/9] style: format runtime diagnostic condition --- src/diagnostics/runtime.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/diagnostics/runtime.ts b/src/diagnostics/runtime.ts index 2848f29..8df6d7a 100644 --- a/src/diagnostics/runtime.ts +++ b/src/diagnostics/runtime.ts @@ -38,7 +38,10 @@ export function createNodeDiagnostic( ); } - if (!isAtLeast(parsed, MINIMUM_NODE) || parsed.major >= MAXIMUM_NODE_MAJOR) { + if ( + !isAtLeast(parsed, MINIMUM_NODE) || + parsed.major >= MAXIMUM_NODE_MAJOR + ) { return failure( 'node', 'Node.js',