From 036b422fc6294a792af43cd6424f0b9a4ed193c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 06:37:50 +0100 Subject: [PATCH] fix(installer): load nvm before node detection --- CHANGELOG.md | 1 + scripts/install.sh | 19 +++++++++ test/scripts/install-sh.test.ts | 72 +++++++++++++++++++++++++++++++-- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17cd474b3cf..ac28b9e5bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Installer/macOS: rerun Homebrew install steps without the gum spinner when raw-mode ioctl failures occur, and avoid claiming `node@24` was installed when the Homebrew keg binary is missing. Fixes #70411. Thanks @1fanwang and @dad-io. +- Installer: load nvm before Node.js detection so `curl | bash` installs respect nvm-managed Node instead of stale system Node. Fixes #49556. Thanks @heavenlxj. - Docker: copy patched dependency files into runtime images so downstream `pnpm install` layers keep working. Fixes #69224. Thanks @gucasbrg. - Agents/runtime: submit heartbeat, cron, and exec wakeups as transient runtime context instead of visible user prompts, keeping synthetic system work out of chat transcripts. Fixes #66496 and #66814. Thanks @jeades and @mandomaker. - Telegram: include native quote excerpts automatically for threaded replies and reply tags when the original Telegram text is available, without adding another config knob. Fixes #6975. Thanks @rex05ai. diff --git a/scripts/install.sh b/scripts/install.sh index eb7df218f22..ae378ccf1dc 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1413,6 +1413,24 @@ ensure_default_node_active_shell() { return 1 } +load_nvm_for_node_detection() { + local nvm_dir="${NVM_DIR:-}" + if [[ -z "$nvm_dir" && -s "$HOME/.nvm/nvm.sh" ]]; then + nvm_dir="$HOME/.nvm" + fi + if [[ -z "$nvm_dir" || ! -s "$nvm_dir/nvm.sh" ]]; then + return 0 + fi + + export NVM_DIR="$nvm_dir" + # shellcheck disable=SC1090 + . "$NVM_DIR/nvm.sh" --no-use >/dev/null 2>&1 || . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true + if command -v nvm >/dev/null 2>&1; then + nvm use default --silent >/dev/null 2>&1 || nvm use node --silent >/dev/null 2>&1 || true + fi + refresh_shell_command_cache +} + check_node() { if command -v node &> /dev/null; then NODE_VERSION="$(node_major_version || true)" @@ -2369,6 +2387,7 @@ main() { install_homebrew # Step 2: Node.js + load_nvm_for_node_detection if ! check_node; then install_node fi diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index 3d568f21fef..c1d91290a2b 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -1,22 +1,23 @@ import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; const SCRIPT_PATH = "scripts/install.sh"; -function runInstallShell(script: string) { +function runInstallShell(script: string, env: NodeJS.ProcessEnv = {}) { return spawnSync("bash", ["-c", script], { encoding: "utf8", env: { ...process.env, OPENCLAW_INSTALL_SH_NO_RUN: "1", + ...env, }, }); } -describe("install.sh apt behavior", () => { +describe("install.sh", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); it("runs apt-get through noninteractive wrappers", () => { @@ -41,6 +42,71 @@ describe("install.sh apt behavior", () => { 'run_quiet_step "Configuring NodeSource repository" sudo -E bash "$tmp"', ); }); + + it("loads nvm before checking Node.js so stale system Node does not win", () => { + expect(script).toMatch( + /# Step 2: Node\.js\s+load_nvm_for_node_detection\s+if ! check_node; then/, + ); + + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-nvm-")); + const home = join(tmp, "home"); + const systemBin = join(tmp, "system-bin"); + const nvmBin = join(home, ".nvm/versions/node/v22.22.1/bin"); + mkdirSync(systemBin, { recursive: true }); + mkdirSync(nvmBin, { recursive: true }); + mkdirSync(join(home, ".nvm"), { recursive: true }); + + const systemNode = join(systemBin, "node"); + const nvmNode = join(nvmBin, "node"); + writeFileSync(systemNode, "#!/bin/sh\necho v8.11.3\n"); + writeFileSync(nvmNode, "#!/bin/sh\necho v22.22.1\n"); + chmodSync(systemNode, 0o755); + chmodSync(nvmNode, 0o755); + writeFileSync( + join(home, ".nvm/nvm.sh"), + [ + 'NVM_DIR="${NVM_DIR:-$HOME/.nvm}"', + "export NVM_DIR", + "nvm() {", + ' if [ "$1" = "use" ]; then', + ' export PATH="$NVM_DIR/versions/node/v22.22.1/bin:$PATH"', + " return 0", + " fi", + " return 0", + "}", + "", + ].join("\n"), + ); + + let result: ReturnType | undefined; + try { + result = runInstallShell( + [ + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + "set +e", + "load_nvm_for_node_detection", + "check_node", + "status=$?", + 'printf "status=%s\\npath=%s\\nversion=%s\\n" "$status" "$(command -v node)" "$(node -v)"', + "exit $status", + ].join("\n"), + { + HOME: home, + PATH: `${systemBin}:/usr/bin:/bin`, + TERM: "dumb", + }, + ); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + + expect(result?.status).toBe(0); + const output = result?.stdout ?? ""; + expect(output).toContain("status=0"); + expect(output).toContain(`path=${nvmNode}`); + expect(output).toContain("version=v22.22.1"); + }); }); describe("install.sh macOS Homebrew Node behavior", () => {