mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user