diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 80f8c73..c5c059a 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -50,7 +50,7 @@ function healthyDeps(credentialsPath: string, extra: Partial = {}): env: {}, credentialsPath, cwd: '/project', - nodeVersion: '22.9.0', + nodeVersion: '22.13.0', existsSync: () => true, // skill landing file present fetchImpl: makeFetch(OK_ME), ...extra, @@ -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+'); }); }); @@ -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, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d53520a..e79ea01 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -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'; @@ -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; @@ -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})`, }; } diff --git a/src/index.ts b/src/index.ts index bc5bd9a..a492223 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); } diff --git a/src/version-guard.test.ts b/src/version-guard.test.ts index 19f0eab..6682ac4 100644 --- a/src/version-guard.test.ts +++ b/src/version-guard.test.ts @@ -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. @@ -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)', () => { diff --git a/src/version-guard.ts b/src/version-guard.ts index a7fe464..716d598 100644 --- a/src/version-guard.ts +++ b/src/version-guard.ts @@ -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. @@ -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; }