Skip to content
10 changes: 5 additions & 5 deletions src/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function healthyDeps(credentialsPath: string, extra: Partial<DoctorDeps> = {}):
env: {},
credentialsPath,
cwd: '/project',
nodeVersion: '22.9.0',
nodeVersion: '22.13.0',
existsSync: () => true, // skill landing file present
fetchImpl: makeFetch(OK_ME),
...extra,
Expand Down Expand Up @@ -259,17 +259,17 @@ describe('runDoctor — failing checks exit non-zero', () => {
expect(capture.stdout.join('\n')).toContain('GET /me failed (NOT_FOUND)');
});

it('an outdated Node runtime fails the Node.js check', async () => {
it('an excluded in-range Node runtime fails the Node.js check', async () => {
writeProfile('default', { apiKey: 'sk-user-abc' }, { path: credentialsPath });
const { capture, deps } = makeCapture();
const rejection = await runDoctor(
{ profile: 'default', output: 'text', debug: false },
{ ...healthyDeps(credentialsPath, { nodeVersion: '18.0.0' }), ...deps },
{ ...healthyDeps(credentialsPath, { nodeVersion: '22.9.0' }), ...deps },
).catch((error: unknown) => error);
expect(rejection).toBeInstanceOf(CLIError);
const out = capture.stdout.join('\n');
expect(out).toContain('Node.js');
expect(out).toContain('below the required Node 20');
expect(out).toContain('outside the supported Node range 20.19+, 22.13+, or 24+');
});
});

