From cd9556a7fcb349361cd20f7e6cee8f31722c0e1c Mon Sep 17 00:00:00 2001 From: blut-agent Date: Mon, 11 May 2026 09:14:36 -0500 Subject: [PATCH] fix(cli): show friendly error when Node < 20 on startup Closes #23 --- packages/cli/src/check-version.test.ts | 72 ++++++++++++++++++++++++++ packages/cli/src/index.test.ts | 4 +- packages/cli/src/index.ts | 23 ++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/check-version.test.ts diff --git a/packages/cli/src/check-version.test.ts b/packages/cli/src/check-version.test.ts new file mode 100644 index 0000000..48ab891 --- /dev/null +++ b/packages/cli/src/check-version.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { checkNodeVersion } from './index.js'; + +describe('checkNodeVersion', () => { + let originalExit: typeof process.exit; + let originalNodeVersion: string; + let consoleErrorSpy: ReturnType>; + + beforeEach(() => { + originalExit = process.exit as unknown as typeof vi.fn; + originalNodeVersion = process.versions.node; + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + // Restore original values + (process.exit as unknown as typeof vi.fn).mockRestore?.(); + Object.defineProperty(process.versions, 'node', { + value: originalNodeVersion, + writable: true, + }); + }); + + it('passes when Node major version meets requirement', () => { + Object.defineProperty(process.versions, 'node', { value: '20.10.0', writable: true }); + vi.spyOn(process, 'exit').mockImplementation(() => {} as never); + + // Should not throw or exit + expect(() => checkNodeVersion()).not.toThrow(); + expect(process.exit).not.toHaveBeenCalled(); + }); + + it('passes when Node major version exceeds requirement', () => { + Object.defineProperty(process.versions, 'node', { value: '22.5.1', writable: true }); + vi.spyOn(process, 'exit').mockImplementation(() => {} as never); + + expect(() => checkNodeVersion()).not.toThrow(); + expect(process.exit).not.toHaveBeenCalled(); + }); + + it('exits with code 1 when Node major version is below requirement', () => { + Object.defineProperty(process.versions, 'node', { value: '18.19.0', writable: true }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {} as never); + + expect(() => checkNodeVersion()).toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('includes the detected version in the error message', () => { + Object.defineProperty(process.versions, 'node', { value: '16.20.2', writable: true }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {} as never); + + try { + checkNodeVersion(); + } catch { + // expected + } + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('v16.20.2'), + ); + }); + + it('exits with code 1 when Node major version equals 19 (below 20)', () => { + Object.defineProperty(process.versions, 'node', { value: '19.9.0', writable: true }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {} as never); + + expect(() => checkNodeVersion()).toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index a754092..37e2613 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { parseArgs } from './index.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { parseArgs, checkNodeVersion } from './index.js'; describe('parseArgs', () => { it('defaults to `start` with no args', () => { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fc15afb..7f0bbc3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,6 +12,27 @@ import { initCliTelemetry, captureCliEvent, getInstallId } from './telemetry.js' const pkg = require('../package.json') as { name: string; version: string }; const DEFAULT_PORT = 8787; +const REQUIRED_NODE_MAJOR = 20; + +/** + * Check that the running Node.js version meets the minimum requirement. + * Exits with code 1 and a friendly message if not. + */ +export function checkNodeVersion(): void { + const required = REQUIRED_NODE_MAJOR; + const current = process.versions.node; + const major = Number(current.split('.')[0]); + + if (major < required) { + console.error( + `error: Node.js ${required}+ is required (found v${current}).\n\n` + + `GranClaw requires Node.js ${required} or newer. Please upgrade:\n` + + ` https://nodejs.org\n\n` + + `You can check your version with: node --version`, + ); + process.exit(1); + } +} // ANSI colour helpers — no external deps const c = { @@ -299,6 +320,8 @@ function startServer(parsed: ParsedArgs): void { } export function main(argv: string[]): void { + checkNodeVersion(); + let parsed: ParsedArgs; try { parsed = parseArgs(argv);