diff --git a/docs/install/node.md b/docs/install/node.md index e40a829c3c61..df98fa73eb39 100644 --- a/docs/install/node.md +++ b/docs/install/node.md @@ -19,6 +19,20 @@ node -v Upgrade Node before updating OpenClaw to avoid SQLite TEXT truncation. See [Node.js compatibility](/install/node-compatibility) for the SQLite safety floors and macOS/ARMv7 support limits. +### Update from the CLI + +If you run `openclaw` with an incompatible Node.js in an interactive terminal, the CLI offers: + +```text +Update NodeJS: Y/N [N]: +``` + +Enter **Y** to download a compatible Node.js for OpenClaw and retry the same command. The download is checksum-verified and stored under `~/.openclaw/tools/cli-node` (or the home selected by `OPENCLAW_HOME`). The Node.js installation does not replace system Node.js, change shell settings, reinstall OpenClaw, or repair/restart Gateway services. The retried command keeps its normal behavior. + +Later CLI invocations reuse that runtime when the active Node.js is incompatible. A supported active Node.js still takes precedence. Enter **N**, press Enter, or cancel to leave your installation unchanged and see manual upgrade instructions. + +Automatic installation supports macOS, Windows, and glibc-based Linux on x64/ARM64. Alpine/musl and other architectures need manual installation. Non-interactive, CI, JSON, and `--yes` invocations never prompt or install Node.js. Commands that require an exact process identity, such as `hooks relay` and `webhooks gmail run`, also require a compatible Node.js on their existing execution path. + ## Install Node diff --git a/node-runtime-update.mjs b/node-runtime-update.mjs new file mode 100644 index 000000000000..f03ea35b00b1 --- /dev/null +++ b/node-runtime-update.mjs @@ -0,0 +1,119 @@ +// This module must run on unsupported Node versions, before importing dist or dependencies. +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { isSupportedOpenClawNodeVersion } from "./node-version.mjs"; + +function isUsableNode(nodePath) { + if (!existsSync(nodePath)) { + return false; + } + const result = spawnSync( + nodePath, + [ + "--input-type=module", + "-e", + 'import "node:sqlite"; process.stdout.write(process.versions.node);', + ], + { encoding: "utf8", timeout: 10_000, windowsHide: true }, + ); + return result.status === 0 && isSupportedOpenClawNodeVersion(result.stdout?.trim()); +} + +function canInstallPrivateNode() { + if (!["x64", "arm64"].includes(process.arch)) { + return false; + } + if (process.platform === "linux") { + // The existing Alpine installer uses apk/sudo; private CLI recovery must not. + return Boolean(process.report?.getReport().header.glibcVersionRuntime); + } + return process.platform === "darwin" || process.platform === "win32"; +} + +function confirmNodeUpdate() { + return new Promise((resolve) => { + const prompt = createInterface({ input: process.stdin, output: process.stderr }); + let settled = false; + const finish = (answer) => { + if (settled) { + return; + } + settled = true; + prompt.close(); + resolve(/^(y|yes)$/i.test(answer.trim())); + }; + prompt.once("close", () => finish("")); + prompt.once("SIGINT", () => finish("")); + prompt.question("Update NodeJS: Y/N [N]: ", finish); + }); +} + +/** Returns a verified private runtime, or null when recovery was declined/unavailable. */ +export async function resolveUpdatedNodeRuntime(homeDir) { + if (process.env.OPENCLAW_NODE_UPDATE_RESPAWNED === "1") { + return null; + } + const prefix = path.join(homeDir, ".openclaw", "tools", "cli-node"); + const nodeRoot = path.join(prefix, "tools", "node"); + const nodePath = + process.platform === "win32" + ? path.join(nodeRoot, "node.exe") + : path.join(nodeRoot, "bin", "node"); + + // An earlier explicit opt-in is durable, but an incompatible cache is never trusted. + if (isUsableNode(nodePath)) { + return nodePath; + } + if ( + !process.stdin.isTTY || + !process.stderr.isTTY || + process.env.CI || + process.argv.some((arg) => ["--non-interactive", "--json", "--yes"].includes(arg)) || + !canInstallPrivateNode() + ) { + return null; + } + + process.stderr.write( + "Install a compatible Node.js for OpenClaw only and retry this command.\n" + + "The Node.js installation will not change system Node.js, shell settings, or Gateway services.\n", + ); + if (!(await confirmNodeUpdate())) { + return null; + } + + const windows = process.platform === "win32"; + const installer = fileURLToPath( + new URL(windows ? "./scripts/install.ps1" : "./scripts/install-cli.sh", import.meta.url), + ); + const command = windows + ? (await import("./scripts/windows-cmd-helpers.mjs")).resolveWindowsPowerShellPath() + : process.platform === "darwin" + ? "/bin/bash" + : "bash"; + const args = windows + ? [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + installer, + "-NodeOnly", + "-NodePrefix", + nodeRoot, + ] + : [installer, "--node-only", "--prefix", prefix]; + const result = spawnSync(command, args, { stdio: "inherit" }); + if (result.status !== 0 || !isUsableNode(nodePath)) { + process.stderr.write( + "openclaw: Node.js update failed; install a compatible Node.js manually.\n", + ); + return null; + } + process.stderr.write("openclaw: Node.js updated. Retrying your command.\n"); + return nodePath; +} diff --git a/openclaw.mjs b/openclaw.mjs index d86e7d510537..a1bc80149d14 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -12,31 +12,13 @@ const isSourceCheckoutLauncher = () => existsSync(new URL("./.git", import.meta.url)) || existsSync(new URL("./src/entry.ts", import.meta.url)); -if ( - !isSourceCheckoutLauncher() && - (existsSync(new URL("./.openclaw-lifecycle-pending", import.meta.url)) || - existsSync(new URL("./dist/openclaw-install-guard", import.meta.url))) -) { - try { - const { completePendingPackageLifecycle } = await import("./dist/infra/package-lifecycle.js"); - await completePendingPackageLifecycle({ - packageRoot: fileURLToPath(new URL("./", import.meta.url)), - }); - } catch (error) { - process.stderr.write( - `openclaw: package lifecycle is incomplete. Reinstall with package scripts enabled, then retry. ${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exit(1); - } -} - const { isSupportedOpenClawNodeVersion } = await import("./node-version.mjs"); const RECOMMENDED_NODE_MAJOR = 26; const SUPPORTED_NODE_RANGE = ">=24.16.0 <25, or >=26.1.0"; const COMPILE_CACHE_DISABLED_RESPAWNED_ENV = "OPENCLAW_COMPILE_CACHE_DISABLED_RESPAWNED"; -const ensureSupportedRuntimeVersion = () => { +const ensureSupportedRuntimeVersion = async () => { if (process.versions.bun) { // Bun >=1.4 (Rust rewrite) ships node:sqlite; feature-probe instead of // rejecting Bun outright so capable Bun builds can run OpenClaw. @@ -60,8 +42,31 @@ const ensureSupportedRuntimeVersion = () => { } process.stderr.write( - `openclaw: Node.js ${SUPPORTED_NODE_RANGE} is required (current: v${process.versions.node}).\n` + - "If you use nvm, run:\n" + + `openclaw: Node.js ${SUPPORTED_NODE_RANGE} is required (current: v${process.versions.node}).\n`, + ); + // These invocations have an exact-PID contract and cannot acquire a wrapper process. + if ( + !isForegroundGmailRunInvocation(process.argv) && + !(process.platform !== "win32" && isNativeHookRelayInvocation(process.argv)) + ) { + const { resolveUpdatedNodeRuntime } = await import("./node-runtime-update.mjs"); + const nodePath = await resolveUpdatedNodeRuntime(resolveLauncherHomeDir()); + if (nodePath) { + const env = { ...process.env, OPENCLAW_NODE_UPDATE_RESPAWNED: "1" }; + const pathKey = + process.platform === "win32" + ? (await import("./scripts/windows-cmd-helpers.mjs")).resolvePathEnvKey(env) + : "PATH"; + env[pathKey] = `${path.dirname(nodePath)}${path.delimiter}${env[pathKey] ?? ""}`; + return runRespawnedChild( + nodePath, + [...process.execArgv, process.argv[1], ...process.argv.slice(2)], + env, + ); + } + } + process.stderr.write( + "If you use nvm, run:\n" + ` nvm install ${RECOMMENDED_NODE_MAJOR}\n` + ` nvm use ${RECOMMENDED_NODE_MAJOR}\n` + ` nvm alias default ${RECOMMENDED_NODE_MAJOR}\n`, @@ -69,12 +74,6 @@ const ensureSupportedRuntimeVersion = () => { process.exit(1); }; -ensureSupportedRuntimeVersion(); - -if (tryOutputLauncherVersion(process.argv)) { - process.exit(0); -} - const isNodeCompileCacheDisabled = () => process.env.NODE_DISABLE_COMPILE_CACHE !== undefined; const isNodeCompileCacheRequested = () => Boolean(process.env.NODE_COMPILE_CACHE) && !isNodeCompileCacheDisabled(); @@ -779,12 +778,39 @@ const tryOutputPrecomputedCommandHelp = () => { return true; }; +// Resolve Node before loading pending package lifecycle code or any built runtime modules. +const waitingForNodeUpdateRespawn = await ensureSupportedRuntimeVersion(); + +if (!waitingForNodeUpdateRespawn) { + if ( + !isSourceCheckoutLauncher() && + (existsSync(new URL("./.openclaw-lifecycle-pending", import.meta.url)) || + existsSync(new URL("./dist/openclaw-install-guard", import.meta.url))) + ) { + try { + const { completePendingPackageLifecycle } = await import("./dist/infra/package-lifecycle.js"); + await completePendingPackageLifecycle({ + packageRoot: fileURLToPath(new URL("./", import.meta.url)), + }); + } catch (error) { + process.stderr.write( + `openclaw: package lifecycle is incomplete. Reinstall with package scripts enabled, then retry. ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exit(1); + } + } + if (tryOutputLauncherVersion(process.argv)) { + process.exit(0); + } +} + // Codex owns the relay timeout by PID. Keep the launcher as that exact process // so a timeout cannot strand a compile-cache respawn child. const waitingForCompileCacheRespawn = - !isForegroundGmailRunInvocation(process.argv) && - !(process.platform !== "win32" && isNativeHookRelayInvocation(process.argv)) && - (respawnWithoutCompileCacheIfNeeded() || respawnWithPackagedCompileCacheIfNeeded()); + waitingForNodeUpdateRespawn || + (!isForegroundGmailRunInvocation(process.argv) && + !(process.platform !== "win32" && isNativeHookRelayInvocation(process.argv)) && + (respawnWithoutCompileCacheIfNeeded() || respawnWithPackagedCompileCacheIfNeeded())); // https://nodejs.org/api/module.html#module-compile-cache if ( diff --git a/package.json b/package.json index a627cd075064..ed724bdac71c 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "CHANGELOG.md", "LICENSE", "node-version.mjs", + "node-runtime-update.mjs", "openclaw.mjs", "pnpm-workspace.yaml", "README.md", @@ -383,6 +384,8 @@ "skills/", "custodian-skills/", "scripts/check-install-dependency-ownership.mjs", + "scripts/install-cli.sh", + "scripts/install.ps1", "scripts/prepare-git-hooks.mjs", "scripts/preinstall-package-manager-warning.mjs", "scripts/lib/official-external-channel-catalog.json", diff --git a/scripts/check-duplicates.mts b/scripts/check-duplicates.mts index 3c1e1a23e0a8..ffa79cf28d02 100644 --- a/scripts/check-duplicates.mts +++ b/scripts/check-duplicates.mts @@ -22,6 +22,7 @@ const targets = [ "test", "skills", "config", + "node-runtime-update.mjs", "node-version.mjs", "openclaw.mjs", "tsdown.ai.config.ts", diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 2fcdf84c22dd..b7aa11492eff 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -114,6 +114,7 @@ GIT_DIR="${OPENCLAW_GIT_DIR:-${OPENCLAW_EFFECTIVE_HOME}/openclaw}" GIT_UPDATE="${OPENCLAW_GIT_UPDATE:-1}" JSON=0 RUN_ONBOARD=0 +NODE_ONLY=0 SET_NPM_PREFIX=0 PNPM_CMD=() GIT_REF_KIND="" @@ -131,6 +132,7 @@ Usage: install-cli.sh [options] --version OpenClaw version (default: latest) --compatible-with Refuse a CLI that cannot modify config written by --node-version Node version (default: 24.19.0) + --node-only Install only a private Node runtime (no system package changes) --onboard Run "openclaw onboard" after install --no-onboard Skip onboarding (default) --set-npm-prefix Force npm prefix to ~/.npm-global if current prefix is not writable (Linux) @@ -429,6 +431,10 @@ parse_args() { NODE_VERSION_REQUESTED=1 shift 2 ;; + --node-only) + NODE_ONLY=1 + shift + ;; --install-method|--method) if [[ $# -lt 2 || "${2:-}" == --* ]]; then fail "Missing value for $1" @@ -1789,6 +1795,13 @@ refresh_gateway_service_if_loaded() { main() { parse_args "$@" PREFIX="$(resolve_installer_path "$PREFIX")" + if [[ "$NODE_ONLY" -eq 1 ]]; then + if is_musl_linux; then + fail "Private Node.js recovery is unavailable on musl Linux; update Node.js with your system package manager." + fi + install_node "$(os_detect)" "$(arch_detect)" + return + fi GIT_DIR="$(resolve_installer_path "$GIT_DIR")" if [[ "${OPENCLAW_NO_ONBOARD:-0}" == "1" ]]; then diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 290225b59635..2cd26f5ce530 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -11,6 +11,8 @@ param( [switch]$NoOnboard, [switch]$NoGitUpdate, [switch]$DryRun, + [switch]$NodeOnly, + [string]$NodePrefix, [switch]$Help ) @@ -29,6 +31,8 @@ Options: -NoOnboard Skip onboarding -NoGitUpdate Skip git pull -DryRun Print actions only + -NodeOnly Install only a private Node.js runtime; do not change PATH + -NodePrefix Absolute private directory for -NodeOnly (required) -Help Show this help "@ | Write-Output return @@ -310,9 +314,13 @@ function Test-NodeSqliteSupported { } function Check-Node { + param([string]$NodePath) + try { - $nodeCommand = Get-Command node -CommandType Application -ErrorAction Stop | Select-Object -First 1 - $nodePath = $nodeCommand.Source + if ([string]::IsNullOrWhiteSpace($NodePath)) { + $nodeCommand = Get-Command node -CommandType Application -ErrorAction Stop | Select-Object -First 1 + $NodePath = $nodeCommand.Source + } $nodeVersion = (& $nodePath -v 2>$null) $sqliteProbe = 'const { DatabaseSync } = require("node:sqlite"); const db = new DatabaseSync(":memory:"); try { process.stdout.write(String(db.prepare("SELECT sqlite_version() AS version").get().version)); } finally { db.close(); }' $sqliteVersion = ($sqliteProbe | & $nodePath - 2>$null) @@ -546,6 +554,66 @@ function Install-PortableNode { Write-Host "[OK] User-local Node.js ready: $nodeVersion" -ForegroundColor Green } +function Install-PrivateNode { + param([Parameter(Mandatory = $true)][string]$Prefix) + + $download = Resolve-PortableNodeDownload + $temporaryRoot = Join-Path $script:InstallerTempDirectory ("openclaw-private-node-" + [guid]::NewGuid().ToString("N")) + $archive = Join-Path $temporaryRoot $download.Name + $checksums = Join-Path $temporaryRoot "SHASUMS256.txt" + $parent = Split-Path -Parent $Prefix + $extracted = Join-Path $parent (".openclaw-node-" + [guid]::NewGuid().ToString("N")) + $backup = $null + try { + New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + $downloadTimeouts = Get-WebRequestTimeoutParameters -CommandName "Invoke-WebRequest" -LegacyTimeoutSec 600 + Invoke-WebRequest -UseBasicParsing -Uri $download.Url -OutFile $archive @downloadTimeouts + Invoke-WebRequest -UseBasicParsing -Uri "https://nodejs.org/dist/$($download.Version)/SHASUMS256.txt" -OutFile $checksums @downloadTimeouts + $checksumPattern = '^(?[0-9a-fA-F]{64})\s+\*?' + [regex]::Escape($download.Name) + '$' + $expected = @(Get-Content -LiteralPath $checksums | ForEach-Object { + if ($_ -match $checksumPattern) { $Matches["hash"] } + }) + if ($expected.Count -ne 1 -or (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $expected[0]) { + throw "Node.js archive checksum verification failed." + } + + New-Item -ItemType Directory -Force -Path $parent | Out-Null + Expand-PortableNodeArchive -ZipPath $archive -DestinationPath $extracted + $nodeExe = Join-Path $extracted "node.exe" + if (-not (Check-Node -NodePath $nodeExe)) { + throw "Downloaded Node.js does not satisfy OpenClaw runtime requirements." + } + + # Keep the matching npm/npx alongside node.exe, without touching global packages or PATH. + # Stage on the destination volume and preserve the previous private runtime until publication. + if (Test-Path -LiteralPath $Prefix) { + $backup = Join-Path $parent (".openclaw-node-backup-" + [guid]::NewGuid().ToString("N")) + [System.IO.Directory]::Move($Prefix, $backup) + } + try { + [System.IO.Directory]::Move($extracted, $Prefix) + } catch { + if ($backup) { + [System.IO.Directory]::Move($backup, $Prefix) + $backup = $null + } + throw + } + if ($backup) { + Remove-Item -LiteralPath $backup -Recurse -Force + $backup = $null + } + Write-Host "[OK] Private Node.js ready: $(Join-Path $Prefix 'node.exe')" -ForegroundColor Green + } finally { + if (Test-Path -LiteralPath $extracted) { + Remove-Item -LiteralPath $extracted -Recurse -Force + } + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force + } + } +} + # Install Node.js function Install-Node { Write-Host "[*] Installing Node.js..." -ForegroundColor Yellow @@ -2071,6 +2139,36 @@ function Complete-NpmShimBackup { # Main installation flow function Main { + if ($NodeOnly) { + $prefixRoot = if (-not [string]::IsNullOrWhiteSpace($NodePrefix)) { [System.IO.Path]::GetPathRoot($NodePrefix) } else { "" } + if ( + [string]::IsNullOrWhiteSpace($prefixRoot) -or + $prefixRoot.EndsWith(":") -or + ($prefixRoot -eq "\") -or + [string]::Equals([System.IO.Path]::GetFullPath($NodePrefix).TrimEnd('\', '/'), $prefixRoot.TrimEnd('\', '/'), [System.StringComparison]::OrdinalIgnoreCase) + ) { + Write-Host "Error: -NodeOnly requires -NodePrefix with an absolute private directory, not a filesystem root." -ForegroundColor Red + Fail-Install -Code 2 + return + } + if ($DryRun) { + Write-Host "[OK] Would install private Node.js to $NodePrefix (PATH unchanged)." -ForegroundColor Green + return $true + } + try { + Install-PrivateNode -Prefix ([System.IO.Path]::GetFullPath($NodePrefix)) + } catch { + Write-Host "Error: Node.js update failed: $($_.Exception.Message)" -ForegroundColor Red + Fail-Install + } + return + } + if (-not [string]::IsNullOrWhiteSpace($NodePrefix)) { + Write-Host "Error: -NodePrefix requires -NodeOnly." -ForegroundColor Red + Fail-Install -Code 2 + return + } + if ($InstallMethod -ne "npm" -and $InstallMethod -ne "git") { Write-Host "Error: invalid -InstallMethod (use npm or git)." -ForegroundColor Red Fail-Install -Code 2 diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 680d67115556..bac992d06281 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -20,6 +20,10 @@ async function makeLauncherFixture(fixtureRoots: string[]): Promise { path.resolve(process.cwd(), "node-version.mjs"), path.join(fixtureRoot, "node-version.mjs"), ); + await fs.copyFile( + path.resolve(process.cwd(), "node-runtime-update.mjs"), + path.join(fixtureRoot, "node-runtime-update.mjs"), + ); await fs.mkdir(path.join(fixtureRoot, "dist"), { recursive: true }); return fixtureRoot; } @@ -153,6 +157,218 @@ describe("openclaw launcher", () => { cleanupTempDirs(fixtureRoots); }); + describe.skipIf(process.platform === "win32")("Node.js update recovery", () => { + async function prepareRecovery( + params: { + tty?: boolean; + version?: string; + install?: "ok" | "failed" | "invalid"; + cached?: boolean; + pendingLifecycle?: boolean; + } = {}, + ) { + const root = await makeLauncherFixture(fixtureRoots); + const home = path.join(root, "home with spaces"); + const nodePath = path.join( + home, + ".openclaw", + "tools", + "cli-node", + "tools", + "node", + "bin", + "node", + ); + await fs.mkdir(home); + if (params.cached) { + await fs.mkdir(path.dirname(nodePath), { recursive: true }); + await fs.symlink(process.execPath, nodePath); + } + const installLog = path.join(root, "installer.json"); + const preload = path.join(root, "legacy-node.mjs"); + await fs.writeFile( + preload, + ` + import childProcess from "node:child_process"; + import fs from "node:fs"; + import path from "node:path"; + import { syncBuiltinESMExports } from "node:module"; + if (process.env.OPENCLAW_NODE_UPDATE_RESPAWNED !== "1") { + Object.defineProperty(process.versions, "node", { value: ${JSON.stringify(params.version ?? "20.0.0")} }); + Object.defineProperty(process.stdin, "isTTY", { value: ${params.tty ?? true} }); + Object.defineProperty(process.stderr, "isTTY", { value: ${params.tty ?? true} }); + const original = childProcess.spawnSync; + childProcess.spawnSync = (command, args, options) => { + if (command !== ${JSON.stringify(process.platform === "darwin" ? "/bin/bash" : "bash")}) return original(command, args, options); + fs.writeFileSync(${JSON.stringify(installLog)}, JSON.stringify({ command, args })); + if (${JSON.stringify(params.install ?? "ok")} === "failed") return { status: 7 }; + if (${JSON.stringify(params.install ?? "ok")} === "ok") { + fs.mkdirSync(path.dirname(${JSON.stringify(nodePath)}), { recursive: true }); + fs.symlinkSync(process.execPath, ${JSON.stringify(nodePath)}); + } + return { status: 0 }; + }; + syncBuiltinESMExports(); + } + `, + ); + await fs.writeFile( + path.join(root, "dist", "entry.js"), + ` + process.stdout.write(JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd(), path: process.env.PATH })); + process.exitCode = 17; + `, + ); + if (params.pendingLifecycle) { + await fs.writeFile(path.join(root, ".openclaw-lifecycle-pending"), ""); + await fs.mkdir(path.join(root, "dist", "infra")); + await fs.writeFile( + path.join(root, "dist", "infra", "package-lifecycle.js"), + 'if (process.env.OPENCLAW_NODE_UPDATE_RESPAWNED !== "1") throw new Error("legacy lifecycle loaded"); export function completePendingPackageLifecycle() {}', + ); + } + const run = (input: string, args = ["status"], env: NodeJS.ProcessEnv = {}) => + spawnSync( + process.execPath, + ["--import", pathToFileURL(preload).href, path.join(root, "openclaw.mjs"), ...args], + { + cwd: root, + env: { + ...launcherEnv(), + HOME: home, + OPENCLAW_HOME: home, + CI: "", + OPENCLAW_NODE_UPDATE_RESPAWNED: "", + ...env, + }, + encoding: "utf8", + input, + timeout: 15_000, + }, + ); + return { root, home, nodePath, installLog, run }; + } + + it("accepts Yes before pending lifecycle imports, installs only Node, and retries exact arguments", async () => { + const fixture = await prepareRecovery({ pendingLifecycle: true }); + const args = ["status", "--profile", "two words", "literal;argument"]; + const result = fixture.run("y\n", args); + expect(result.status, result.stdout + result.stderr).toBe(17); + expect(result.stderr).toContain("Update NodeJS: Y/N"); + expect(result.stderr).toContain("Node.js updated. Retrying your command."); + expect(result.stderr).not.toContain("legacy lifecycle loaded"); + const output = JSON.parse(result.stdout); + expect(output).toEqual({ + args, + cwd: fixture.root, + path: expect.any(String), + }); + expect(output.path.split(path.delimiter)[0]).toBe(path.dirname(fixture.nodePath)); + expect(JSON.parse(await fs.readFile(fixture.installLog, "utf8"))).toEqual({ + command: process.platform === "darwin" ? "/bin/bash" : "bash", + args: [ + path.join(fixture.root, "scripts", "install-cli.sh"), + "--node-only", + "--prefix", + path.join(fixture.home, ".openclaw", "tools", "cli-node"), + ], + }); + }); + + it.each(["n\n", "\n", "", "maybe\n", "\u0003"])( + "does not install after decline or cancellation: %j", + async (input) => { + const fixture = await prepareRecovery(); + const result = fixture.run(input); + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toContain("Update NodeJS: Y/N"); + expect(result.stderr).toContain("nvm install"); + await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(path.join(fixture.home, ".openclaw"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }, + ); + + it.each([ + { label: "non-TTY", tty: false, args: ["status"], env: {} }, + { label: "CI", tty: true, args: ["status"], env: { CI: "1" } }, + { label: "JSON", tty: true, args: ["status", "--json"], env: {} }, + { label: "non-interactive", tty: true, args: ["onboard", "--non-interactive"], env: {} }, + { label: "yes flag", tty: true, args: ["update", "--yes"], env: {} }, + { label: "hook relay", tty: true, args: ["hooks", "relay"], env: {} }, + { label: "Gmail foreground", tty: true, args: ["webhooks", "gmail", "run"], env: {} }, + ])("does not prompt or install for $label", async ({ tty, args, env }) => { + const fixture = await prepareRecovery({ tty }); + const result = fixture.run("y\n", args, env); + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).not.toContain("Update NodeJS:"); + await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it.each(["failed", "invalid"] as const)( + "does not retry after a %s installation", + async (install) => { + const fixture = await prepareRecovery({ install }); + const result = fixture.run("y\n"); + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).toContain("Node.js update failed"); + expect(result.stdout).toBe(""); + }, + ); + + it("reuses a previously approved runtime without prompting or installing", async () => { + const fixture = await prepareRecovery({ cached: true, tty: false }); + const result = fixture.run(""); + expect(result.status, result.stderr).toBe(17); + expect(result.stderr).not.toContain("Update NodeJS:"); + expect(JSON.parse(result.stdout).path.split(path.delimiter)[0]).toBe( + path.dirname(fixture.nodePath), + ); + await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("keeps a supported active Node even when a private runtime exists", async () => { + const fixture = await prepareRecovery({ cached: true, version: process.versions.node }); + const result = fixture.run(""); + expect(result.status, result.stderr).toBe(17); + expect(result.stderr).not.toContain("Node.js"); + expect(JSON.parse(result.stdout).path.split(path.delimiter)[0]).not.toBe( + path.dirname(fixture.nodePath), + ); + await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects an incompatible cached runtime without falling into a respawn loop", async () => { + const fixture = await prepareRecovery(); + await fs.mkdir(path.dirname(fixture.nodePath), { recursive: true }); + await fs.writeFile(fixture.nodePath, "#!/bin/sh\necho 20.0.0\n", { mode: 0o755 }); + const result = fixture.run("n\n"); + expect(result.status, result.stderr).toBe(1); + expect(result.stderr.match(/Update NodeJS:/g)).toHaveLength(1); + expect(result.stdout).toBe(""); + await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("fails without another prompt when a retried command still has an unsupported runtime", async () => { + const fixture = await prepareRecovery({ cached: true }); + // This preload runs even in the recovery child, unlike the ordinary fixture preload. + const incompatible = path.join(fixture.root, "always-incompatible.mjs"); + await fs.writeFile( + incompatible, + 'Object.defineProperty(process.versions, "node", { value: "20.0.0", configurable: true });', + ); + const result = fixture.run("y\n", ["status"], { + NODE_OPTIONS: `--import=${pathToFileURL(incompatible).href}`, + OPENCLAW_NODE_UPDATE_RESPAWNED: "1", + }); + expect(result.status, result.stderr).toBe(1); + expect(result.stderr).not.toContain("Update NodeJS:"); + expect(result.stdout).toBe(""); + await expect(fs.stat(fixture.installLog)).rejects.toMatchObject({ code: "ENOENT" }); + }); + }); + it("keeps the bootstrap Node range aligned with the package engine", async () => { const fixtureRoot = await makeLauncherFixture(fixtureRoots); await fs.writeFile( diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index 72f95527916c..910df924b146 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -67,6 +67,35 @@ function writeInstalledOpenClawEntry(nodeDir: string) { describe("install-cli.sh", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); + it("installs only Node into the requested prefix without entering package or service setup", () => { + const result = runInstallCliShell(` + source ${SCRIPT_PATH} + is_musl_linux() { return 1; } + os_detect() { echo linux; } + arch_detect() { echo x64; } + install_node() { printf 'node:%s:%s:%s\\n' "$1" "$2" "$PREFIX"; } + preflight_fresh_git_disk_space() { exit 91; } + install_openclaw_from_git() { exit 92; } + install_openclaw() { exit 93; } + refresh_gateway_service_if_loaded() { exit 94; } + main --node-only --prefix '/tmp/private node' --git --onboard + `); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout.trim()).toBe("node:linux:x64:/tmp/private node"); + }); + + it("refuses musl Node-only recovery before an installer can invoke system package changes", () => { + const result = runInstallCliShell(` + source ${SCRIPT_PATH} + is_musl_linux() { return 0; } + install_node() { echo unexpected-node-install; } + main --node-only + `); + expect(result.status).toBe(1); + expect(result.stdout + result.stderr).toContain("unavailable on musl Linux"); + expect(result.stdout).not.toContain("unexpected-node-install"); + }); + it("re-execs a streamed installer on Darwin Bash 5.3+ without leaving a temp file", (context) => { const bash = findDarwinReexecBash(); if (!bash) { diff --git a/test/scripts/install-ps1.test.ts b/test/scripts/install-ps1.test.ts index ac8a7ce9c5a7..ed35cc558766 100644 --- a/test/scripts/install-ps1.test.ts +++ b/test/scripts/install-ps1.test.ts @@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process"; import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, parse } from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { isSupportedOpenClawNodeVersion } from "../../node-version.mjs"; import { NODE_RELEASE_VERSION_CASES } from "../helpers/node-version-cases.js"; @@ -149,6 +149,95 @@ describe("install.ps1 failure handling", () => { const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, ""); const entrypointLines = extractEntrypointLines(source); const cases = [ + { + name: "private-node-update", + source: [ + scriptWithoutEntryPoint, + String.raw` +$root = Join-Path $script:InstallerTempDirectory ('openclaw-private-node-test-' + [guid]::NewGuid().ToString('N')) +$NodeOnly = $true +$NodePrefix = Join-Path $root 'private tools/node' +$originalTemp = $script:InstallerTempDirectory +$beforePath = $env:PATH +$beforeUserPath = [Environment]::GetEnvironmentVariable('Path', 'User') +$beforeMachinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') +$script:InstallerTempDirectory = Join-Path $root 'temp' +$script:Scenario = '' +$script:Extractions = 0 +function Check-ExistingOpenClaw { throw 'unexpected OpenClaw lookup' } +function Install-Node { throw 'unexpected package-manager install' } +function Install-OpenClaw { throw 'unexpected OpenClaw install' } +function Ensure-OpenClawOnPath { throw 'unexpected OpenClaw PATH update' } +function Add-ToProcessPath { throw 'unexpected process PATH update' } +function Add-ToUserPath { throw 'unexpected user PATH update' } +function Refresh-GatewayServiceIfLoaded { throw 'unexpected Gateway update' } +function Invoke-NpmCommand { throw 'unexpected npm invocation' } +function Invoke-RestMethod { + param([string]$Uri, [int]$TimeoutSec) + if ($Uri -ne 'https://nodejs.org/dist/index.json') { throw "unexpected metadata URL: $Uri" } + return @([pscustomobject]@{ version = 'v26.1.0'; files = @('win-x64-zip', 'win-arm64-zip') }) +} +function Invoke-WebRequest { + param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing, [int]$TimeoutSec) + if ($script:Scenario -eq 'download') { throw 'fixture download failure' } + if ($Uri -eq 'https://nodejs.org/dist/v26.1.0/SHASUMS256.txt') { + $archive = Get-ChildItem -LiteralPath (Split-Path -Parent $OutFile) -Filter '*.zip' | Select-Object -First 1 + $hash = (Get-FileHash -LiteralPath $archive.FullName -Algorithm SHA256).Hash + if ($script:Scenario -eq 'checksum') { $hash = '0' * 64 } + $name = if ($script:Scenario -eq 'missing-checksum') { 'another-node.zip' } else { $archive.Name } + [IO.File]::WriteAllText($OutFile, "$hash $name") + return + } + if ($Uri -notmatch '^https://nodejs\.org/dist/v26\.1\.0/node-v26\.1\.0-win-(x64|arm64)\.zip$') { throw "unexpected archive URL: $Uri" } + [IO.File]::WriteAllText($OutFile, 'downloaded archive bytes') +} +function Expand-PortableNodeArchive { + param([string]$ZipPath, [string]$DestinationPath) + $script:Extractions++ + New-Item -ItemType Directory -Path $DestinationPath | Out-Null + [IO.File]::WriteAllText((Join-Path $DestinationPath 'node.exe'), 'new node') + [IO.File]::WriteAllText((Join-Path $DestinationPath 'npm.cmd'), 'matching npm') + [IO.File]::WriteAllText((Join-Path $DestinationPath 'npx.cmd'), 'matching npx') + if ($script:Scenario -eq 'archive') { throw 'fixture extraction failure' } +} +function Check-Node { + param([string]$NodePath) + if (-not $NodePath -or -not (Test-Path -LiteralPath $NodePath -PathType Leaf)) { throw 'runtime was not checked by its explicit path' } + if ([IO.File]::ReadAllText($NodePath) -ne 'new node') { throw 'the downloaded runtime was not checked' } + return ($script:Scenario -ne 'runtime') +} +try { + New-Item -ItemType Directory -Force -Path $script:InstallerTempDirectory, $NodePrefix | Out-Null + foreach ($scenario in @('download', 'checksum', 'missing-checksum', 'archive', 'runtime', 'success', 'fresh')) { + $script:Scenario = $scenario + $script:Extractions = 0 + $script:InstallExitCode = 0 + [IO.File]::WriteAllText((Join-Path $NodePrefix 'node.exe'), 'previous node') + if ($scenario -eq 'fresh') { Remove-Item -LiteralPath $NodePrefix -Recurse -Force } + $output = @(Main *>&1 | ForEach-Object { $_.ToString() }) + $success = $scenario -in @('success', 'fresh') + if (($script:InstallExitCode -eq 0) -ne $success) { throw "incorrect result for $($scenario): $output" } + $expectedExtractions = if ($scenario -in @('download', 'checksum', 'missing-checksum')) { 0 } else { 1 } + if ($script:Extractions -ne $expectedExtractions) { throw "extraction boundary violated for $scenario" } + $expectedNode = if ($success) { 'new node' } else { 'previous node' } + if ([IO.File]::ReadAllText((Join-Path $NodePrefix 'node.exe')) -ne $expectedNode) { throw "previous runtime not preserved for $scenario" } + if ($success) { + if ([IO.File]::ReadAllText((Join-Path $NodePrefix 'npm.cmd')) -ne 'matching npm') { throw 'matching npm missing' } + if ([IO.File]::ReadAllText((Join-Path $NodePrefix 'npx.cmd')) -ne 'matching npx') { throw 'matching npx missing' } + } + if (@(Get-ChildItem -LiteralPath $script:InstallerTempDirectory -Force).Count -ne 0) { throw 'download temporary files remain' } + if (@(Get-ChildItem -LiteralPath (Split-Path -Parent $NodePrefix) -Force).Count -ne 1) { throw 'publication temporary directories remain' } + if ($env:PATH -cne $beforePath) { throw 'process PATH changed' } + if ([Environment]::GetEnvironmentVariable('Path', 'User') -cne $beforeUserPath) { throw 'user PATH changed' } + if ([Environment]::GetEnvironmentVariable('Path', 'Machine') -cne $beforeMachinePath) { throw 'machine PATH changed' } + } +} finally { + $script:InstallerTempDirectory = $originalTemp + Remove-Item -LiteralPath $root -Recurse -Force +} +`, + ].join("\n"), + }, { name: "native-npm-stderr", source: [ @@ -519,6 +608,12 @@ try { source: [ scriptWithoutEntryPoint, "", + "function private-node-fixture {", + " $global:LASTEXITCODE = 0", + " if ($args[0] -eq '-v') { return 'v26.1.0' }", + " return $script:FixtureSqliteVersion", + "}", + "function Get-Command { throw 'unexpected ambient runtime lookup' }", "$cases = @{", " '3.44.5' = $false", " '3.44.6' = $true", @@ -533,6 +628,9 @@ try { "foreach ($entry in $cases.GetEnumerator()) {", " $actual = Test-NodeSqliteSupported -Version $entry.Key", ' if ($actual -ne $entry.Value) { throw "Version=$($entry.Key) Actual=$actual" }', + " $script:FixtureSqliteVersion = $entry.Key", + " $actual = Check-Node -NodePath 'private-node-fixture'", + ' if ($actual -ne $entry.Value) { throw "Explicit runtime SQLite=$($entry.Key) Actual=$actual" }', "}", "", ].join("\n"), @@ -1311,6 +1409,36 @@ try { expect(result.stdout).toContain("[OK] Onboard: skipped"); }); + runIfPowerShell("requires an explicit absolute private prefix for Node-only updates", () => { + const root = harness.createTempDir("openclaw-node-only-options-"); + for (const args of [ + ["-NodeOnly"], + ["-NodeOnly", "-NodePrefix", "relative/node"], + ["-NodeOnly", "-NodePrefix", parse(root).root], + ["-NodePrefix", join(root, "private-node")], + ]) { + const result = runInstallerFile([...args, "-DryRun"]); + expect(result.status, args.join(" ")).toBe(2); + expect(result.stdout).toContain("Error:"); + } + const result = runInstallerFile([ + "-NodeOnly", + "-NodePrefix", + join(root, "private node"), + "-DryRun", + ]); + expect(result.status).toBe(0); + expect(result.stdout).toContain("PATH unchanged"); + expect(result.stdout).not.toContain("Install method:"); + }); + + runIfPowerShell( + "updates only the private runtime after checksum and compatibility checks", + () => { + expectBatchedPowerShellCase("private-node-update"); + }, + ); + it("does not exit directly from inside Main", () => { const mainBody = extractFunctionBody(source, "Main"); expect(mainBody).not.toMatch(/\bexit\b/i);