Skip to content
Open
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
12 changes: 6 additions & 6 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 @@ -283,17 +283,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 @@ -323,7 +323,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 Expand Up @@ -375,4 +375,4 @@ describe('createDoctorCommand wiring', () => {
}
}
});
});
});
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