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. 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)); 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 }), + }; +}; diff --git a/src/diagnostics/runtime.ts b/src/diagnostics/runtime.ts new file mode 100644 index 0000000..8df6d7a --- /dev/null +++ b/src/diagnostics/runtime.ts @@ -0,0 +1,114 @@ +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()]; +} 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; +} 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; 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; 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.'); + }); +});