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
5 changes: 5 additions & 0 deletions .changeset/runtime-doctor-diagnostics.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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));
Expand Down
27 changes: 27 additions & 0 deletions src/diagnostics/process.ts
Original file line number Diff line number Diff line change
@@ -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 }),
};
};
114 changes: 114 additions & 0 deletions src/diagnostics/runtime.ts
Original file line number Diff line number Diff line change
@@ -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()];
}
35 changes: 35 additions & 0 deletions src/diagnostics/version.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 2 additions & 1 deletion tests/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 12 additions & 1 deletion tests/executable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
100 changes: 100 additions & 0 deletions tests/runtime-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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.');
});
});