Expand Down Expand Up @@ -299,7 +299,7 @@ describe('runDoctor — warnings do not fail', () => {
env: {},
credentialsPath,
cwd: '/project',
nodeVersion: '22.9.0',
nodeVersion: '22.13.0',
existsSync: () => true,
fetchImpl,
...deps,
Expand Down
13 changes: 5 additions & 8 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '..
import { isVerifySkillInstalled } from '../lib/skill-nudge.js';
import { emitV3RoutingAdvisory, routingLabel } from '../lib/v3-advisory.js';
import { VERSION } from '../version.js';
import { MIN_SUPPORTED_NODE_MAJOR, shouldRejectNodeVersion } from '../version-guard.js';
import { SUPPORTED_NODE_RANGE, shouldRejectNodeVersion } from '../version-guard.js';

export type DoctorStatus = 'ok' | 'warn' | 'fail';

Expand Down Expand Up @@ -67,7 +67,7 @@ export interface DoctorDeps {
stderr?: (line: string) => void;
/** Project dir for the skill check. Defaults to `process.cwd()`. */
cwd?: string;
/** Runtime version string (e.g. "22.9.0"). Defaults to `process.versions.node`. */
/** Runtime version string (e.g. "22.13.0"). Defaults to `process.versions.node`. */
nodeVersion?: string;
existsSync?: (p: string) => boolean;
readFileSync?: (p: string) => string;
Expand Down Expand Up @@ -156,17 +156,14 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro
}

function checkNodeVersion(nodeVersion: string): DoctorCheck {
// Reuse the CLI's own runtime guard so the verdict matches exactly what the
// entrypoint enforces at startup, rather than a divergent hardcoded check.
// The precise engines floor (20.19+/22.13+/24+) is enforced by npm at install
// time via .npmrc engine-strict. sourceRef: src/version-guard.ts.
// Reuse the CLI runtime guard so doctor and startup enforce and describe the same range.
const rejected = shouldRejectNodeVersion(nodeVersion);
return {
name: 'Node.js',
status: rejected ? 'fail' : 'ok',
detail: rejected
? `v${nodeVersion} is below the required Node ${MIN_SUPPORTED_NODE_MAJOR}; upgrade Node.js`
: `v${nodeVersion} (>=${MIN_SUPPORTED_NODE_MAJOR} required)`,
? `v${nodeVersion} is outside the supported Node range ${SUPPORTED_NODE_RANGE}; upgrade Node.js`
: `v${nodeVersion} (supported range: ${SUPPORTED_NODE_RANGE})`,
};
}

Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ import {
} from './lib/telemetry.js';
import { maybeNotifyUpdate } from './lib/update-check.js';
import { VERSION } from './version.js';
import { shouldRejectNodeVersion } from './version-guard.js';
import { SUPPORTED_NODE_RANGE, shouldRejectNodeVersion } from './version-guard.js';

// Guard: exit early with a clear message on unsupported Node.js versions,
// rather than failing later with a cryptic ESM/runtime error.
if (shouldRejectNodeVersion(process.versions.node)) {
process.stderr.write(
`Error: testsprite requires Node.js >= 20 (found ${process.versions.node}).\nInstall the latest LTS from https://nodejs.org\n`,
`Error: testsprite requires Node.js ${SUPPORTED_NODE_RANGE} (found ${process.versions.node}).\nInstall a supported Node.js release from https://nodejs.org\n`,
);
process.exit(1);
}
Expand Down
39 changes: 28 additions & 11 deletions src/version-guard.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { createRequire } from 'node:module';
import { describe, expect, it } from 'vitest';
import {
MIN_SUPPORTED_NODE_MAJOR,
SUPPORTED_NODE_ENGINE,
parseMajorVersion,
shouldRejectNodeVersion,
} from './version-guard.js';

const require = createRequire(import.meta.url);
const pkg: { engines: { node: string } } = require('../package.json') as {
engines: { node: string };
};

// These tests exercise the REAL guard functions used by src/index.ts,
// imported here rather than re-declared, so a regression in the source is
// actually caught.
Expand All @@ -22,22 +29,32 @@ describe('parseMajorVersion', () => {
});

describe('shouldRejectNodeVersion', () => {
it('rejects majors below the supported floor', () => {
it('stays pinned to package.json engines.node', () => {
expect(SUPPORTED_NODE_ENGINE).toBe(pkg.engines.node);
});

it('rejects runtimes outside the declared engines range', () => {
expect(shouldRejectNodeVersion('18.19.1')).toBe(true);
expect(shouldRejectNodeVersion('16.20.2')).toBe(true);
expect(shouldRejectNodeVersion('14.21.3')).toBe(true);
expect(shouldRejectNodeVersion('20.0.0')).toBe(true);
expect(shouldRejectNodeVersion('20.18.99')).toBe(true);
expect(shouldRejectNodeVersion('21.99.0')).toBe(true);
expect(shouldRejectNodeVersion('22.0.0')).toBe(true);
expect(shouldRejectNodeVersion('22.12.99')).toBe(true);
expect(shouldRejectNodeVersion('23.99.99')).toBe(true);
});

it('accepts the supported floor and above', () => {
expect(shouldRejectNodeVersion('20.0.0')).toBe(false);
expect(shouldRejectNodeVersion('20.11.0')).toBe(false);
expect(shouldRejectNodeVersion('21.0.0')).toBe(false);
expect(shouldRejectNodeVersion('22.1.0')).toBe(false);
it('accepts every supported Node window', () => {
expect(shouldRejectNodeVersion('20.19.0')).toBe(false);
expect(shouldRejectNodeVersion('20.99.0')).toBe(false);
expect(shouldRejectNodeVersion('22.13.0')).toBe(false);
expect(shouldRejectNodeVersion('22.99.0')).toBe(false);
expect(shouldRejectNodeVersion('24.0.0')).toBe(false);
expect(shouldRejectNodeVersion('25.0.0')).toBe(false);
});

it(`treats exactly ${MIN_SUPPORTED_NODE_MAJOR} as supported (boundary)`, () => {
expect(shouldRejectNodeVersion(`${MIN_SUPPORTED_NODE_MAJOR}.0.0`)).toBe(false);
expect(shouldRejectNodeVersion(`${MIN_SUPPORTED_NODE_MAJOR - 1}.9.9`)).toBe(true);
it(`keeps the major floor constant at ${MIN_SUPPORTED_NODE_MAJOR}`, () => {
expect(shouldRejectNodeVersion(`${MIN_SUPPORTED_NODE_MAJOR}.19.0`)).toBe(false);
expect(shouldRejectNodeVersion(`${MIN_SUPPORTED_NODE_MAJOR - 1}.99.99`)).toBe(true);
});

it('does not reject an unparseable version (guard never blocks on garbage)', () => {
Expand Down
42 changes: 33 additions & 9 deletions src/version-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@
* real implementation the entrypoint uses — not a copy.
*/

/** Minimum Node.js major version supported by the CLI (matches package.json `engines.node`). */
/** Canonical supported range, pinned to package.json by the unit test. */
export const SUPPORTED_NODE_ENGINE = '^20.19.0 || ^22.13.0 || >=24';
/** Human-readable form of the supported range for startup errors. */
export const SUPPORTED_NODE_RANGE = '20.19+, 22.13+, or 24+';
export const MIN_SUPPORTED_NODE_MAJOR = 20;
const MIN_NODE_20_MINOR = 19;
const MIN_NODE_22_MINOR = 13;

/**
* Parse the leading major version number from a Node.js version string.
Expand All @@ -24,18 +29,37 @@ export function parseMajorVersion(nodeVersion: string): number {
return Number(nodeVersion.split('.')[0]);
}

function parseMajorMinor(nodeVersion: string): { major: number; minor: number } | null {
const [majorRaw, minorRaw] = nodeVersion.split('.');
const major = Number(majorRaw);
// Preserve the old guard's behavior for injectable major-only versions such as "18".
const minor = minorRaw === undefined ? 0 : Number(minorRaw);
if (!Number.isInteger(major) || !Number.isInteger(minor) || major < 0 || minor < 0) {
return null;
}
return { major, minor };
}

/**
* Decide whether the given Node.js version is too old to run the CLI.
* Decide whether the given Node.js version is outside the supported engine range.
*
* A version is rejected only when its major number is a real value below
* {@link MIN_SUPPORTED_NODE_MAJOR}. An unparseable string yields `NaN`, which is
* treated as "do not reject" so the guard never blocks on a version string it
* cannot understand (the runtime would surface any real incompatibility itself).
* Mirrors `^20.19.0 || ^22.13.0 || >=24`: Node 20 is supported from 20.19,
* Node 22 from 22.13, odd intermediate majors 21/23 are rejected, and 24+ is supported.
* An unparseable string is treated as "do not reject" so the guard never blocks on a
* version string it cannot understand (the runtime would surface any incompatibility).
*
* @param nodeVersion - a `process.versions.node` style string (e.g. `"18.19.1"`).
* @returns `true` when the runtime is below the supported floor and should be rejected.
* @returns `true` when the runtime is unsupported and should be rejected.
*/
export function shouldRejectNodeVersion(nodeVersion: string): boolean {
const major = parseMajorVersion(nodeVersion);
return !Number.isNaN(major) && major < MIN_SUPPORTED_NODE_MAJOR;
const parsed = parseMajorMinor(nodeVersion);
if (parsed === null) return false;

const { major, minor } = parsed;
if (major < MIN_SUPPORTED_NODE_MAJOR) return true;
if (major === 20) return minor < MIN_NODE_20_MINOR;
if (major === 21) return true;
if (major === 22) return minor < MIN_NODE_22_MINOR;
if (major === 23) return true;
return false;
}
Loading