Skip to content

Commit 2ddb03d

Browse files
authored
feat(cli): add runtime Node.js version check with clear error message (#11)
* feat(cli): add runtime Node.js version check with clear error message * refactor(version-guard): extract to a documented module tested against the real implementation
1 parent d72290d commit 2ddb03d

3 files changed

Lines changed: 103 additions & 0 deletions

File tree

src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/usr/bin/env node
2+
23
import { Command, CommanderError } from 'commander';
34
import { createAgentCommand } from './commands/agent.js';
45
import { createAuthCommand } from './commands/auth.js';
@@ -15,6 +16,16 @@ import { Output, isOutputMode } from './lib/output.js';
1516
import { renderCommanderError, rephraseUnknownOption } from './lib/render-error.js';
1617
import { maybeEmitSkillNudge } from './lib/skill-nudge.js';
1718
import { VERSION } from './version.js';
19+
import { shouldRejectNodeVersion } from './version-guard.js';
20+
21+
// Guard: exit early with a clear message on unsupported Node.js versions,
22+
// rather than failing later with a cryptic ESM/runtime error.
23+
if (shouldRejectNodeVersion(process.versions.node)) {
24+
process.stderr.write(
25+
`Error: testsprite requires Node.js >= 20 (found ${process.versions.node}).\nInstall the latest LTS from https://nodejs.org\n`,
26+
);
27+
process.exit(1);
28+
}
1829

1930
const program = new Command();
2031

src/version-guard.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
MIN_SUPPORTED_NODE_MAJOR,
4+
parseMajorVersion,
5+
shouldRejectNodeVersion,
6+
} from './version-guard.js';
7+
8+
// These tests exercise the REAL guard functions used by src/index.ts,
9+
// imported here rather than re-declared, so a regression in the source is
10+
// actually caught.
11+
12+
describe('parseMajorVersion', () => {
13+
it('extracts the leading major from a semver string', () => {
14+
expect(parseMajorVersion('20.11.1')).toBe(20);
15+
expect(parseMajorVersion('18.0.0')).toBe(18);
16+
expect(parseMajorVersion('22.3.0')).toBe(22);
17+
});
18+
19+
it('returns NaN for a non-numeric version string', () => {
20+
expect(Number.isNaN(parseMajorVersion('not-a-version'))).toBe(true);
21+
});
22+
});
23+
24+
describe('shouldRejectNodeVersion', () => {
25+
it('rejects majors below the supported floor', () => {
26+
expect(shouldRejectNodeVersion('18.19.1')).toBe(true);
27+
expect(shouldRejectNodeVersion('16.20.2')).toBe(true);
28+
expect(shouldRejectNodeVersion('14.21.3')).toBe(true);
29+
});
30+
31+
it('accepts the supported floor and above', () => {
32+
expect(shouldRejectNodeVersion('20.0.0')).toBe(false);
33+
expect(shouldRejectNodeVersion('20.11.0')).toBe(false);
34+
expect(shouldRejectNodeVersion('21.0.0')).toBe(false);
35+
expect(shouldRejectNodeVersion('22.1.0')).toBe(false);
36+
});
37+
38+
it(`treats exactly ${MIN_SUPPORTED_NODE_MAJOR} as supported (boundary)`, () => {
39+
expect(shouldRejectNodeVersion(`${MIN_SUPPORTED_NODE_MAJOR}.0.0`)).toBe(false);
40+
expect(shouldRejectNodeVersion(`${MIN_SUPPORTED_NODE_MAJOR - 1}.9.9`)).toBe(true);
41+
});
42+
43+
it('does not reject an unparseable version (guard never blocks on garbage)', () => {
44+
expect(shouldRejectNodeVersion('not-a-version')).toBe(false);
45+
});
46+
47+
it('the running Node satisfies the guard (meta-test)', () => {
48+
// The test suite itself runs on a supported Node, so the guard must pass.
49+
expect(shouldRejectNodeVersion(process.versions.node)).toBe(false);
50+
});
51+
});

src/version-guard.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Node.js runtime version guard.
3+
*
4+
* The CLI targets modern Node (see `engines.node` in package.json). Running on
5+
* an older runtime tends to fail later with a cryptic ESM/syntax error, so the
6+
* entrypoint (`src/index.ts`) uses {@link shouldRejectNodeVersion} to exit early
7+
* with a clear, actionable message instead.
8+
*
9+
* The logic lives here (rather than inline) so it can be unit-tested against the
10+
* real implementation the entrypoint uses — not a copy.
11+
*/
12+
13+
/** Minimum Node.js major version supported by the CLI (matches package.json `engines.node`). */
14+
export const MIN_SUPPORTED_NODE_MAJOR = 20;
15+
16+
/**
17+
* Parse the leading major version number from a Node.js version string.
18+
*
19+
* @param nodeVersion - a dot-separated version string such as `process.versions.node`
20+
* (e.g. `"20.11.1"`). A leading `v` is not expected (Node does not include one here).
21+
* @returns the major version as a number, or `NaN` if the string has no numeric leading segment.
22+
*/
23+
export function parseMajorVersion(nodeVersion: string): number {
24+
return Number(nodeVersion.split('.')[0]);
25+
}
26+
27+
/**
28+
* Decide whether the given Node.js version is too old to run the CLI.
29+
*
30+
* A version is rejected only when its major number is a real value below
31+
* {@link MIN_SUPPORTED_NODE_MAJOR}. An unparseable string yields `NaN`, which is
32+
* treated as "do not reject" so the guard never blocks on a version string it
33+
* cannot understand (the runtime would surface any real incompatibility itself).
34+
*
35+
* @param nodeVersion - a `process.versions.node` style string (e.g. `"18.19.1"`).
36+
* @returns `true` when the runtime is below the supported floor and should be rejected.
37+
*/
38+
export function shouldRejectNodeVersion(nodeVersion: string): boolean {
39+
const major = parseMajorVersion(nodeVersion);
40+
return !Number.isNaN(major) && major < MIN_SUPPORTED_NODE_MAJOR;
41+
}

0 commit comments

Comments
 (0)