fix(installer): support alpine cli installs

This commit is contained in:
Vincent Koc
2026-05-25 01:28:08 +02:00
parent 2a73725b5d
commit f68ed721b1
4 changed files with 584 additions and 5 deletions
+1
View File
@@ -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.
+1
View File
@@ -189,6 +189,7 @@ by default, plus git-checkout installs under the same prefix flow.
<Steps>
<Step title="Install local Node runtime">
Downloads a pinned supported Node LTS tarball (the version is embedded in the script and updated independently) to `<prefix>/tools/node-v<version>` 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.
</Step>
<Step title="Ensure Git">
If Git is missing, attempts install via apt/dnf/yum on Linux or Homebrew on macOS.
+191
View File
@@ -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
+391 -5
View File
@@ -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',