diff --git a/CHANGELOG.md b/CHANGELOG.md index e4beb040970d..52dd0609e6d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Installer: let the local-prefix CLI installer use Alpine's `apk` Node.js, npm, and Git packages on musl Linux instead of downloading glibc Node tarballs that fail `node:sqlite`. - Scripts: use `git grep` to prefilter tracked conflict-marker scans so changed checks avoid reading every repository file on clean runs. - Installer: install Node.js through `apk` on Alpine Linux instead of falling through to the NodeSource package-manager path. - Agents/perf: cache manifest-backed CLI provider descriptors and fallback provider resolution so model fallback retries avoid repeated bundled provider runtime scans while still invalidating across plugin reloads. diff --git a/docs/install/installer.md b/docs/install/installer.md index 8b85427c961a..305ea8779923 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -189,6 +189,7 @@ by default, plus git-checkout installs under the same prefix flow. Downloads a pinned supported Node LTS tarball (the version is embedded in the script and updated independently) to `/tools/node-v` and verifies SHA-256. + On Alpine/musl Linux, where Node does not publish compatible tarballs for the pinned runtime, installs `nodejs` and `npm` with `apk` and links that runtime into the prefix wrapper path. If Git is missing, attempts install via apt/dnf/yum on Linux or Homebrew on macOS. diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index f09ceaeec50e..a16c35811e8a 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -53,6 +53,12 @@ OPENCLAW_EFFECTIVE_HOME="$(resolve_openclaw_effective_home)" PREFIX="${OPENCLAW_PREFIX:-${HOME}/.openclaw}" OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}" NODE_VERSION="${OPENCLAW_NODE_VERSION:-22.22.0}" +NODE_VERSION_REQUESTED=0 +if [[ -n "${OPENCLAW_NODE_VERSION:-}" ]]; then + NODE_VERSION_REQUESTED=1 +fi +MIN_NODE_VERSION="22.19.0" +APK_NODE_BIN_DIR="/usr/bin" SHARP_IGNORE_GLOBAL_LIBVIPS="${SHARP_IGNORE_GLOBAL_LIBVIPS:-1}" NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}" INSTALL_METHOD="${OPENCLAW_INSTALL_METHOD:-npm}" @@ -215,6 +221,14 @@ ensure_git() { else fail "Git missing and sudo unavailable. Install git and retry." fi + elif command -v apk >/dev/null 2>&1; then + if is_root; then + apk add --no-cache git + elif has_sudo; then + sudo apk add --no-cache git + else + fail "Git missing and sudo unavailable. Install git and retry." + fi else fail "Git missing and package manager not found. Install git and retry." fi @@ -252,6 +266,7 @@ parse_args() { ;; --node-version) NODE_VERSION="$2" + NODE_VERSION_REQUESTED=1 shift 2 ;; --install-method|--method) @@ -329,6 +344,177 @@ npm_bin() { echo "$(node_dir)/bin/npm" } +command_path_without_node_prefix() { + local name="$1" + local path_entry + local prefix_bin + local filtered_path="" + local separator="" + local -a path_entries=() + + prefix_bin="$(node_dir)/bin" + IFS=: read -r -a path_entries <<<"$PATH" + for path_entry in "${path_entries[@]}"; do + if [[ "$path_entry" == "$prefix_bin" ]]; then + continue + fi + filtered_path="${filtered_path}${separator}${path_entry}" + separator=":" + done + + PATH="$filtered_path" command -v "$name" 2>/dev/null +} + +is_musl_linux() { + if [[ "$(os_detect)" != "linux" ]]; then + return 1 + fi + if [[ -f /etc/alpine-release ]]; then + return 0 + fi + ldd --version 2>&1 | grep -qi musl +} + +link_node_runtime_paths() { + local node_path="$1" + local npm_path="$2" + local dir + local runtime_bin + local resolved + dir="$(node_dir)" + runtime_bin="${node_path%/*}" + + mkdir -p "${dir}/bin" "${PREFIX}/tools" + ln -sfn "$node_path" "${dir}/bin/node" + ln -sfn "$npm_path" "${dir}/bin/npm" + for name in npx corepack; do + if [[ -x "${runtime_bin}/${name}" ]]; then + ln -sfn "${runtime_bin}/${name}" "${dir}/bin/${name}" + continue + fi + resolved="$(command_path_without_node_prefix "$name" || true)" + if [[ -n "$resolved" && "$resolved" != "${dir}/bin/${name}" ]]; then + ln -sfn "$resolved" "${dir}/bin/${name}" + fi + done + ln -sfn "$dir" "${PREFIX}/tools/node" +} + +linked_node_is_usable() { + local current_version + local required_version + + if [[ ! -x "$(node_bin)" || ! -x "$(npm_bin)" ]]; then + return 1 + fi + + current_version="$("$(node_bin)" -v 2>/dev/null || echo "")" + required_version="$(required_node_version)" + if ! semver_at_least "$current_version" "$required_version"; then + return 1 + fi + + "$(node_bin)" -e "require('node:sqlite')" >/dev/null 2>&1 +} + +semver_at_least() { + local version="${1#v}" + local required="${2#v}" + local version_major version_minor version_patch + local required_major required_minor required_patch + + IFS=. read -r version_major version_minor version_patch <<<"$version" + IFS=. read -r required_major required_minor required_patch <<<"$required" + version_minor="${version_minor:-0}" + version_patch="${version_patch:-0}" + required_minor="${required_minor:-0}" + required_patch="${required_patch:-0}" + + for part in "$version_major" "$version_minor" "$version_patch" "$required_major" "$required_minor" "$required_patch"; do + if [[ ! "$part" =~ ^[0-9]+$ ]]; then + return 1 + fi + done + + if ((version_major != required_major)); then + ((version_major > required_major)) + return + fi + if ((version_minor != required_minor)); then + ((version_minor > required_minor)) + return + fi + ((version_patch >= required_patch)) +} + +required_node_version() { + if [[ "$NODE_VERSION_REQUESTED" == "1" ]] && semver_at_least "$NODE_VERSION" "$MIN_NODE_VERSION"; then + printf '%s\n' "$NODE_VERSION" + return + fi + printf '%s\n' "$MIN_NODE_VERSION" +} + +try_link_usable_node_runtime_from_path() { + local path_entry + local prefix_bin + local -a path_entries=() + + prefix_bin="$(node_dir)/bin" + IFS=: read -r -a path_entries <<<"$PATH" + for path_entry in "${path_entries[@]}"; do + if [[ -z "$path_entry" ]]; then + path_entry="." + fi + if [[ "$path_entry" == "$prefix_bin" ]]; then + continue + fi + if [[ -x "${path_entry}/node" && -x "${path_entry}/npm" ]]; then + link_node_runtime_paths "${path_entry}/node" "${path_entry}/npm" + if linked_node_is_usable; then + return 0 + fi + fi + done + return 1 +} + +install_alpine_node() { + local installed_version + local required_version + + emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"start\",\"method\":\"apk\"}" + if try_link_usable_node_runtime_from_path; then + installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)" + emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"ok\",\"method\":\"system\",\"version\":\"${installed_version}\"}" + return + fi + + log "Installing Node via apk (Alpine Linux detected)..." + if is_root; then + apk add --no-cache nodejs npm + elif has_sudo; then + sudo apk add --no-cache nodejs npm + else + fail "Alpine Linux detected, but Node musl tarballs are unavailable and sudo is unavailable. Install nodejs and npm with apk, then retry." + fi + + if [[ -x "${APK_NODE_BIN_DIR}/node" && -x "${APK_NODE_BIN_DIR}/npm" ]]; then + link_node_runtime_paths "${APK_NODE_BIN_DIR}/node" "${APK_NODE_BIN_DIR}/npm" + elif ! try_link_usable_node_runtime_from_path; then + fail "apk Node install failed. Install nodejs and npm manually, then retry." + fi + + if ! linked_node_is_usable; then + installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)" + required_version="$(required_node_version)" + fail "Alpine Node package must provide Node >= ${required_version} with node:sqlite; found ${installed_version}." + fi + + installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)" + emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"ok\",\"method\":\"apk\",\"version\":\"${installed_version}\"}" +} + set_pnpm_cmd() { PNPM_CMD=("$@") } @@ -560,6 +746,11 @@ install_node() { arch="$(arch_detect)" dir="$(node_dir)" + if [[ "$os" == "linux" ]] && command -v apk >/dev/null 2>&1 && is_musl_linux; then + install_alpine_node + return + fi + if [[ -x "$(node_bin)" ]]; then current_major="$("$(node_bin)" -v 2>/dev/null | tr -d 'v' | cut -d'.' -f1 || echo "")" if [[ -n "$current_major" && "$current_major" -ge 22 ]]; then diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index dc1f25a387c8..a4c6172b65d6 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest"; const SCRIPT_PATH = "scripts/install-cli.sh"; function runInstallCliShell(script: string, env: NodeJS.ProcessEnv = {}) { - return spawnSync("bash", ["-c", script], { + return spawnSync("/bin/bash", ["-c", script], { encoding: "utf8", env: { ...process.env, @@ -17,6 +17,12 @@ function runInstallCliShell(script: string, env: NodeJS.ProcessEnv = {}) { }); } +function linkRequiredShellTools(bin: string) { + for (const tool of ["ln", "mkdir"]) { + symlinkSync(`/bin/${tool}`, join(bin, tool)); + } +} + describe("install-cli.sh", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); @@ -125,6 +131,386 @@ describe("install-cli.sh", () => { expect(script).toContain('activate_repo_pnpm_version "$repo_dir"'); }); + it("links an existing usable Alpine/musl Node runtime without sudo", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-alpine-")); + const bin = join(tmp, "bin"); + const prefix = join(tmp, "prefix"); + const apkLog = join(tmp, "apk.log"); + const fakeApk = join(bin, "apk"); + const fakeNode = join(bin, "node"); + const fakeNpm = join(bin, "npm"); + + mkdirSync(bin, { recursive: true }); + linkRequiredShellTools(bin); + writeFileSync( + fakeApk, + [ + "#!/bin/bash", + 'printf "%s\\n" "$*" >> "$APK_LOG"', + "exit 99", + "", + ].join("\n"), + ); + writeFileSync( + fakeNode, + [ + "#!/bin/bash", + 'if [[ "${1:-}" == "-v" ]]; then', + " printf 'v22.22.2\\n'", + " exit 0", + "fi", + 'if [[ "${1:-}" == "-e" ]]; then', + " exit 0", + "fi", + "exit 0", + "", + ].join("\n"), + ); + writeFileSync( + fakeNpm, + [ + "#!/bin/bash", + "exit 0", + "", + ].join("\n"), + ); + chmodSync(fakeApk, 0o755); + chmodSync(fakeNode, 0o755); + chmodSync(fakeNpm, 0o755); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + "os_detect() { printf 'linux\\n'; }", + "arch_detect() { printf 'x64\\n'; }", + "is_musl_linux() { return 0; }", + "is_root() { return 1; }", + `PREFIX=${JSON.stringify(prefix)}`, + `APK_NODE_BIN_DIR=${JSON.stringify(bin)}`, + "NODE_VERSION=22.22.0", + "install_node", + ].join("\n"), + { + APK_LOG: apkLog, + PATH: bin, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("Installing Node via apk"); + expect(() => readFileSync(apkLog, "utf8")).toThrow(); + const nodeLink = join(prefix, "tools", "node-v22.22.0", "bin", "node"); + const npmLink = join(prefix, "tools", "node-v22.22.0", "bin", "npm"); + expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true); + expect(readlinkSync(nodeLink)).toBe(fakeNode); + expect(readlinkSync(npmLink)).toBe(fakeNpm); + expect(script).toContain("apk add --no-cache git"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("replaces a stale Alpine/musl prefix Node before the generic skip", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-alpine-stale-")); + const bin = join(tmp, "bin"); + const oldBin = join(tmp, "old-bin"); + const prefix = join(tmp, "prefix"); + const nodePrefixBin = join(prefix, "tools", "node-v22.22.0", "bin"); + const apkLog = join(tmp, "apk.log"); + const fakeApk = join(bin, "apk"); + const fakeNode = join(bin, "node"); + const fakeNpm = join(bin, "npm"); + const oldNode = join(oldBin, "node"); + const oldNpm = join(oldBin, "npm"); + const staleNode = join(nodePrefixBin, "node"); + + mkdirSync(bin, { recursive: true }); + linkRequiredShellTools(bin); + mkdirSync(oldBin, { recursive: true }); + mkdirSync(nodePrefixBin, { recursive: true }); + writeFileSync( + fakeApk, + [ + "#!/bin/bash", + 'printf "%s\\n" "$*" >> "$APK_LOG"', + "exit 99", + "", + ].join("\n"), + ); + writeFileSync( + staleNode, + [ + "#!/bin/bash", + 'if [[ "${1:-}" == "-v" ]]; then', + " printf 'v22.22.0\\n'", + " exit 0", + "fi", + 'if [[ "${1:-}" == "-e" ]]; then', + " exit 1", + "fi", + "exit 0", + "", + ].join("\n"), + ); + writeFileSync( + fakeNode, + [ + "#!/bin/bash", + 'if [[ "${1:-}" == "-v" ]]; then', + " printf 'v22.22.2\\n'", + " exit 0", + "fi", + 'if [[ "${1:-}" == "-e" ]]; then', + " exit 0", + "fi", + "exit 0", + "", + ].join("\n"), + ); + writeFileSync( + oldNode, + [ + "#!/bin/bash", + 'if [[ "${1:-}" == "-v" ]]; then', + " printf 'v18.20.0\\n'", + " exit 0", + "fi", + 'if [[ "${1:-}" == "-e" ]]; then', + " exit 1", + "fi", + "exit 0", + "", + ].join("\n"), + ); + writeFileSync( + oldNpm, + [ + "#!/bin/bash", + "exit 0", + "", + ].join("\n"), + ); + writeFileSync( + fakeNpm, + [ + "#!/bin/bash", + "exit 0", + "", + ].join("\n"), + ); + chmodSync(fakeApk, 0o755); + chmodSync(staleNode, 0o755); + chmodSync(oldNode, 0o755); + chmodSync(oldNpm, 0o755); + chmodSync(fakeNode, 0o755); + chmodSync(fakeNpm, 0o755); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + "os_detect() { printf 'linux\\n'; }", + "arch_detect() { printf 'x64\\n'; }", + "is_musl_linux() { return 0; }", + "is_root() { return 1; }", + `PREFIX=${JSON.stringify(prefix)}`, + "NODE_VERSION=22.22.0", + "NODE_VERSION_REQUESTED=1", + "install_node", + ].join("\n"), + { + APK_LOG: apkLog, + PATH: `${nodePrefixBin}:${oldBin}:${bin}`, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("Installing Node via apk"); + expect(() => readFileSync(apkLog, "utf8")).toThrow(); + const nodeLink = join(prefix, "tools", "node-v22.22.0", "bin", "node"); + const npmLink = join(prefix, "tools", "node-v22.22.0", "bin", "npm"); + expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true); + expect(readlinkSync(nodeLink)).toBe(fakeNode); + expect(readlinkSync(npmLink)).toBe(fakeNpm); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("uses apk-managed Node and Git on Alpine/musl when the existing Node is unusable", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-alpine-apk-")); + const bin = join(tmp, "bin"); + const prefix = join(tmp, "prefix"); + const apkLog = join(tmp, "apk.log"); + const nodeState = join(tmp, "node-state"); + const fakeApk = join(bin, "apk"); + const fakeNode = join(bin, "node"); + const fakeNpm = join(bin, "npm"); + + mkdirSync(bin, { recursive: true }); + linkRequiredShellTools(bin); + writeFileSync( + fakeApk, + [ + "#!/bin/bash", + 'printf "%s\\n" "$*" >> "$APK_LOG"', + 'printf "new\\n" > "$NODE_STATE"', + "exit 0", + "", + ].join("\n"), + ); + writeFileSync( + fakeNode, + [ + "#!/bin/bash", + 'if [[ "${1:-}" == "-v" ]]; then', + ' if [[ -f "$NODE_STATE" ]]; then', + " printf 'v22.22.2\\n'", + " else", + " printf 'v18.20.0\\n'", + " fi", + " exit 0", + "fi", + 'if [[ "${1:-}" == "-e" ]]; then', + ' [[ -f "$NODE_STATE" ]]', + " exit $?", + "fi", + "exit 0", + "", + ].join("\n"), + ); + writeFileSync( + fakeNpm, + [ + "#!/bin/bash", + "exit 0", + "", + ].join("\n"), + ); + chmodSync(fakeApk, 0o755); + chmodSync(fakeNode, 0o755); + chmodSync(fakeNpm, 0o755); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + "os_detect() { printf 'linux\\n'; }", + "arch_detect() { printf 'x64\\n'; }", + "is_musl_linux() { return 0; }", + "is_root() { return 0; }", + `PREFIX=${JSON.stringify(prefix)}`, + `APK_NODE_BIN_DIR=${JSON.stringify(bin)}`, + "NODE_VERSION=22.22.0", + "NODE_VERSION_REQUESTED=1", + "install_node", + ].join("\n"), + { + APK_LOG: apkLog, + NODE_STATE: nodeState, + PATH: bin, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Installing Node via apk"); + expect(readFileSync(apkLog, "utf8")).toContain("add --no-cache nodejs npm"); + const nodeLink = join(prefix, "tools", "node-v22.22.0", "bin", "node"); + const npmLink = join(prefix, "tools", "node-v22.22.0", "bin", "npm"); + expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true); + expect(readlinkSync(nodeLink)).toBe(fakeNode); + expect(readlinkSync(npmLink)).toBe(fakeNpm); + expect(script).toContain("apk add --no-cache git"); + } 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"); + const prefix = join(tmp, "prefix"); + const apkLog = join(tmp, "apk.log"); + const fakeApk = join(bin, "apk"); + const fakeNode = join(bin, "node"); + const fakeNpm = join(bin, "npm"); + + mkdirSync(bin, { recursive: true }); + linkRequiredShellTools(bin); + writeFileSync( + fakeApk, + [ + "#!/bin/bash", + 'printf "%s\\n" "$*" >> "$APK_LOG"', + "exit 0", + "", + ].join("\n"), + ); + writeFileSync( + fakeNode, + [ + "#!/bin/bash", + 'if [[ "${1:-}" == "-v" ]]; then', + " printf 'v22.18.0\\n'", + " exit 0", + "fi", + 'if [[ "${1:-}" == "-e" ]]; then', + " exit 0", + "fi", + "exit 0", + "", + ].join("\n"), + ); + writeFileSync( + fakeNpm, + [ + "#!/bin/bash", + "exit 0", + "", + ].join("\n"), + ); + chmodSync(fakeApk, 0o755); + chmodSync(fakeNode, 0o755); + chmodSync(fakeNpm, 0o755); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + "os_detect() { printf 'linux\\n'; }", + "arch_detect() { printf 'x64\\n'; }", + "is_musl_linux() { return 0; }", + "is_root() { return 0; }", + `PREFIX=${JSON.stringify(prefix)}`, + `APK_NODE_BIN_DIR=${JSON.stringify(bin)}`, + "NODE_VERSION=22.22.0", + "NODE_VERSION_REQUESTED=1", + "install_node", + ].join("\n"), + { + APK_LOG: apkLog, + PATH: bin, + }, + ); + + expect(result.status).toBe(1); + expect(readFileSync(apkLog, "utf8")).toContain("add --no-cache nodejs npm"); + expect(result.stdout).toContain("Alpine Node package must provide Node >= 22.22.0 with node:sqlite"); + expect(result.stdout).toContain("found v22.18.0"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + it("clears npm freshness filters for package installs", () => { expect(script).toContain('freshness_flag="--min-release-age=0"'); expect(script).toContain('npm_raw_config_has_key "min-release-age"'); @@ -146,7 +532,7 @@ describe("install-cli.sh", () => { writeFileSync( fakeNpm, [ - "#!/usr/bin/env bash", + "#!/bin/bash", 'if [[ "$1" == "config" && "$2" == "get" ]]; then', ' if [[ "$3" == "min-release-age" ]]; then', " printf 'null\\n'", @@ -213,7 +599,7 @@ describe("install-cli.sh", () => { writeFileSync( fakeNpm, [ - "#!/usr/bin/env bash", + "#!/bin/bash", 'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"', 'if [[ "$1" == "config" && "$2" == "get" ]]; then', ' if [[ "$3" == "min-release-age" ]]; then', @@ -290,7 +676,7 @@ describe("install-cli.sh", () => { writeFileSync( fakeNpm, [ - "#!/usr/bin/env bash", + "#!/bin/bash", 'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"', 'if [[ "$1" == "config" && "$2" == "get" ]]; then', ' if [[ "$3" == "min-release-age" ]]; then',