diff --git a/README.md b/README.md index ffd911423..6b9817288 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,9 @@ OpenClaude is also mirrored to GitLawb: ### Install +OpenClaude requires Node.js `>=22.0.0` for npm installs and runtime. Bun is +only needed for source builds and local development. + ```bash npm install -g @gitlawb/openclaude@latest ``` @@ -330,6 +333,8 @@ npm run dev:grpc:cli ## Source Build And Local Development +Use Node.js `>=22.0.0` and Bun `1.3.13` or newer for source builds. + ```bash bun install bun run build diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 677369580..adc947e1a 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -4,6 +4,9 @@ This guide is for users who want source builds, Bun workflows, provider profiles ## Install Options +OpenClaude requires Node.js `>=22.0.0` for npm installs and runtime. Bun is +only required when building or running from source. + ### Option A: npm ```bash @@ -12,7 +15,7 @@ npm install -g @gitlawb/openclaude@latest ### Option B: From source with Bun -Use Bun `1.3.13` or newer for source builds on Windows. Older Bun versions can fail during `bun run build`. +Use Bun `1.3.13` or newer for source builds. Older Bun versions can fail during `bun run build`. ```bash git clone https://github.com/Gitlawb/openclaude.git diff --git a/docs/non-technical-setup.md b/docs/non-technical-setup.md index 43553e12f..6d970f009 100644 --- a/docs/non-technical-setup.md +++ b/docs/non-technical-setup.md @@ -22,7 +22,7 @@ For most first-time users, OpenAI is the easiest option. You need: -1. Node.js 20 or newer installed +1. Node.js 22 LTS or newer installed 2. A terminal window 3. An API key from your provider, unless you are using a local model like Ollama diff --git a/docs/quick-start-mac-linux.md b/docs/quick-start-mac-linux.md index 182bfc8d4..f82ac7707 100644 --- a/docs/quick-start-mac-linux.md +++ b/docs/quick-start-mac-linux.md @@ -4,7 +4,7 @@ This guide uses a standard shell such as Terminal, iTerm, bash, or zsh. ## 1. Install Node.js -Install Node.js 20 or newer from: +Install Node.js 22 LTS or newer from: - `https://nodejs.org/` diff --git a/docs/quick-start-windows.md b/docs/quick-start-windows.md index 1dbe59f48..cea25f216 100644 --- a/docs/quick-start-windows.md +++ b/docs/quick-start-windows.md @@ -4,7 +4,7 @@ This guide uses Windows PowerShell. ## 1. Install Node.js -Install Node.js 20 or newer from: +Install Node.js 22 LTS or newer from: - `https://nodejs.org/` @@ -164,4 +164,4 @@ For advanced provider setup, custom endpoints, environment variables, and enterp For Windows helper aliases and launcher shortcuts such as `oc`, `oc-init`, `oc-local`, `oc-provider`, and `oc-check`, see: -- [Windows aliases and launchers](windows-aliases-and-launchers.md) \ No newline at end of file +- [Windows aliases and launchers](windows-aliases-and-launchers.md) diff --git a/scripts/system-check.test.ts b/scripts/system-check.test.ts index 7290ba630..46b76755f 100644 --- a/scripts/system-check.test.ts +++ b/scripts/system-check.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test' -import { formatReachabilityFailureDetail } from './system-check.ts' +import { + checkNodeVersion, + formatReachabilityFailureDetail, + readNodeExecutableVersion, +} from './system-check.ts' describe('formatReachabilityFailureDetail', () => { test('returns generic failure detail for non-codex transport', () => { @@ -57,3 +61,61 @@ describe('formatReachabilityFailureDetail', () => { ) }) }) + +describe('checkNodeVersion', () => { + test('reads the Node.js version from the node executable output', () => { + const probe = readNodeExecutableVersion(() => ({ + status: 0, + stdout: 'v22.0.0\n', + stderr: '', + error: undefined, + })) + + expect(probe).toEqual({ + ok: true, + version: 'v22.0.0', + }) + }) + + test('checks the probed node executable version', () => { + expect(checkNodeVersion({ ok: true, version: 'v20.11.1' })).toEqual({ + ok: false, + label: 'Node.js version', + detail: + 'Detected 20.11.1. OpenClaude requires Node.js >=22.0.0. Install Node 22 LTS or newer, then reinstall/re-run OpenClaude.', + }) + }) + + test('reports a missing node executable as a Node.js version failure', () => { + const probe = readNodeExecutableVersion(() => ({ + status: null, + stdout: '', + stderr: '', + error: new Error('spawn node ENOENT'), + })) + + expect(checkNodeVersion(probe)).toEqual({ + ok: false, + label: 'Node.js version', + detail: + 'Unable to run `node --version`: spawn node ENOENT. OpenClaude requires Node.js >=22.0.0 on PATH.', + }) + }) + + test('uses the shared Node.js minimum in doctor failures', () => { + expect(checkNodeVersion('20.11.1')).toEqual({ + ok: false, + label: 'Node.js version', + detail: + 'Detected 20.11.1. OpenClaude requires Node.js >=22.0.0. Install Node 22 LTS or newer, then reinstall/re-run OpenClaude.', + }) + }) + + test('passes supported Node.js versions', () => { + expect(checkNodeVersion('22.0.0')).toEqual({ + ok: true, + label: 'Node.js version', + detail: '22.0.0', + }) + }) +}) diff --git a/scripts/system-check.ts b/scripts/system-check.ts index 9fa813fb8..c87e0833f 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -13,6 +13,10 @@ import { } from '../src/utils/providerDiscovery.js' import { DEFAULT_GEMINI_MODEL } from '../src/utils/providerProfile.js' import { redactUrlForDisplay } from '../src/utils/urlRedaction.js' +import { + MIN_NODE_ENGINE_RANGE, + checkSupportedNodeVersion, +} from '../src/utils/nodeRuntime.js' type CheckResult = { ok: boolean @@ -20,6 +24,16 @@ type CheckResult = { detail?: string } +type NodeExecutableVersionProbe = + | { + ok: true + version: string + } + | { + ok: false + detail: string + } + type CliOptions = { json: boolean outFile: string | null @@ -89,18 +103,58 @@ export function formatReachabilityFailureDetail( return `${base}${bodySuffix} Hint: model alias "${request.requestedModel}" resolved to "${request.resolvedModel}", which this ChatGPT account does not currently allow. Try "codexplan" or another entitled Codex model.` } -function checkNodeVersion(): CheckResult { - const raw = process.versions.node - const major = Number(raw.split('.')[0] ?? '0') - if (Number.isNaN(major)) { - return fail('Node.js version', `Could not parse version: ${raw}`) +export function readNodeExecutableVersion( + spawn = spawnSync, +): NodeExecutableVersionProbe { + const result = spawn('node', ['--version'], { + encoding: 'utf8', + }) + + if (result.error) { + return { + ok: false, + detail: `Unable to run \`node --version\`: ${result.error.message}. OpenClaude requires Node.js ${MIN_NODE_ENGINE_RANGE} on PATH.`, + } } - if (major < 20) { - return fail('Node.js version', `Detected ${raw}. Require >= 20.`) + if (result.status !== 0) { + const output = (result.stderr || result.stdout || '').trim() + const suffix = output ? `: ${output}` : `: exit code ${result.status ?? 'unknown'}` + return { + ok: false, + detail: `Unable to run \`node --version\`${suffix}. OpenClaude requires Node.js ${MIN_NODE_ENGINE_RANGE} on PATH.`, + } } - return pass('Node.js version', raw) + const version = (result.stdout || result.stderr || '').trim() + if (!version) { + return { + ok: false, + detail: `Unable to read Node.js version from \`node --version\`. OpenClaude requires Node.js ${MIN_NODE_ENGINE_RANGE} on PATH.`, + } + } + + return { + ok: true, + version, + } +} + +export function checkNodeVersion( + raw: string | NodeExecutableVersionProbe = readNodeExecutableVersion(), +): CheckResult { + if (typeof raw !== 'string' && !raw.ok) { + return fail('Node.js version', raw.detail) + } + + const versionCheck = checkSupportedNodeVersion( + typeof raw === 'string' ? raw : raw.version, + ) + if (!versionCheck.ok) { + return fail('Node.js version', versionCheck.message) + } + + return pass('Node.js version', versionCheck.version) } function checkBunRuntime(): CheckResult { diff --git a/src/bootstrap/state.ts b/src/bootstrap/state.ts index e8db6587a..2b46c6581 100644 --- a/src/bootstrap/state.ts +++ b/src/bootstrap/state.ts @@ -397,7 +397,7 @@ const STATE: State = getInitialState() * * **Runtime Requirement:** Uses Node.js `async_hooks.AsyncLocalStorage`. * Not available in browsers or non-Node JavaScript environments. - * SDK consumers must run in a Node.js runtime (Node.js 12.17.0+ or 14.0.0+). + * SDK consumers must run in OpenClaude's supported Node.js runtime (>=22.0.0). */ type SdkContext = { sessionId: SessionId diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 397f570db..f615709db 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -1,14 +1,14 @@ import { feature } from 'bun:bundle'; -// OpenClaude: polyfill globalThis.File for Node < 20. -// undici v7 references `File` at module evaluation time (webidl type -// assertions). Node 18 lacks the global, causing a ReferenceError inside -// the bundled __commonJS require chain which deadlocks the process when a -// proxy is configured (configureGlobalAgents → require_undici). +// Defensive compatibility guard for environments where globalThis.File is +// unexpectedly absent. OpenClaude's supported runtime is Node >=22; this is +// not a Node 18 support guarantee. The guard is harmless on supported Node +// versions and prevents undici's module evaluation from throwing in unusual +// embedded/runtime setups. // eslint-disable-next-line custom-rules/no-top-level-side-effects if (typeof globalThis.File === 'undefined') { try { - // Node 18.13+ exposes File in node:buffer but not as a global. + // Some runtimes expose File in node:buffer but not as a global. // eslint-disable-next-line @typescript-eslint/no-require-imports const { File: NodeFile } = require('node:buffer') globalThis.File = NodeFile diff --git a/src/query.ts b/src/query.ts index ad34ebfb6..49f442db7 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1215,8 +1215,8 @@ async function* queryLoop( // Surface the real error instead of a misleading "[Request interrupted // by user]" — this path is a model/runtime failure, not a user action. - // SDK consumers were seeing phantom interrupts on e.g. Node 18's missing - // Array.prototype.with(), masking the actual cause. + // SDK consumers were seeing phantom interrupts on unsupported runtimes + // with missing built-ins, masking the actual cause. yield createAssistantAPIErrorMessage({ content: errorMessage, }) diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 12a8a5157..aa3a79746 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -1536,7 +1536,7 @@ async function* queryModel( let stream: Stream | undefined = undefined let streamRequestId: string | null | undefined = undefined let clientRequestId: string | undefined = undefined - // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins -- Response is available in Node 18+ and is used by the SDK + // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins -- Response is available in supported Node runtimes and is used by the SDK let streamResponse: Response | undefined = undefined // Release all stream resources to prevent native memory leaks. diff --git a/src/setup.ts b/src/setup.ts index c85e78823..4be37c318 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -42,6 +42,7 @@ import { checkAndRestoreITerm2Backup } from './utils/iTermBackup.js' import { logError } from './utils/log.js' import { getRecentActivity } from './utils/logoV2Utils.js' import { lockCurrentVersion } from './utils/nativeInstaller/index.js' +import { checkSupportedNodeVersion } from './utils/nodeRuntime.js' import type { PermissionMode } from './utils/permissions/PermissionMode.js' import { getPlanSlug } from './utils/plans.js' import { saveWorktreeState } from './utils/sessionStorage.js' @@ -66,13 +67,12 @@ export async function setup( ): Promise { logForDiagnosticsNoPII('info', 'setup_started') - // Check for Node.js version < 18 - const nodeVersion = process.version.match(/^v(\d+)\./)?.[1] - if (!nodeVersion || parseInt(nodeVersion) < 18) { + const nodeVersion = checkSupportedNodeVersion(process.version) + if (!nodeVersion.ok) { // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( chalk.bold.red( - 'Error: OpenClaude requires Node.js version 18 or higher.', + `Error: ${nodeVersion.message}`, ), ) process.exit(1) diff --git a/src/tools.ts b/src/tools.ts index ecccb60c8..a4ed5ebb9 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -354,8 +354,8 @@ export function assembleToolPool( // sort would interleave MCP tools into built-ins and invalidate all downstream // cache keys whenever an MCP tool sorts between existing built-ins. uniqBy // preserves insertion order, so built-ins win on name conflict. - // Avoid Array.toSorted (Node 20+) — we support Node 18. builtInTools is - // readonly so copy-then-sort; allowedMcpTools is a fresh .filter() result. + // Keep copy-then-sort because builtInTools is readonly; allowedMcpTools is a + // fresh .filter() result. const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name) return uniqBy( [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)), diff --git a/src/utils/filePersistence/outputsScanner.ts b/src/utils/filePersistence/outputsScanner.ts index 5a4e7a212..74051b421 100644 --- a/src/utils/filePersistence/outputsScanner.ts +++ b/src/utils/filePersistence/outputsScanner.ts @@ -85,7 +85,8 @@ export async function findModifiedFiles( continue } if (entry.isFile()) { - // entry.parentPath is available in Node 20+, fallback to entry.path for older versions + // entry.parentPath is the supported Node path; entry.path keeps test + // doubles and unusual embedded runtimes defensive. const parentPath = getEntryParentPath(entry, outputsDir) filePaths.push(path.join(parentPath, entry.name)) } diff --git a/src/utils/nodeRuntime.test.ts b/src/utils/nodeRuntime.test.ts new file mode 100644 index 000000000..79b04f6a4 --- /dev/null +++ b/src/utils/nodeRuntime.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test' + +import { + MIN_NODE_ENGINE_RANGE, + MIN_NODE_MAJOR, + MIN_NODE_VERSION, + checkSupportedNodeVersion, +} from './nodeRuntime.js' + +describe('node runtime contract', () => { + test('matches the package engines contract', async () => { + const packageJson = await Bun.file( + new URL('../../package.json', import.meta.url), + ).json() + + expect(MIN_NODE_MAJOR).toBe(22) + expect(MIN_NODE_VERSION).toBe('22.0.0') + expect(MIN_NODE_ENGINE_RANGE).toBe('>=22.0.0') + expect(packageJson.engines.node).toBe(MIN_NODE_ENGINE_RANGE) + }) + + test('accepts supported Node versions', () => { + expect(checkSupportedNodeVersion('v22.0.0')).toEqual({ + ok: true, + version: '22.0.0', + major: 22, + }) + expect(checkSupportedNodeVersion('22.0.0')).toEqual({ + ok: true, + version: '22.0.0', + major: 22, + }) + expect(checkSupportedNodeVersion('25.5.0')).toEqual({ + ok: true, + version: '25.5.0', + major: 25, + }) + }) + + test('rejects unsupported Node versions with an actionable message', () => { + expect(checkSupportedNodeVersion('20.11.1')).toEqual({ + ok: false, + version: '20.11.1', + major: 20, + message: + 'Detected 20.11.1. OpenClaude requires Node.js >=22.0.0. Install Node 22 LTS or newer, then reinstall/re-run OpenClaude.', + }) + }) + + test('rejects malformed Node versions with the same required minimum', () => { + expect(checkSupportedNodeVersion('nightly')).toEqual({ + ok: false, + version: 'nightly', + major: null, + message: + 'Could not parse Node.js version: nightly. OpenClaude requires Node.js >=22.0.0.', + }) + }) +}) diff --git a/src/utils/nodeRuntime.ts b/src/utils/nodeRuntime.ts new file mode 100644 index 000000000..be1fb6eba --- /dev/null +++ b/src/utils/nodeRuntime.ts @@ -0,0 +1,56 @@ +export const MIN_NODE_MAJOR = 22 +export const MIN_NODE_VERSION = '22.0.0' +export const MIN_NODE_ENGINE_RANGE = `>=${MIN_NODE_VERSION}` + +export type NodeVersionCheckResult = + | { + ok: true + version: string + major: number + } + | { + ok: false + version: string + major: number | null + message: string + } + +function normalizeNodeVersion(rawVersion: string): string { + return rawVersion.trim().replace(/^v/, '') +} + +function parseNodeMajor(version: string): number | null { + const major = Number(version.split('.')[0] ?? '') + return Number.isInteger(major) ? major : null +} + +export function checkSupportedNodeVersion( + rawVersion: string, +): NodeVersionCheckResult { + const version = normalizeNodeVersion(rawVersion) + const major = parseNodeMajor(version) + + if (major === null) { + return { + ok: false, + version, + major, + message: `Could not parse Node.js version: ${version}. OpenClaude requires Node.js ${MIN_NODE_ENGINE_RANGE}.`, + } + } + + if (major < MIN_NODE_MAJOR) { + return { + ok: false, + version, + major, + message: `Detected ${version}. OpenClaude requires Node.js ${MIN_NODE_ENGINE_RANGE}. Install Node ${MIN_NODE_MAJOR} LTS or newer, then reinstall/re-run OpenClaude.`, + } + } + + return { + ok: true, + version, + major, + } +}