diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 1584ab8363b0..18733eae4e00 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -447,6 +447,7 @@ link_node_runtime_paths() { } linked_node_is_usable() { + local candidate_bin local current_version local required_version @@ -462,6 +463,10 @@ linked_node_is_usable() { if ! semver_at_least "$current_version" "$required_version"; then return 1 fi + candidate_bin="$(node_dir)/bin" + if ! PATH="${candidate_bin}:${PATH}" "$(npm_bin)" --version >/dev/null 2>&1; then + return 1 + fi "$(node_bin)" -e ' const { DatabaseSync } = require("node:sqlite"); diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index 19d28e27110f..915bea42a08e 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -641,6 +641,81 @@ describe("install-cli.sh", () => { } }); + it("skips PATH Node runtimes whose npm command cannot start", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-broken-npm-")); + const badBin = join(tmp, "bad-bin"); + const goodBin = join(tmp, "good-bin"); + const prefix = join(tmp, "prefix"); + const badNpmLog = join(tmp, "bad-npm.log"); + const goodNpmLog = join(tmp, "good-npm.log"); + const goodNodeLog = join(tmp, "good-node.log"); + const badNode = join(badBin, "node"); + const badNpm = join(badBin, "npm"); + const goodNode = join(goodBin, "node"); + const goodNpm = join(goodBin, "npm"); + + mkdirSync(badBin, { recursive: true }); + mkdirSync(goodBin, { recursive: true }); + symlinkSync(process.execPath, badNode); + writeFileSync( + goodNode, + [ + "#!/bin/bash", + 'printf "%s\\n" "$*" >> "$GOOD_NODE_LOG"', + `exec ${JSON.stringify(process.execPath)} "$@"`, + "", + ].join("\n"), + ); + writeFileSync( + badNpm, + ["#!/bin/bash", 'printf "%s\\n" "$*" >> "$BAD_NPM_LOG"', "exit 42", ""].join("\n"), + ); + writeFileSync( + goodNpm, + [ + "#!/usr/bin/env node", + 'require("node:fs").appendFileSync(', + " process.env.GOOD_NPM_LOG,", + ' `${process.argv.slice(2).join(" ")}\\n`,', + ");", + "", + ].join("\n"), + ); + chmodSync(badNpm, 0o755); + chmodSync(goodNode, 0o755); + chmodSync(goodNpm, 0o755); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + `export PATH=${JSON.stringify(`${badBin}:${goodBin}:${process.env.PATH ?? ""}`)}`, + `PREFIX=${JSON.stringify(prefix)}`, + "try_link_usable_node_runtime_from_path", + ].join("\n"), + { + BAD_NPM_LOG: badNpmLog, + GOOD_NPM_LOG: goodNpmLog, + GOOD_NODE_LOG: goodNodeLog, + }, + ); + + expect(result.status).toBe(0); + const nodeLink = join(prefix, "tools", "node-v24.15.0", "bin", "node"); + const npmLink = join(prefix, "tools", "node-v24.15.0", "bin", "npm"); + expect(readFileSync(badNpmLog, "utf8")).toBe("--version\n"); + expect(readFileSync(goodNpmLog, "utf8")).toBe("--version\n"); + expect(readFileSync(goodNodeLog, "utf8")).toContain("npm --version"); + expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true); + expect(readlinkSync(nodeLink)).toBe(goodNode); + expect(readlinkSync(npmLink)).toBe(goodNpm); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + it("rejects Alpine/musl Node packages below the requested runtime floor", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-alpine-old-node-")); const bin = join(tmp, "bin");