chore(runtime): align Node.js minimum version (#1644)

* chore(runtime): align Node.js runtime requirements

* test(runtime): cover prefixed Node versions

* fix(runtime): check node executable in doctor
This commit is contained in:
Bogdan
2026-06-16 06:55:17 +08:00
committed by GitHub
parent 00ff6de4ca
commit d8dbf274b4
16 changed files with 271 additions and 31 deletions
+5
View File
@@ -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
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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/`
+2 -2
View File
@@ -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)
- [Windows aliases and launchers](windows-aliases-and-launchers.md)
+63 -1
View File
@@ -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',
})
})
})
+62 -8
View File
@@ -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 {
+1 -1
View File
@@ -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
+6 -6
View File
@@ -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
+2 -2
View File
@@ -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,
})
+1 -1
View File
@@ -1536,7 +1536,7 @@ async function* queryModel(
let stream: Stream<BetaRawMessageStreamEvent> | 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.
+4 -4
View File
@@ -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<void> {
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)
+2 -2
View File
@@ -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)),
+2 -1
View File
@@ -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))
}
+59
View File
@@ -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.',
})
})
})
+56
View File
@@ -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,
}
}