diff --git a/SECURITY.md b/SECURITY.md index 09cead9869dd..ec441ea94ca2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -316,7 +316,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for * ### Node.js Version -OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended default runtime for new installs. The minimum version includes important security patches: +OpenClaw requires **Node.js 22.19+, Node.js 23.11+, or Node.js 24+**. Node 24 is the recommended default runtime for new installs. The minimum supported Node 22 version includes important security patches: - CVE-2025-59466: async_hooks DoS vulnerability - CVE-2026-21636: Permission model bypass vulnerability @@ -324,7 +324,7 @@ OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended Verify your Node.js version: ```bash -node --version # Should be v22.19.0 or later +node --version # Should be v22.19+, v23.11+, or v24+ ``` ### Docker Security diff --git a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift index 2f799993dcdc..ea107456d53c 100644 --- a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift +++ b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift @@ -46,7 +46,6 @@ enum RuntimeResolutionError: Error { case unsupported( kind: RuntimeKind, found: RuntimeVersion, - required: RuntimeVersion, path: String, searchPaths: [String]) case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String]) @@ -54,7 +53,19 @@ enum RuntimeResolutionError: Error { enum RuntimeLocator { private static let logger = Logger(subsystem: "ai.openclaw", category: "runtime") - private static let minNode = RuntimeVersion(major: 22, minor: 19, patch: 0) + private static let minNode22 = RuntimeVersion(major: 22, minor: 19, patch: 0) + private static let minNode23 = RuntimeVersion(major: 23, minor: 11, patch: 0) + private static let supportedNodeRange = ">=22.19.0 <23 or >=23.11.0" + + static func isSupportedNodeVersion(_ version: RuntimeVersion) -> Bool { + if version.major == self.minNode22.major { + return version >= self.minNode22 + } + if version.major == self.minNode23.major { + return version >= self.minNode23 + } + return version.major > self.minNode23.major + } static func resolve( searchPaths: [String] = CommandResolver.preferredPaths()) -> Result @@ -75,11 +86,10 @@ enum RuntimeLocator { guard let parsed = RuntimeVersion.from(string: rawVersion) else { return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths)) } - guard parsed >= self.minNode else { + guard self.isSupportedNodeVersion(parsed) else { return .failure(.unsupported( kind: runtime, found: parsed, - required: self.minNode, path: binary, searchPaths: searchPaths)) } @@ -91,13 +101,13 @@ enum RuntimeLocator { switch error { case let .notFound(searchPaths): [ - "openclaw needs Node >=22.19.0 but found no runtime.", + "openclaw needs Node \(self.supportedNodeRange) but found no runtime.", "PATH searched: \(searchPaths.joined(separator: ":"))", "Install Node: https://nodejs.org/en/download", ].joined(separator: "\n") - case let .unsupported(kind, found, required, path, searchPaths): + case let .unsupported(kind, found, path, searchPaths): [ - "Found \(kind.rawValue) \(found) at \(path) but need >= \(required).", + "Found \(kind.rawValue) \(found) at \(path) but need \(self.supportedNodeRange).", "PATH searched: \(searchPaths.joined(separator: ":"))", "Upgrade Node and rerun openclaw.", ].joined(separator: "\n") @@ -105,7 +115,7 @@ enum RuntimeLocator { [ "Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).", "PATH searched: \(searchPaths.joined(separator: ":"))", - "Try reinstalling or pinning a supported version (Node >=22.19.0).", + "Try reinstalling or pinning a supported version (Node \(self.supportedNodeRange)).", ].joined(separator: "\n") } } diff --git a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift index 764c81f51493..968f6c03e9ac 100644 --- a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift @@ -35,15 +35,44 @@ struct RuntimeLocatorTests { """ let node = try self.makeTempExecutable(contents: script) let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) - guard case let .failure(.unsupported(_, found, required, path, _)) = result else { + guard case let .failure(.unsupported(_, found, path, _)) = result else { Issue.record("Expected unsupported error, got \(result)") return } #expect(found == RuntimeVersion(major: 22, minor: 18, patch: 9)) - #expect(required == RuntimeVersion(major: 22, minor: 19, patch: 0)) #expect(path == node.path) } + @Test func `resolve rejects early node 23`() throws { + let script = """ + #!/bin/sh + echo v23.7.0 + """ + let node = try self.makeTempExecutable(contents: script) + let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) + guard case let .failure(.unsupported(_, found, path, _)) = result else { + Issue.record("Expected unsupported error, got \(result)") + return + } + #expect(found == RuntimeVersion(major: 23, minor: 7, patch: 0)) + #expect(path == node.path) + } + + @Test func `resolve accepts node 23 with statement columns`() throws { + let script = """ + #!/bin/sh + echo v23.11.0 + """ + let node = try self.makeTempExecutable(contents: script) + let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) + guard case let .success(res) = result else { + Issue.record("Expected success, got \(result)") + return + } + #expect(res.path == node.path) + #expect(res.version == RuntimeVersion(major: 23, minor: 11, patch: 0)) + } + @Test func `resolve fails when too old`() throws { let script = """ #!/bin/sh @@ -51,7 +80,7 @@ struct RuntimeLocatorTests { """ let node = try self.makeTempExecutable(contents: script) let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) - guard case let .failure(.unsupported(_, found, _, path, _)) = result else { + guard case let .failure(.unsupported(_, found, path, _)) = result else { Issue.record("Expected unsupported error, got \(result)") return } @@ -76,7 +105,7 @@ struct RuntimeLocatorTests { @Test func `describe failure includes paths`() { let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"])) - #expect(msg.contains("Node >=22.19.0")) + #expect(msg.contains("Node >=22.19.0 <23 or >=23.11.0")) #expect(msg.contains("PATH searched: /tmp/a:/tmp/b")) let parseMsg = RuntimeLocator.describeFailure( @@ -85,7 +114,7 @@ struct RuntimeLocatorTests { raw: "garbage", path: "/usr/local/bin/node", searchPaths: ["/usr/local/bin"])) - #expect(parseMsg.contains("Node >=22.19.0")) + #expect(parseMsg.contains("Node >=22.19.0 <23 or >=23.11.0")) } @Test func `runtime version parses with leading V and metadata`() { diff --git a/docs/install/node.md b/docs/install/node.md index ed9b2bc4cf4e..6dee5539c054 100644 --- a/docs/install/node.md +++ b/docs/install/node.md @@ -7,7 +7,7 @@ read_when: - "npm install -g fails with permissions or PATH issues" --- -OpenClaw requires **Node 22.19 or newer**. **Node 24 is the default and recommended runtime** for installs, CI, and release workflows. Node 22 remains supported via the active LTS line. The [installer script](/install#alternative-install-methods) will detect and install Node automatically - this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs). +OpenClaw requires **Node 22.19+, Node 23.11+, or Node 24+**. **Node 24 is the default and recommended runtime** for installs, CI, and release workflows. Node 22 remains supported via the active LTS line. The [installer script](/install#alternative-install-methods) will detect and install Node automatically - this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs). ## Check your version @@ -15,7 +15,7 @@ OpenClaw requires **Node 22.19 or newer**. **Node 24 is the default and recommen node -v ``` -If this prints `v24.x.x` or higher, you're on the recommended default. If it prints `v22.19.x` or higher, you're on the supported Node 22 LTS path, but we still recommend upgrading to Node 24 when convenient. If Node isn't installed or the version is too old, pick an install method below. +If this prints `v24.x.x` or higher, you're on the recommended default. If it prints `v22.19.x` or higher, you're on the supported Node 22 LTS path, but we still recommend upgrading to Node 24 when convenient. Node 23 versions before `v23.11.0` are unsupported. If Node isn't installed or the version is outside the supported range, pick an install method below. ## Install Node diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index 66ba27573098..7bf8b68fc436 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -25,7 +25,7 @@ Bare package specs still install from npm during the launch cutover. Use the ## Requirements -- Use Node 22.19 or newer and a package manager such as `npm` or `pnpm`. +- Use Node 22.19+, Node 23.11+, or Node 24+ and a package manager such as `npm` or `pnpm`. - Be familiar with TypeScript ESM modules. - For in-repo bundled plugin work, clone the repository and run `pnpm install`. Source-checkout plugin development is pnpm-only because OpenClaw loads bundled diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 97ac9fb124ed..6378bad9fa55 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -69,7 +69,7 @@ "openclaw": "openclaw.mjs" }, "engines": { - "node": ">=22.19.0" + "node": ">=22.19.0 <23 || >=23.11.0" }, "optionalDependencies": { "sqlite-vec": "0.1.9" diff --git a/openclaw.mjs b/openclaw.mjs index 77f48f78df3c..9a79a2dd79bd 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -10,7 +10,9 @@ import { fileURLToPath } from "node:url"; const MIN_NODE_MAJOR = 22; const MIN_NODE_MINOR = 19; -const MIN_NODE_VERSION = `${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}`; +const MIN_NODE_23_MINOR = 11; +const RECOMMENDED_NODE_MAJOR = 24; +const SUPPORTED_NODE_RANGE = ">=22.19.0 <23 or >=23.11.0"; const MIN_COMPILE_CACHE_NODE_24_MINOR = 15; const COMPILE_CACHE_DISABLED_RESPAWNED_ENV = "OPENCLAW_COMPILE_CACHE_DISABLED_RESPAWNED"; @@ -22,9 +24,15 @@ const parseNodeVersion = (rawVersion) => { }; }; -const isSupportedNodeVersion = (version) => - version.major > MIN_NODE_MAJOR || - (version.major === MIN_NODE_MAJOR && version.minor >= MIN_NODE_MINOR); +const isSupportedNodeVersion = (version) => { + if (version.major === MIN_NODE_MAJOR) { + return version.minor >= MIN_NODE_MINOR; + } + if (version.major === 23) { + return version.minor >= MIN_NODE_23_MINOR; + } + return version.major > 23; +}; const isNodeVersionAffectedByCompileCacheDeadlock = (rawVersion) => { const version = parseNodeVersion(rawVersion); @@ -41,11 +49,11 @@ const ensureSupportedNodeVersion = () => { } process.stderr.write( - `openclaw: Node.js v${MIN_NODE_VERSION}+ is required (current: v${process.versions.node}).\n` + + `openclaw: Node.js ${SUPPORTED_NODE_RANGE} is required (current: v${process.versions.node}).\n` + "If you use nvm, run:\n" + - ` nvm install ${MIN_NODE_MAJOR}\n` + - ` nvm use ${MIN_NODE_MAJOR}\n` + - ` nvm alias default ${MIN_NODE_MAJOR}\n`, + ` nvm install ${RECOMMENDED_NODE_MAJOR}\n` + + ` nvm use ${RECOMMENDED_NODE_MAJOR}\n` + + ` nvm alias default ${RECOMMENDED_NODE_MAJOR}\n`, ); process.exit(1); }; diff --git a/package.json b/package.json index deb59ab8a6a4..c2033685a2e8 100644 --- a/package.json +++ b/package.json @@ -2060,7 +2060,7 @@ "sqlite-vec": "0.1.9" }, "engines": { - "node": ">=22.19.0" + "node": ">=22.19.0 <23 || >=23.11.0" }, "packageManager": "pnpm@11.2.2+sha512.36e6621fad506178936455e70247b8808ef4ec25797a9f437a93281a020484e2607f6a469a22e982987c3dbb8866e3071514ab10a4a1749e06edcd1ec118436f" } diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 4513bff2f908..ee8dd0956d78 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -58,6 +58,8 @@ if [[ -n "${OPENCLAW_NODE_VERSION:-}" ]]; then NODE_VERSION_REQUESTED=1 fi MIN_NODE_VERSION="22.19.0" +MIN_NODE_23_VERSION="23.11.0" +SUPPORTED_NODE_VERSION_LABEL="Node 22.19+, Node 23.11+, or Node 24+" APK_NODE_BIN_DIR="/usr/bin" NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}" INSTALL_METHOD="${OPENCLAW_INSTALL_METHOD:-npm}" @@ -423,11 +425,14 @@ linked_node_is_usable() { current_version="$("$(node_bin)" -v 2>/dev/null || echo "")" required_version="$(required_node_version)" + if ! node_version_is_supported "$current_version"; then + return 1 + fi if ! semver_at_least "$current_version" "$required_version"; then return 1 fi - "$(node_bin)" -e "require('node:sqlite')" >/dev/null 2>&1 + "$(node_bin)" -e "const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(':memory:'); const statement = db.prepare('SELECT 1'); const supported = typeof statement.columns === 'function'; db.close(); if (!supported) process.exit(1);" >/dev/null 2>&1 } semver_at_least() { @@ -460,8 +465,32 @@ semver_at_least() { ((version_patch >= required_patch)) } +node_version_is_supported() { + local version="${1#v}" + local major minor patch + + IFS=. read -r major minor patch <<<"$version" + minor="${minor:-0}" + patch="${patch:-0}" + for part in "$major" "$minor" "$patch"; do + if [[ ! "$part" =~ ^[0-9]+$ ]]; then + return 1 + fi + done + + if ((major == 22)); then + semver_at_least "$version" "$MIN_NODE_VERSION" + return + fi + if ((major == 23)); then + semver_at_least "$version" "$MIN_NODE_23_VERSION" + return + fi + ((major > 23)) +} + required_node_version() { - if [[ "$NODE_VERSION_REQUESTED" == "1" ]] && semver_at_least "$NODE_VERSION" "$MIN_NODE_VERSION"; then + if [[ "$NODE_VERSION_REQUESTED" == "1" ]] && node_version_is_supported "$NODE_VERSION"; then printf '%s\n' "$NODE_VERSION" return fi @@ -767,6 +796,10 @@ install_node() { local expected_sha local actual_sha + if ! node_version_is_supported "$NODE_VERSION"; then + fail "Node ${NODE_VERSION} is unsupported; use ${SUPPORTED_NODE_VERSION_LABEL}." + fi + os="$(os_detect)" arch="$(arch_detect)" dir="$(node_dir)" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 9a55003b5ed0..c4b059ee1b05 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -149,18 +149,33 @@ if ([string]::IsNullOrWhiteSpace($GitDir)) { } # Check for Node.js +function Test-NodeVersionSupported { + param([string]$Version) + + $versionMatch = [regex]::Match($Version, '^v?(?\d+)\.(?\d+)\.') + if (-not $versionMatch.Success) { + return $false + } + $major = [int]$versionMatch.Groups["major"].Value + $minor = [int]$versionMatch.Groups["minor"].Value + if ($major -eq 22) { + return ($minor -ge 19) + } + if ($major -eq 23) { + return ($minor -ge 11) + } + return ($major -gt 23) +} + function Check-Node { try { $nodeVersion = (node -v 2>$null) if ($nodeVersion) { - $versionMatch = [regex]::Match($nodeVersion, '^v(?\d+)\.(?\d+)\.') - $major = if ($versionMatch.Success) { [int]$versionMatch.Groups["major"].Value } else { 0 } - $minor = if ($versionMatch.Success) { [int]$versionMatch.Groups["minor"].Value } else { 0 } - if (($major -gt 22) -or (($major -eq 22) -and ($minor -ge 19))) { + if (Test-NodeVersionSupported -Version $nodeVersion) { Write-Host "[OK] Node.js $nodeVersion found" -ForegroundColor Green return $true } else { - Write-Host "[!] Node.js $nodeVersion found, but v22.19+ required" -ForegroundColor Yellow + Write-Host "[!] Node.js $nodeVersion found, but Node 22.19+, Node 23.11+, or Node 24+ is required" -ForegroundColor Yellow return $false } } diff --git a/scripts/install.sh b/scripts/install.sh index 0d6cf19584ac..b63b821a4301 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -19,7 +19,8 @@ DEFAULT_TAGLINE="All your chats, one OpenClaw." NODE_DEFAULT_MAJOR=24 NODE_MIN_MAJOR=22 NODE_MIN_MINOR=19 -NODE_MIN_VERSION="${NODE_MIN_MAJOR}.${NODE_MIN_MINOR}" +NODE_23_MIN_MINOR=11 +NODE_SUPPORTED_VERSION_LABEL="22.19+, 23.11+, or 24+" ORIGINAL_PATH="${PATH:-}" @@ -1479,23 +1480,32 @@ node_major_version() { return 1 } -node_is_at_least_required() { +node_version_components_are_supported() { + local major="$1" + local minor="$2" + if [[ "$major" -eq "$NODE_MIN_MAJOR" && "$minor" -ge "$NODE_MIN_MINOR" ]]; then + return 0 + fi + if [[ "$major" -eq 23 && "$minor" -ge "$NODE_23_MIN_MINOR" ]]; then + return 0 + fi + if [[ "$major" -gt 23 ]]; then + return 0 + fi + return 1 +} + +node_is_supported() { local version_components major minor version_components="$(parse_node_version_components || true)" read -r major minor <<< "$version_components" if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ ]]; then return 1 fi - if [[ "$major" -gt "$NODE_MIN_MAJOR" ]]; then - return 0 - fi - if [[ "$major" -eq "$NODE_MIN_MAJOR" && "$minor" -ge "$NODE_MIN_MINOR" ]]; then - return 0 - fi - return 1 + node_version_components_are_supported "$major" "$minor" } -node_binary_is_at_least_required() { +node_binary_is_supported() { local node_bin="$1" local version_components major minor version_components="$(parse_node_version_components_for_binary "$node_bin" || true)" @@ -1503,13 +1513,7 @@ node_binary_is_at_least_required() { if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ ]]; then return 1 fi - if [[ "$major" -gt "$NODE_MIN_MAJOR" ]]; then - return 0 - fi - if [[ "$major" -eq "$NODE_MIN_MAJOR" && "$minor" -ge "$NODE_MIN_MINOR" ]]; then - return 0 - fi - return 1 + node_version_components_are_supported "$major" "$minor" } prepend_path_dir() { @@ -1585,7 +1589,7 @@ promote_supported_node_binary() { continue fi seen_dirs="${seen_dirs}${dir}:" - if node_binary_is_at_least_required "$candidate"; then + if node_binary_is_supported "$candidate"; then prepend_path_dir "$dir" || continue if [[ "$OS" == "linux" ]]; then persist_shell_path_prepend "$dir" || true @@ -1633,9 +1637,7 @@ ensure_macos_default_node_active() { fi fi - local major="" - major="$(node_major_version || true)" - if [[ -n "$major" && "$major" -ge 22 ]]; then + if node_is_supported; then return 0 fi @@ -1658,7 +1660,7 @@ ensure_macos_default_node_active() { ensure_default_node_active_shell() { promote_supported_node_binary || true - if node_is_at_least_required; then + if node_is_supported; then return 0 fi @@ -1666,7 +1668,7 @@ ensure_default_node_active_shell() { active_path="$(command -v node 2>/dev/null || echo "not found")" active_version="$(node -v 2>/dev/null || echo "missing")" - ui_error "Active Node.js must be v${NODE_MIN_VERSION}+ but this shell is using ${active_version} (${active_path})" + ui_error "Active Node.js must be ${NODE_SUPPORTED_VERSION_LABEL} but this shell is using ${active_version} (${active_path})" print_active_node_paths || true local nvm_detected=0 @@ -1686,7 +1688,7 @@ ensure_default_node_active_shell() { echo "Then open a new shell and rerun:" echo " curl -fsSL https://openclaw.ai/install.sh | bash" else - echo "Install/select Node.js ${NODE_DEFAULT_MAJOR} (or Node ${NODE_MIN_VERSION}+ minimum) and ensure it is first on PATH, then rerun installer." + echo "Install/select Node.js ${NODE_DEFAULT_MAJOR} and ensure it is first on PATH, then rerun installer." fi return 1 @@ -1716,15 +1718,15 @@ load_nvm_for_node_detection() { check_node() { if command -v node &> /dev/null; then NODE_VERSION="$(node_major_version || true)" - if node_is_at_least_required; then + if node_is_supported; then ui_success "Node.js v$(node -v | cut -d'v' -f2) found" print_active_node_paths || true return 0 else if [[ -n "$NODE_VERSION" ]]; then - ui_info "Node.js $(node -v) found, upgrading to v${NODE_MIN_VERSION}+" + ui_info "Node.js $(node -v) found, upgrading to a supported version" else - ui_info "Node.js found but version could not be parsed; reinstalling v${NODE_MIN_VERSION}+" + ui_info "Node.js found but version could not be parsed; reinstalling a supported version" fi return 1 fi @@ -1736,11 +1738,11 @@ check_node() { finish_linux_node_install() { activate_supported_node_on_path || true - if ! node_is_at_least_required; then + if ! node_is_supported; then local active_path active_version active_path="$(command -v node 2>/dev/null || echo "not found")" active_version="$(node -v 2>/dev/null || echo "missing")" - ui_error "Installed Node.js must be v${NODE_MIN_VERSION}+ but this shell is using ${active_version} (${active_path})" + ui_error "Installed Node.js must be ${NODE_SUPPORTED_VERSION_LABEL} but this shell is using ${active_version} (${active_path})" echo "Upgrade the system Node.js package or install Node.js ${NODE_DEFAULT_MAJOR} manually, then rerun the installer." exit 1 fi @@ -1758,14 +1760,14 @@ install_node_with_apk() { fi activate_supported_node_on_path || true - if node_is_at_least_required; then + if node_is_supported; then finish_linux_node_install return 0 fi local apk_node_version apk_node_version="$(node -v 2>/dev/null || echo "missing")" - ui_warn "Alpine nodejs package installed ${apk_node_version}, below required v${NODE_MIN_VERSION}+" + ui_warn "Alpine nodejs package installed ${apk_node_version}, outside the supported range (${NODE_SUPPORTED_VERSION_LABEL})" ui_info "Trying Alpine nodejs-current package" if is_root; then run_required_step "Installing nodejs-current" apk add --no-cache nodejs-current npm @@ -1774,7 +1776,7 @@ install_node_with_apk() { fi activate_supported_node_on_path || true - if node_is_at_least_required; then + if node_is_supported; then finish_linux_node_install return 0 fi @@ -1782,7 +1784,7 @@ install_node_with_apk() { local active_path active_version active_path="$(command -v node 2>/dev/null || echo "not found")" active_version="$(node -v 2>/dev/null || echo "missing")" - ui_error "Alpine apk repositories did not provide Node.js v${NODE_MIN_VERSION}+; found ${active_version} (${active_path})" + ui_error "Alpine apk repositories did not provide Node.js ${NODE_SUPPORTED_VERSION_LABEL}; found ${active_version} (${active_path})" echo "Use Alpine 3.21+ or install Node.js ${NODE_DEFAULT_MAJOR} manually, then rerun the installer." exit 1 } @@ -1864,7 +1866,7 @@ install_node() { fi else ui_error "Could not detect package manager" - echo "Please install Node.js ${NODE_DEFAULT_MAJOR} manually (or Node ${NODE_MIN_VERSION}+ minimum): https://nodejs.org" + echo "Please install Node.js ${NODE_DEFAULT_MAJOR} manually: https://nodejs.org" exit 1 fi diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 3bb64b4f5d35..2f986eb98ef2 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1186,7 +1186,7 @@ async function resolvePackageRuntimePreflightError(params: { `The requested package requires ${status.nodeEngine}.`, runtime.nodeRunner ? "Upgrade the Node runtime that owns the managed Gateway service, then rerun `openclaw update`." - : "Upgrade Node to 22.19+ or Node 24, then rerun `openclaw update`.", + : "Upgrade to Node 22.19 or newer 22.x, Node 23.11+, or Node 24+, then rerun `openclaw update`.", "Bare `npm i -g openclaw` can silently install an older compatible release.", "After upgrading Node, use `npm i -g openclaw@latest`.", ].join("\n"); diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 4fae651d6798..a80d5c4d4d90 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -411,7 +411,7 @@ describe("maybeRepairGatewayServiceConfig", () => { }); mocks.buildGatewayInstallPlan.mockImplementation(async ({ warn }) => { warn?.( - "System Node 20.20.2 at /usr/bin/node is below the required Node 22.19+. Using /home/orin/.nvm/versions/node/v22.22.2/bin/node for the daemon.", + "System Node 20.20.2 at /usr/bin/node is outside the supported range. Using /home/orin/.nvm/versions/node/v22.22.2/bin/node for the daemon.", "Gateway runtime", ); return { diff --git a/src/daemon/runtime-paths.test.ts b/src/daemon/runtime-paths.test.ts index a91bbe59fbed..9ed605892d5d 100644 --- a/src/daemon/runtime-paths.test.ts +++ b/src/daemon/runtime-paths.test.ts @@ -442,7 +442,7 @@ describe("resolveSystemNodeInfo", () => { "/Users/me/.fnm/node-22/bin/node", ); - expect(warning).toContain("below the required Node 22.19+"); + expect(warning).toContain("outside the supported range"); expect(warning).toContain(darwinNode); }); diff --git a/src/daemon/runtime-paths.ts b/src/daemon/runtime-paths.ts index d3d113f75bf6..ae841fb4902c 100644 --- a/src/daemon/runtime-paths.ts +++ b/src/daemon/runtime-paths.ts @@ -186,7 +186,7 @@ export async function resolveSystemNodeInfo(params: { return firstAvailable; } -/** Renders a warning when the system Node exists but is below the supported floor. */ +/** Renders a warning when the system Node exists but is outside the supported range. */ export function renderSystemNodeWarning( systemNode: SystemNodeInfo | null, selectedNodePath?: string, @@ -196,7 +196,7 @@ export function renderSystemNodeWarning( } const versionLabel = systemNode.version ?? "unknown"; const selectedLabel = selectedNodePath ? ` Using ${selectedNodePath} for the daemon.` : ""; - return `System Node ${versionLabel} at ${systemNode.path} is below the required Node 22.19+.${selectedLabel} Install Node 24 (recommended) or Node 22 LTS from nodejs.org or Homebrew.`; + return `System Node ${versionLabel} at ${systemNode.path} is outside the supported range.${selectedLabel} Install Node 24 (recommended) or Node 22 LTS from nodejs.org or Homebrew.`; } /** Resolves the Node binary the daemon should use for a node runtime. */ export async function resolvePreferredNodePath(params: { diff --git a/src/infra/runtime-guard.test.ts b/src/infra/runtime-guard.test.ts index da330a6864bc..b1e628ad1798 100644 --- a/src/infra/runtime-guard.test.ts +++ b/src/infra/runtime-guard.test.ts @@ -56,6 +56,10 @@ describe("runtime-guard", () => { expect(runtimeSatisfies(unknown)).toBe(false); expect(isSupportedNodeVersion("22.19.0")).toBe(true); expect(isSupportedNodeVersion("22.18.9")).toBe(false); + expect(isSupportedNodeVersion("23.7.0")).toBe(false); + expect(isSupportedNodeVersion("23.10.9")).toBe(false); + expect(isSupportedNodeVersion("23.11.0")).toBe(true); + expect(isSupportedNodeVersion("24.0.0")).toBe(true); expect(isSupportedNodeVersion(null)).toBe(false); }); @@ -72,6 +76,18 @@ describe("runtime-guard", () => { expect(nodeVersionSatisfiesEngine("22.19.0", "^22.19.0")).toBeNull(); }); + it("checks node versions against the supported engine range", () => { + const engine = ">=22.19.0 <23 || >=23.11.0"; + expect(nodeVersionSatisfiesEngine("22.19.0", engine)).toBe(true); + expect(nodeVersionSatisfiesEngine("22.18.9", engine)).toBe(false); + expect(nodeVersionSatisfiesEngine("23.7.0", engine)).toBe(false); + expect(nodeVersionSatisfiesEngine("23.10.9", engine)).toBe(false); + expect(nodeVersionSatisfiesEngine("23.11.0", engine)).toBe(true); + expect(nodeVersionSatisfiesEngine("24.0.0", engine)).toBe(true); + expect(nodeVersionSatisfiesEngine(null, engine)).toBe(false); + expect(nodeVersionSatisfiesEngine("unknown", engine)).toBe(false); + }); + it("throws via exit when runtime is too old", () => { const runtime = { log: vi.fn(), @@ -90,7 +106,7 @@ describe("runtime-guard", () => { expect(runtime.error).toHaveBeenCalledOnce(); expect(runtime.error).toHaveBeenCalledWith( [ - "openclaw requires Node >=22.19.0.", + "openclaw requires Node >=22.19.0 <23 or >=23.11.0.", "Detected: node 20.0.0 (exec: /usr/bin/node).", "PATH searched: /usr/bin", "Install Node: https://nodejs.org/en/download", @@ -135,7 +151,7 @@ describe("runtime-guard", () => { expect(runtime.error).toHaveBeenCalledOnce(); expect(runtime.error).toHaveBeenCalledWith( [ - "openclaw requires Node >=22.19.0.", + "openclaw requires Node >=22.19.0 <23 or >=23.11.0.", "Detected: unknown runtime (exec: unknown).", "PATH searched: (not set)", "Install Node: https://nodejs.org/en/download", diff --git a/src/infra/runtime-guard.ts b/src/infra/runtime-guard.ts index a0e6fe8716f1..6e6f957d4e5f 100644 --- a/src/infra/runtime-guard.ts +++ b/src/infra/runtime-guard.ts @@ -10,8 +10,11 @@ type Semver = { patch: number; }; -const MIN_NODE: Semver = { major: 22, minor: 19, patch: 0 }; +const MIN_NODE_22: Semver = { major: 22, minor: 19, patch: 0 }; +const MIN_NODE_23: Semver = { major: 23, minor: 11, patch: 0 }; const MINIMUM_ENGINE_RE = /^\s*>=\s*v?(\d+\.\d+\.\d+)\s*$/i; +const DISJUNCTIVE_ENGINE_RE = + /^\s*>=\s*v?(\d+\.\d+\.\d+)\s+<\s*v?(\d+)(?:\.(\d+)\.(\d+))?\s*\|\|\s*>=\s*v?(\d+\.\d+\.\d+)\s*$/i; /** Runtime facts included in startup/runtime-version diagnostics. */ export type RuntimeDetails = { @@ -69,16 +72,25 @@ export function detectRuntime(): RuntimeDetails { /** Returns whether a detected runtime meets OpenClaw's minimum runtime contract. */ export function runtimeSatisfies(details: RuntimeDetails): boolean { - const parsed = parseSemver(details.version); if (details.kind === "node") { - return isAtLeast(parsed, MIN_NODE); + return isSupportedNodeVersion(details.version); } return false; } -/** Checks a Node version label against OpenClaw's current minimum Node version. */ +/** Checks a Node version label against OpenClaw's supported Node version range. */ export function isSupportedNodeVersion(version: string | null): boolean { - return isAtLeast(parseSemver(version), MIN_NODE); + const parsed = parseSemver(version); + if (!parsed) { + return false; + } + if (parsed.major === MIN_NODE_22.major) { + return isAtLeast(parsed, MIN_NODE_22); + } + if (parsed.major === MIN_NODE_23.major) { + return isAtLeast(parsed, MIN_NODE_23); + } + return parsed.major > MIN_NODE_23.major; } /** Parses simple package `engines.node` ranges of the form `>=x.y.z`. */ @@ -93,16 +105,40 @@ export function parseMinimumNodeEngine(engine: string | null): Semver | null { return parseSemver(match[1] ?? null); } -/** Returns whether a Node version satisfies a simple minimum engine range, or null if unsupported. */ +/** Returns whether a Node version satisfies a supported engine range, or null if unsupported. */ export function nodeVersionSatisfiesEngine( version: string | null, engine: string | null, ): boolean | null { const minimum = parseMinimumNodeEngine(engine); - if (!minimum) { + if (minimum) { + return isAtLeast(parseSemver(version), minimum); + } + + const rangeMatch = engine?.match(DISJUNCTIVE_ENGINE_RE); + if (!rangeMatch) { return null; } - return isAtLeast(parseSemver(version), minimum); + const parsed = parseSemver(version); + if (!parsed) { + return false; + } + const [, firstMinimumRaw, upperMajorRaw, upperMinorRaw, upperPatchRaw, secondMinimumRaw] = + rangeMatch; + const firstMinimum = parseSemver(firstMinimumRaw ?? null); + const secondMinimum = parseSemver(secondMinimumRaw ?? null); + const upperBound: Semver = { + major: Number.parseInt(upperMajorRaw ?? "", 10), + minor: Number.parseInt(upperMinorRaw ?? "0", 10), + patch: Number.parseInt(upperPatchRaw ?? "0", 10), + }; + if (!firstMinimum || !secondMinimum || !Number.isFinite(upperBound.major)) { + return null; + } + return ( + (isAtLeast(parsed, firstMinimum) && !isAtLeast(parsed, upperBound)) || + isAtLeast(parsed, secondMinimum) + ); } /** Exits through the provided runtime when the current Node runtime is unsupported. */ @@ -121,7 +157,7 @@ export function assertSupportedRuntime( runtime.error( [ - "openclaw requires Node >=22.19.0.", + "openclaw requires Node >=22.19.0 <23 or >=23.11.0.", `Detected: ${runtimeLabel} (exec: ${execLabel}).`, `PATH searched: ${details.pathEnv}`, "Install Node: https://nodejs.org/en/download", diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 05d3c58ca153..43b16d00a10d 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -163,75 +163,67 @@ describe("openclaw launcher", () => { cleanupTempDirs(fixtureRoots); }); - it("keeps the bootstrap Node floor aligned with package and runtime guards", async () => { - const [launcher, runtimeGuard, packageJsonRaw] = await Promise.all([ - fs.readFile(path.resolve(process.cwd(), "openclaw.mjs"), "utf8"), - fs.readFile(path.resolve(process.cwd(), "src/infra/runtime-guard.ts"), "utf8"), - fs.readFile(path.resolve(process.cwd(), "package.json"), "utf8"), - ]); + it("keeps the bootstrap Node range aligned with the package engine", async () => { + const packageJsonRaw = await fs.readFile(path.resolve(process.cwd(), "package.json"), "utf8"); const packageJson = JSON.parse(packageJsonRaw) as { engines?: { node?: string } }; - const launcherMatch = launcher.match( - /const MIN_NODE_MAJOR = (\d+);\s+const MIN_NODE_MINOR = (\d+);/u, - ); - const runtimeMatch = runtimeGuard.match( - /const MIN_NODE: Semver = \{ major: (\d+), minor: (\d+), patch: (\d+) \};/u, - ); - const engineMatch = packageJson.engines?.node?.match(/^>=(\d+)\.(\d+)\.(\d+)$/u); - - if (!launcherMatch) { - throw new Error("openclaw.mjs MIN_NODE_* constants were not found"); - } - if (!runtimeMatch) { - throw new Error("src/infra/runtime-guard.ts MIN_NODE constant was not found"); - } - if (!engineMatch) { - throw new Error("package.json engines.node must use >=.."); - } - const [engineMajor, engineMinor, enginePatch] = engineMatch.slice(1, 4).map(Number); - const launcherMinimumLabel = `${engineMajor}.${engineMinor}`; - - expect( - [Number(launcherMatch[1]), Number(launcherMatch[2]), 0], - "openclaw.mjs MIN_NODE_* must match package.json engines.node", - ).toEqual([engineMajor, engineMinor, enginePatch]); - expect( - runtimeMatch.slice(1, 4).map(Number), - "src/infra/runtime-guard.ts MIN_NODE must match package.json engines.node", - ).toEqual([engineMajor, engineMinor, enginePatch]); + expect(packageJson.engines?.node).toBe(">=22.19.0 <23 || >=23.11.0"); const fixtureRoot = await makeLauncherFixture(fixtureRoots); - const mockedNodeVersion = - engineMinor > 0 ? `${engineMajor}.${engineMinor - 1}.0` : `${engineMajor - 1}.999.0`; - const mockNodeVersionPath = path.join(fixtureRoot, "mock-node-version.mjs"); await fs.writeFile( - mockNodeVersionPath, - [ - "Object.defineProperty(process.versions, 'node', {", - ` value: ${JSON.stringify(mockedNodeVersion)},`, - "});", - ].join("\n"), + path.join(fixtureRoot, "dist", "entry.js"), + 'process.stdout.write("runtime-loaded\\n");\n', "utf8", ); - const result = spawnSync( - process.execPath, - [ - "--import", - pathToFileURL(mockNodeVersionPath).href, - path.join(fixtureRoot, "openclaw.mjs"), - "--help", - ], - { - cwd: fixtureRoot, - env: launcherEnv(), - encoding: "utf8", - }, - ); + const cases = [ + { version: "22.18.9", supported: false }, + { version: "22.19.0", supported: true }, + { version: "23.7.0", supported: false }, + { version: "23.10.9", supported: false }, + { version: "23.11.0", supported: true }, + { version: "24.0.0", supported: true }, + ] as const; - expect(result.status).toBe(1); - expect(result.stderr).toContain( - `openclaw: Node.js v${launcherMinimumLabel}+ is required (current: v${mockedNodeVersion}).`, - ); + for (const testCase of cases) { + const mockNodeVersionPath = path.join( + fixtureRoot, + `mock-node-version-${testCase.version}.mjs`, + ); + await fs.writeFile( + mockNodeVersionPath, + [ + "Object.defineProperty(process.versions, 'node', {", + ` value: ${JSON.stringify(testCase.version)},`, + "});", + ].join("\n"), + "utf8", + ); + + const result = spawnSync( + process.execPath, + [ + "--import", + pathToFileURL(mockNodeVersionPath).href, + path.join(fixtureRoot, "openclaw.mjs"), + "--help", + ], + { + cwd: fixtureRoot, + env: launcherEnv(), + encoding: "utf8", + }, + ); + + if (testCase.supported) { + expect(result.status, testCase.version).toBe(0); + expect(result.stdout, testCase.version).toContain("runtime-loaded"); + } else { + expect(result.status, testCase.version).toBe(1); + expect(result.stderr, testCase.version).toContain( + `openclaw: Node.js >=22.19.0 <23 or >=23.11.0 is required (current: v${testCase.version}).`, + ); + } + } }); it("surfaces transitive entry import failures instead of masking them as missing dist", async () => { diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index 3642dfc37d64..0fad992b8e49 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -47,6 +47,42 @@ describe("install-cli.sh", () => { expect(script).toContain('cleanup_legacy_submodules "$repo_dir"'); }); + it("accepts only Node versions with the required SQLite statement API", () => { + const result = runInstallCliShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + set +e + for version in 22.18.9 22.19.0 23.7.0 23.10.9 23.11.0 24.0.0; do + node_version_is_supported "$version" + printf '%s=%s\n' "$version" "$?" + done + `); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("22.18.9=1"); + expect(result.stdout).toContain("22.19.0=0"); + expect(result.stdout).toContain("23.7.0=1"); + expect(result.stdout).toContain("23.10.9=1"); + expect(result.stdout).toContain("23.11.0=0"); + expect(result.stdout).toContain("24.0.0=0"); + }); + + it("rejects an explicitly requested incompatible Node 23 release", () => { + const result = runInstallCliShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + NODE_VERSION=23.7.0 + NODE_VERSION_REQUESTED=1 + install_node + `); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "Node 23.7.0 is unsupported; use Node 22.19+, Node 23.11+, or Node 24+.", + ); + expect(result.stdout).not.toContain("Installing Node 23.7.0"); + }); + it("rejects installer options with missing values", () => { const result = runInstallCliShell(` set -euo pipefail @@ -686,7 +722,7 @@ describe("install-cli.sh", () => { "require_bin() { :; }", "download_file() {", ' case "$1" in', - " */SHASUMS256.txt) printf 'fixture-sha node-v22.18.0-linux-x64.tar.gz\\n' > \"$2\" ;;", + " */SHASUMS256.txt) printf 'fixture-sha node-v22.19.0-linux-x64.tar.gz\\n' > \"$2\" ;;", " *) printf 'node tarball fixture\\n' > \"$2\" ;;", " esac", "}", @@ -701,7 +737,7 @@ describe("install-cli.sh", () => { ' cp "$NEW_NPM" "$dest/bin/npm"', "}", `PREFIX=${JSON.stringify(prefix)}`, - "NODE_VERSION=22.18.0", + "NODE_VERSION=22.19.0", "NODE_VERSION_REQUESTED=1", "install_node", ].join("\n"), @@ -713,7 +749,7 @@ describe("install-cli.sh", () => { expect(result.status).toBe(1); expect(result.stdout).toContain( - "Installed Node 22.18.0 must provide Node >= 22.19.0 with node:sqlite", + "Installed Node 22.19.0 must provide Node >= 22.19.0 with node:sqlite", ); expect(result.stdout).toContain("found v22.18.0"); } finally { diff --git a/test/scripts/install-ps1.test.ts b/test/scripts/install-ps1.test.ts index 88e1a8fe08a0..d39cfe37287c 100644 --- a/test/scripts/install-ps1.test.ts +++ b/test/scripts/install-ps1.test.ts @@ -89,6 +89,56 @@ describe("install.ps1 failure handling", () => { expect(source).toContain("$installSucceeded = Test-BooleanSuccessResult -Results $mainResults"); }); + it("checks the full supported Node version range", () => { + const versionBody = extractFunctionBody(source, "Test-NodeVersionSupported"); + const checkNodeBody = extractFunctionBody(source, "Check-Node"); + expect(versionBody).toContain("$major -eq 22"); + expect(versionBody).toContain("$minor -ge 19"); + expect(versionBody).toContain("$major -eq 23"); + expect(versionBody).toContain("$minor -ge 11"); + expect(versionBody).toContain("$major -gt 23"); + expect(checkNodeBody).toContain("Test-NodeVersionSupported -Version $nodeVersion"); + }); + + runIfPowerShell("accepts only supported Node versions", () => { + const tempDir = harness.createTempDir("openclaw-install-ps1-"); + const scriptPath = join(tempDir, "install.ps1"); + const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, ""); + writeFileSync( + scriptPath, + [ + scriptWithoutEntryPoint, + "", + "$cases = @{", + " '22.18.9' = $false", + " '22.19.0' = $true", + " '23.7.0' = $false", + " '23.10.9' = $false", + " '23.11.0' = $true", + " '24.0.0' = $true", + "}", + "foreach ($entry in $cases.GetEnumerator()) {", + " $actual = Test-NodeVersionSupported -Version $entry.Key", + ' if ($actual -ne $entry.Value) { throw "Version=$($entry.Key) Actual=$actual" }', + "}", + "", + ].join("\n"), + ); + chmodSync(scriptPath, 0o755); + + const result = runPowerShell([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ]); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + }); + it("runs npm install through the resolved command with quiet CI defaults", () => { const npmInstallBody = extractFunctionBody(source, "Install-OpenClaw"); expect(npmInstallBody).toContain("$npmOutput = Invoke-NpmCommand -Arguments"); diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index 2f53de553918..f56d5c7189f5 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -161,7 +161,7 @@ NODE expect(script).toContain( 'run_required_step "Installing nodejs-current" apk add --no-cache nodejs-current npm', ); - expect(script).toContain("if ! node_is_at_least_required; then"); + expect(script).toContain("if ! node_is_supported; then"); const apkIndex = script.indexOf("if command -v apk &> /dev/null && is_alpine_linux; then"); const nodeSourceIndex = script.indexOf('ui_info "Installing Node.js via NodeSource"'); @@ -182,7 +182,7 @@ NODE ui_success() { printf 'success:%s\\n' "$*"; } run_quiet_step() { printf 'step:%s|%s\\n' "$1" "\${*:2}"; } apk() { :; } - node_is_at_least_required() { return 0; } + node_is_supported() { return 0; } finish_linux_node_install() { printf 'finish-linux-node\\n'; } install_node `); @@ -275,7 +275,7 @@ NODE "step:Installing nodejs-current|apk add --no-cache nodejs-current npm", ); expect(result.stdout).toContain( - "error:Alpine apk repositories did not provide Node.js v22.19+", + "error:Alpine apk repositories did not provide Node.js 22.19+, 23.11+, or 24+", ); expect(result.stdout).toContain("Use Alpine 3.21+ or install Node.js 24 manually"); }); @@ -1078,15 +1078,11 @@ NODE expect(output).toContain("version=v22.22.0"); }); - it("uses the package engine floor when accepting existing Node runtimes", () => { + it("uses the package engine range when accepting existing Node runtimes", () => { const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { engines?: { node?: string }; }; - const engineMatch = /^>=22\.(\d+)\.0$/.exec(pkg.engines?.node ?? ""); - expect(engineMatch).not.toBeNull(); - - const minMinor = Number(engineMatch?.[1]); - expect(script).toContain(`NODE_MIN_MINOR=${minMinor}`); + expect(pkg.engines?.node).toBe(">=22.19.0 <23 || >=23.11.0"); const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-node-floor-")); const bin = join(tmp, "bin"); @@ -1111,15 +1107,12 @@ NODE "unset -f node 2>/dev/null || true", "unalias node 2>/dev/null || true", 'node() { printf "%s\\n" "${FAKE_NODE_VERSION:-v0.0.0}"; }', - `FAKE_NODE_VERSION="v22.${minMinor - 1}.0"`, - "export FAKE_NODE_VERSION", - "node_is_at_least_required", - "node_below_floor=$?", - `FAKE_NODE_VERSION="v22.${minMinor}.0"`, - "export FAKE_NODE_VERSION", - "node_is_at_least_required", - "node_at_floor=$?", - 'printf "node_below_floor=%s\\nnode_at_floor=%s\\n" "$node_below_floor" "$node_at_floor"', + "for version in 22.18.9 22.19.0 23.7.0 23.10.9 23.11.0 24.0.0; do", + ' FAKE_NODE_VERSION="v${version}"', + " export FAKE_NODE_VERSION", + " node_is_supported", + ' printf "%s=%s\\n" "$version" "$?"', + "done", "exit 0", ].join("\n"), { @@ -1132,8 +1125,12 @@ NODE } expect(result?.status).toBe(0); - expect(result?.stdout).toContain("node_below_floor=1"); - expect(result?.stdout).toContain("node_at_floor=0"); + expect(result?.stdout).toContain("22.18.9=1"); + expect(result?.stdout).toContain("22.19.0=0"); + expect(result?.stdout).toContain("23.7.0=1"); + expect(result?.stdout).toContain("23.10.9=1"); + expect(result?.stdout).toContain("23.11.0=0"); + expect(result?.stdout).toContain("24.0.0=0"); }); it("persists a supported Linux Node path before noninteractive shell guards", () => { @@ -1414,10 +1411,7 @@ NODE mkdirSync(bin, { recursive: true }); mkdirSync(outer, { recursive: true }); mkdirSync(repo, { recursive: true }); - writeFileSync( - join(outer, "package.json"), - '{\n "packageManager": "yarn@4.5.0"\n}\n', - ); + writeFileSync(join(outer, "package.json"), '{\n "packageManager": "yarn@4.5.0"\n}\n'); writeFileSync( join(repo, "package.json"), '{\n "packageManager": "pnpm@11.2.2+sha512.test"\n}\n', @@ -1545,7 +1539,7 @@ describe("install.sh macOS Homebrew Node behavior", () => { fi return 0 } - node_major_version() { echo 16; } + node_is_supported() { return 1; } if ensure_macos_default_node_active; then echo "ensure returned success" else