Files
openclaw-openclaw/test/scripts/install-sh.test.ts
Sebastien Tardif 0d6a1dcb53 fix(install): trap SIGINT so Ctrl+C exits cleanly during upgrade doctor (#76386)
* style: restore exec approval e2e formatting

* fix(install): trap SIGINT so Ctrl+C exits cleanly during upgrade doctor

Three changes to fix the install script's Ctrl+C handling:

1. Add INT/TERM signal traps that clean up temp files and exit with
   the correct signal exit codes (130 for SIGINT, 143 for SIGTERM).

2. Preserve signal exit codes (>128) through run_quiet_step so the
   doctor path can distinguish user cancellation from normal errors.
   Non-signal failures still return 1, preserving existing caller
   semantics for all other installer steps.

3. Fix guardCancel in onboard-helpers.ts: exit(0) changed to exit(1)
   so Clack prompt cancellation (Escape/Ctrl+C) is treated as failure,
   not success. This prevents the installer from continuing with plugin
   updates after the user explicitly cancelled.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* fix(install): abort dashboard launch on doctor cancellation

When a user cancels the interactive upgrade-doctor prompt (Clack
cancellation exits 1, SIGINT exits 130), clear should_open_dashboard
so the installer does not launch a dead dashboard after an incomplete
upgrade.

Also propagate non-zero exit from run_doctor() so the non-interactive
upgrade path correctly skips dashboard launch on failure.

* fix: guard every run_doctor caller and add focused tests

The existing-config path called run_doctor without checking its return
value, so a failed or cancelled doctor would still launch the dashboard.
Now both run_doctor call sites guard the return value with if-then.

Adds focused tests verifying: every run_doctor caller is guarded,
dashboard flag is cleared on doctor failure, signal exit codes
propagate through run_quiet_step, and SIGINT (exit 130) triggers
abort_install_int.

* retrigger proof check

* fix: exit 130 on Clack cancellation so installer treats it as SIGINT

guardCancel now exits with 130 (SIGINT convention) instead of 1. When
the user presses Ctrl+C at an interactive doctor prompt, the installer
sees doctor_exit=130 and calls abort_install_int, aborting cleanly
instead of continuing after exit 1.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* fix: narrow exit 130 to doctor-prompter path only

Revert guardCancel to exit 0 by default (matching main) and pass
exit code 130 only from doctor-prompter where the installer needs
to distinguish user cancellation from normal failures.

This preserves the existing cancellation behavior for configure,
wizard, gateway, and daemon prompts while keeping the SIGINT
convention for the installer's doctor subprocess.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

---------

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-06 06:19:44 +00:00

1751 lines
62 KiB
TypeScript

// Install Sh tests cover install sh script behavior.
import { spawnSync } from "node:child_process";
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";
import {
writeNpmBeforePolicyFixture,
writeNpmFreshnessConflictFixture,
} from "./install-npm-fixtures.js";
const SCRIPT_PATH = "scripts/install.sh";
function runInstallShell(script: string, env: NodeJS.ProcessEnv = {}) {
const home = mkdtempSync(join(tmpdir(), "openclaw-install-home-"));
try {
return spawnSync("bash", ["-c", script], {
encoding: "utf8",
env: {
...process.env,
HOME: home,
...env,
BASH_ENV: "",
ENV: "",
OPENCLAW_INSTALL_SH_NO_RUN: "1",
},
});
} finally {
rmSync(home, { force: true, recursive: true });
}
}
describe("install.sh", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
it("runs installer snippets without inherited shell startup files", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-shell-env-"));
const bashEnvPath = join(tmp, "bash_env");
writeFileSync(bashEnvPath, "export OPENCLAW_BASH_ENV_LEAKED=1\n");
try {
const result = runInstallShell('printf "leaked=%s\\n" "${OPENCLAW_BASH_ENV_LEAKED:-0}"', {
BASH_ENV: bashEnvPath,
});
expect(result.status).toBe(0);
expect(result.stdout).toBe("leaked=0\n");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("runs apt-get through noninteractive wrappers", () => {
expect(script).toContain("apt_get()");
expect(script).toContain('DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"');
expect(script).toContain('NEEDRESTART_MODE="${NEEDRESTART_MODE:-a}"');
expect(script).toContain("sudo env DEBIAN_FRONTEND=");
expect(script).toContain("-o Dpkg::Options::=--force-confdef");
expect(script).toContain("-o Dpkg::Options::=--force-confold");
const rawAptInstalls = script
.split("\n")
.filter((line) => /\b(?:sudo\s+)?apt-get\s+install\b/.test(line));
expect(rawAptInstalls).toStrictEqual([]);
});
it("rejects unknown installer options", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
parse_args --bogus
`);
expect(result.status).toBe(2);
expect(result.stdout + result.stderr).toContain("Unknown option: --bogus");
});
it("rejects installer options with missing values", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
parse_args --version --no-onboard
`);
expect(result.status).toBe(2);
expect(result.stdout + result.stderr).toContain("Missing value for --version");
});
it("writes git install wrappers with the resolved Node runtime", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
tmp="$(mktemp -d)"
repo="$tmp/repo"
node_dir="node-bin"
cd "$tmp"
mkdir -p "$repo/.git" "$repo/dist" "$node_dir"
touch "$repo/dist/entry.js"
cat > "$node_dir/node" <<'NODE'
#!/usr/bin/env bash
printf 'fake-node:%s\\n' "$*"
NODE
chmod +x "$node_dir/node"
PATH="$node_dir:/usr/bin:/bin"
export PATH
OS=macos
check_git() { return 0; }
ensure_pnpm() { :; }
ensure_pnpm_binary_for_scripts() { :; }
resolve_git_openclaw_ref() { printf 'main\\n'; }
checkout_git_openclaw_ref() { :; }
cleanup_legacy_submodules() { :; }
activate_repo_pnpm_version() { :; }
git_install_lockfile_flag() { printf '%s\\n' '--frozen-lockfile'; }
run_quiet_step() { return 0; }
ensure_user_local_bin_on_path() {
mkdir -p "$HOME/.local/bin"
export PATH="$HOME/.local/bin:$PATH"
}
ui_info() { :; }
ui_success() { :; }
ui_error() { printf 'error:%s\\n' "$*"; }
git() {
if [[ "$1" == "-C" && "$3" == "status" ]]; then
return 0
fi
printf 'unexpected git:%s\\n' "$*" >&2
return 1
}
install_openclaw_from_git "$repo"
wrapper="$HOME/.local/bin/openclaw"
grep -F "$tmp/$node_dir/node" "$wrapper"
cd /
PATH="/usr/bin:/bin" "$wrapper" --version
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("exec ");
expect(result.stdout).toContain("/node-bin/node");
expect(result.stdout).toContain("fake-node:");
expect(result.stdout).toContain("/repo/dist/entry.js --version");
});
it("accepts GNU and musl Linux shells in OS detection", () => {
expect(script).toContain('[[ "$OSTYPE" == "linux"* ]]');
expect(script).not.toContain('[[ "$OSTYPE" == "linux-gnu"* ]]');
});
it("installs Node.js with apk on Alpine before falling back to NodeSource", () => {
expect(script).toContain("finish_linux_node_install()");
expect(script).toContain("is_alpine_linux()");
expect(script).toContain("install_node_with_apk()");
expect(script).toContain('ui_info "Installing Node.js via apk (Alpine Linux detected)"');
expect(script).toContain(
'run_required_step "Installing Node.js" apk add --no-cache nodejs npm',
);
expect(script).toContain(
'run_required_step "Installing Node.js" sudo apk add --no-cache nodejs npm',
);
expect(script).toContain(
'run_required_step "Installing nodejs-current" apk add --no-cache nodejs-current npm',
);
expect(script).toContain("if ! node_is_supported; then");
const apkIndex = script.indexOf("if command -v apk &> /dev/null && is_alpine_linux; then");
const nodeSourceIndex = script.indexOf('ui_info "Installing Node.js via NodeSource"');
expect(apkIndex).toBeGreaterThan(-1);
expect(nodeSourceIndex).toBeGreaterThan(apkIndex);
});
it("uses the apk Node.js installer path on Alpine", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
require_sudo() { :; }
install_build_tools_linux() { return 0; }
is_root() { return 0; }
is_alpine_linux() { return 0; }
ui_info() { printf 'info:%s\\n' "$*"; }
ui_success() { printf 'success:%s\\n' "$*"; }
run_quiet_step() { printf 'step:%s|%s\\n' "$1" "\${*:2}"; }
apk() { :; }
node_is_supported() { return 0; }
finish_linux_node_install() { printf 'finish-linux-node\\n'; }
install_node
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("info:Installing Node.js via apk (Alpine Linux detected)");
expect(result.stdout).toContain("step:Installing Node.js|apk add --no-cache nodejs npm");
expect(result.stdout).toContain("finish-linux-node");
expect(result.stdout).not.toContain("Installing Node.js via NodeSource");
});
it("ignores an unrelated pacman command on Debian", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
require_sudo() { :; }
install_build_tools_linux() { return 0; }
is_root() { return 0; }
is_arch_linux() { return 1; }
is_alpine_linux() { return 1; }
pacman() { printf 'pacman:%s\\n' "$*"; }
apt-get() { :; }
ui_info() { printf 'info:%s\\n' "$*"; }
ui_success() { :; }
run_required_step() { printf 'step:%s|%s\\n' "$1" "\${*:2}"; }
finish_linux_node_install() { printf 'finish-linux-node\\n'; }
install_node
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("info:Installing Node.js via NodeSource");
expect(result.stdout).toContain("step:Installing Node.js|apt_get_install nodejs");
expect(result.stdout).toContain("finish-linux-node");
expect(result.stdout).not.toContain("pacman:");
});
it("uses pacman for Node.js on Arch Linux", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
require_sudo() { :; }
install_build_tools_linux() { return 0; }
is_root() { return 0; }
is_arch_linux() { return 0; }
pacman() { :; }
ui_info() { printf 'info:%s\\n' "$*"; }
ui_success() { :; }
run_required_step() { printf 'step:%s|%s\\n' "$1" "\${*:2}"; }
finish_linux_node_install() { printf 'finish-linux-node\\n'; }
install_node
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain(
"info:Installing Node.js via pacman (Arch-based distribution detected)",
);
expect(result.stdout).toContain("step:Installing Node.js|pacman -Sy --noconfirm nodejs npm");
expect(result.stdout).toContain("finish-linux-node");
expect(result.stdout).not.toContain("Installing Node.js via NodeSource");
});
it("tries nodejs-current when Alpine nodejs is below the runtime floor", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
NODE_FAKE_VERSION=v20.15.1
require_sudo() { :; }
install_build_tools_linux() { return 0; }
is_root() { return 0; }
is_alpine_linux() { return 0; }
ui_info() { printf 'info:%s\\n' "$*"; }
ui_success() { printf 'success:%s\\n' "$*"; }
ui_warn() { printf 'warn:%s\\n' "$*"; }
run_quiet_step() {
printf 'step:%s|%s\\n' "$1" "\${*:2}"
"\${@:2}"
}
apk() {
printf 'apk:%s\\n' "$*"
if [[ "$*" == *"nodejs-current"* ]]; then
NODE_FAKE_VERSION=v22.22.2
fi
}
node() {
if [[ "\${1:-}" == "-v" ]]; then
printf '%s\\n' "$NODE_FAKE_VERSION"
fi
}
activate_supported_node_on_path() { :; }
finish_linux_node_install() { printf 'finish-linux-node\\n'; }
install_node
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("step:Installing Node.js|apk add --no-cache nodejs npm");
expect(result.stdout).toContain("warn:Alpine nodejs package installed v20.15.1");
expect(result.stdout).toContain(
"step:Installing nodejs-current|apk add --no-cache nodejs-current npm",
);
expect(result.stdout).toContain("finish-linux-node");
});
it("fails with Alpine version guidance when apk cannot provide the runtime floor", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
NODE_FAKE_VERSION=v20.15.1
require_sudo() { :; }
install_build_tools_linux() { return 0; }
is_root() { return 0; }
is_alpine_linux() { return 0; }
ui_info() { printf 'info:%s\\n' "$*"; }
ui_success() { printf 'success:%s\\n' "$*"; }
ui_warn() { printf 'warn:%s\\n' "$*"; }
ui_error() { printf 'error:%s\\n' "$*"; }
run_quiet_step() {
printf 'step:%s|%s\\n' "$1" "\${*:2}"
"\${@:2}"
}
apk() {
printf 'apk:%s\\n' "$*"
if [[ "$*" == *"nodejs-current"* ]]; then
NODE_FAKE_VERSION=v21.7.3
fi
}
node() {
if [[ "\${1:-}" == "-v" ]]; then
printf '%s\\n' "$NODE_FAKE_VERSION"
fi
}
activate_supported_node_on_path() { :; }
install_node
`);
expect(result.status).toBe(1);
expect(result.stdout).toContain("warn:Alpine nodejs package installed v20.15.1");
expect(result.stdout).toContain(
"step:Installing nodejs-current|apk add --no-cache nodejs-current npm",
);
expect(result.stdout).toContain(
"error:Alpine apk repositories did not provide Node.js 22.19+, 23.11+, or 24+",
);
expect(result.stdout).toContain("Use Alpine 3.21+ or install Node.js 24 manually");
});
it("stops when NodeSource repository setup fails", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
require_sudo() { :; }
install_build_tools_linux() { return 0; }
is_root() { return 0; }
is_alpine_linux() { return 1; }
apt-get() { :; }
download_file() { :; }
ui_info() { printf 'info:%s\\n' "$*"; }
ui_success() { printf 'success:%s\\n' "$*"; }
ui_error() { printf 'error:%s\\n' "$*"; }
run_quiet_step() {
printf 'step:%s|%s\\n' "$1" "\${*:2}"
if [[ "$1" == "Configuring NodeSource repository" ]]; then
return 64
fi
return 0
}
node() {
if [[ "\${1:-}" == "-v" ]]; then
printf 'v24.0.0\\n'
fi
}
activate_supported_node_on_path() { :; }
if install_node; then
echo "install_node returned success"
fi
`);
expect(result.status).toBe(1);
expect(result.stdout).toContain("step:Configuring NodeSource repository|bash");
expect(result.stdout).not.toContain("step:Installing Node.js|apt_get_install nodejs");
expect(result.stdout).not.toContain("success:Node.js v24.0.0 installed");
expect(result.stdout).not.toContain("install_node returned success");
});
it("stops when apt cannot install the Node.js package", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
require_sudo() { :; }
install_build_tools_linux() { return 0; }
is_root() { return 0; }
is_alpine_linux() { return 1; }
apt-get() { :; }
download_file() { :; }
ui_info() { printf 'info:%s\\n' "$*"; }
ui_success() { printf 'success:%s\\n' "$*"; }
ui_error() { printf 'error:%s\\n' "$*"; }
run_quiet_step() {
printf 'step:%s|%s\\n' "$1" "\${*:2}"
if [[ "$1" == "Installing Node.js" ]]; then
return 65
fi
return 0
}
node() {
if [[ "\${1:-}" == "-v" ]]; then
printf 'v24.0.0\\n'
fi
}
activate_supported_node_on_path() { :; }
if install_node; then
echo "install_node returned success"
fi
`);
expect(result.status).toBe(1);
expect(result.stdout).toContain("step:Configuring NodeSource repository|bash");
expect(result.stdout).toContain("step:Installing Node.js|apt_get_install nodejs");
expect(result.stdout).not.toContain("success:Node.js v24.0.0 installed");
expect(result.stdout).not.toContain("install_node returned success");
});
it("installs Git with apk on Alpine", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-git-apk-"));
const bin = join(tmp, "bin");
const apkLog = join(tmp, "apk-args.txt");
mkdirSync(bin, { recursive: true });
const fakeApk = join(bin, "apk");
writeFileSync(
fakeApk,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`printf '%s\\n' "$*" >> ${JSON.stringify(apkLog)}`,
"",
].join("\n"),
);
chmodSync(fakeApk, 0o755);
try {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
PATH=${JSON.stringify(`${bin}:/bin`)}
OS=linux
require_sudo() { :; }
is_root() { return 0; }
is_alpine_linux() { return 0; }
ui_success() { printf 'success:%s\\n' "$*"; }
ui_error() { printf 'error:%s\\n' "$*"; }
run_quiet_step() {
printf 'step:%s|%s\\n' "$1" "\${*:2}"
"\${@:2}"
}
install_git
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("step:Installing Git|apk add --no-cache git");
expect(result.stdout).toContain("success:Git installed");
expect(readFileSync(apkLog, "utf8").trim()).toBe("add --no-cache git");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("does not select apk Git on non-Alpine hosts", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-git-native-"));
const bin = join(tmp, "bin");
const apkLog = join(tmp, "apk-args.txt");
mkdirSync(bin, { recursive: true });
const fakeApk = join(bin, "apk");
const fakeApt = join(bin, "apt-get");
writeFileSync(apkLog, "");
writeFileSync(
fakeApk,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`printf '%s\\n' "$*" >> ${JSON.stringify(apkLog)}`,
"",
].join("\n"),
);
writeFileSync(fakeApt, "#!/usr/bin/env bash\nexit 0\n");
chmodSync(fakeApk, 0o755);
chmodSync(fakeApt, 0o755);
try {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
PATH=${JSON.stringify(`${bin}:/bin`)}
OS=linux
require_sudo() { :; }
is_root() { return 0; }
is_alpine_linux() { return 1; }
apt_get_update() { printf 'apt-update\\n'; }
apt_get_install() { printf 'apt-install:%s\\n' "$*"; }
ui_success() { printf 'success:%s\\n' "$*"; }
ui_error() { printf 'error:%s\\n' "$*"; }
run_quiet_step() {
printf 'step:%s|%s\\n' "$1" "\${*:2}"
"\${@:2}"
}
install_git
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("step:Updating package index|apt_get_update");
expect(result.stdout).toContain("apt-update");
expect(result.stdout).toContain("step:Installing Git|apt_get_install git");
expect(result.stdout).toContain("apt-install:git");
expect(readFileSync(apkLog, "utf8")).toBe("");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("clears npm freshness filters for package installs", () => {
expect(script).toContain("env -u NPM_CONFIG_BEFORE -u npm_config_before");
expect(script).toContain('freshness_flag="--min-release-age=0"');
expect(script).toContain('npm_config_has_raw_key npm "min-release-age"');
expect(script).toContain('freshness_flag="--before=$(date -u');
expect(script).toContain('cmd+=(--no-fund --no-audit "$freshness_flag" install -g "$spec")');
});
it("does not emit --before when raw user npmrc config contains min-release-age", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npmrc-"));
const bin = join(tmp, "bin");
const home = join(tmp, "home");
const npmrc = join(tmp, "user.npmrc");
const calls = join(tmp, "npm-calls.txt");
const installArgs = join(tmp, "npm-install-args.txt");
mkdirSync(bin, { recursive: true });
mkdirSync(home, { recursive: true });
writeFileSync(npmrc, "min-release-age=7\n");
const fakeNpm = join(bin, "npm");
writeFileSync(
fakeNpm,
[
"#!/usr/bin/env bash",
'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"',
'if [[ "$1" == "config" && "$2" == "get" ]]; then',
' if [[ "$3" == "min-release-age" ]]; then',
" printf 'null\\n'",
" exit 0",
" fi",
' if [[ "$3" == "before" ]]; then',
" printf '2026-01-01T00:00:00.000Z\\n'",
" exit 0",
" fi",
"fi",
'printf "%s\\n" "$@" > "$NPM_FAKE_INSTALL_ARGS"',
"exit 0",
"",
].join("\n"),
);
chmodSync(fakeNpm, 0o755);
try {
const result = runInstallShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
'printf "cmd=%s\\n" "$LAST_NPM_INSTALL_CMD"',
].join("\n"),
{
HOME: home,
NPM_CONFIG_USERCONFIG: npmrc,
NPM_FAKE_CALLS: calls,
NPM_FAKE_INSTALL_ARGS: installArgs,
PATH: `${bin}:/usr/local/bin:/usr/bin:/bin`,
},
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("--min-release-age=0");
expect(result.stdout).not.toContain("--before=");
expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n");
expect(readFileSync(installArgs, "utf8")).not.toContain("--before=");
expect(readFileSync(calls, "utf8")).not.toContain("config get before");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("does not emit --before when default global npmrc config contains min-release-age", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-global-npmrc-"));
const bin = join(tmp, "bin");
const home = join(tmp, "home");
const prefix = join(tmp, "prefix");
const npmrc = join(prefix, "etc", "npmrc");
const calls = join(tmp, "npm-calls.txt");
const installArgs = join(tmp, "npm-install-args.txt");
mkdirSync(bin, { recursive: true });
mkdirSync(home, { recursive: true });
mkdirSync(join(prefix, "etc"), { recursive: true });
writeFileSync(npmrc, "min-release-age=7\n");
const fakeNpm = join(bin, "npm");
writeFileSync(
fakeNpm,
[
"#!/usr/bin/env bash",
'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"',
'if [[ "$1" == "config" && "$2" == "get" ]]; then',
' if [[ "$3" == "min-release-age" ]]; then',
" printf 'null\\n'",
" exit 0",
" fi",
' if [[ "$3" == "globalconfig" ]]; then',
' printf "%s\\n" "$NPM_FAKE_GLOBALCONFIG"',
" exit 0",
" fi",
' if [[ "$3" == "before" ]]; then',
" printf '2026-01-01T00:00:00.000Z\\n'",
" exit 0",
" fi",
"fi",
'printf "%s\\n" "$@" > "$NPM_FAKE_INSTALL_ARGS"',
"exit 0",
"",
].join("\n"),
);
chmodSync(fakeNpm, 0o755);
try {
const result = runInstallShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
'printf "cmd=%s\\n" "$LAST_NPM_INSTALL_CMD"',
].join("\n"),
{
HOME: home,
NPM_CONFIG_GLOBALCONFIG: undefined,
NPM_CONFIG_PREFIX: undefined,
npm_config_globalconfig: undefined,
npm_config_prefix: undefined,
NPM_FAKE_CALLS: calls,
NPM_FAKE_GLOBALCONFIG: npmrc,
NPM_FAKE_INSTALL_ARGS: installArgs,
PATH: `${bin}:${process.env.PATH}`,
},
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("--min-release-age=0");
expect(result.stdout).not.toContain("--before=");
expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n");
expect(readFileSync(installArgs, "utf8")).not.toContain("--before=");
expect(readFileSync(calls, "utf8")).not.toContain("config get before");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("does not emit --before when builtin npmrc config contains min-release-age", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-builtin-npmrc-"));
const bin = join(tmp, "bin");
const home = join(tmp, "home");
const npmrc = join(tmp, "npmrc");
const calls = join(tmp, "npm-calls.txt");
const installArgs = join(tmp, "npm-install-args.txt");
mkdirSync(bin, { recursive: true });
mkdirSync(home, { recursive: true });
writeFileSync(npmrc, "min-release-age=7\n");
const fakeNpm = join(bin, "npm");
writeFileSync(
fakeNpm,
[
"#!/usr/bin/env bash",
'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"',
'if [[ "$1" == "config" && "$2" == "get" ]]; then',
' if [[ "$3" == "min-release-age" ]]; then',
" printf 'null\\n'",
" exit 0",
" fi",
' if [[ "$3" == "globalconfig" ]]; then',
' printf "%s\\n" "$NPM_FAKE_GLOBALCONFIG"',
" exit 0",
" fi",
' if [[ "$3" == "before" ]]; then',
" printf '2026-01-01T00:00:00.000Z\\n'",
" exit 0",
" fi",
"fi",
'printf "%s\\n" "$@" > "$NPM_FAKE_INSTALL_ARGS"',
"exit 0",
"",
].join("\n"),
);
chmodSync(fakeNpm, 0o755);
try {
const result = runInstallShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
'printf "cmd=%s\\n" "$LAST_NPM_INSTALL_CMD"',
].join("\n"),
{
HOME: home,
NPM_CONFIG_GLOBALCONFIG: undefined,
NPM_CONFIG_PREFIX: undefined,
npm_config_globalconfig: undefined,
npm_config_prefix: undefined,
NPM_FAKE_CALLS: calls,
NPM_FAKE_GLOBALCONFIG: join(tmp, "missing-global-npmrc"),
NPM_FAKE_INSTALL_ARGS: installArgs,
PATH: `${bin}:${process.env.PATH}`,
},
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("--min-release-age=0");
expect(result.stdout).not.toContain("--before=");
expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n");
expect(readFileSync(installArgs, "utf8")).not.toContain("--before=");
expect(readFileSync(calls, "utf8")).not.toContain("config get before");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("uses OPENCLAW_HOME for git and onboarding defaults", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-home-"));
const osHome = join(tmp, "os-home");
const openclawHome = join(tmp, "openclaw-home");
mkdirSync(osHome, { recursive: true });
mkdirSync(openclawHome, { recursive: true });
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
'printf "git=%s\\nworkspace=%s\\n" "$GIT_DIR" "$(resolve_workspace_dir)"',
"OPENCLAW_PROFILE=work",
'printf "workspaceProfile=%s\\n" "$(resolve_workspace_dir)"',
].join("\n"),
{
HOME: osHome,
OPENCLAW_HOME: openclawHome,
OPENCLAW_GIT_DIR: undefined,
TERM: "dumb",
},
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
const output = result?.stdout ?? "";
expect(output).toContain(`git=${join(openclawHome, "openclaw")}`);
expect(output).toContain(`workspace=${join(openclawHome, ".openclaw", "workspace")}`);
expect(output).toContain(
`workspaceProfile=${join(openclawHome, ".openclaw", "workspace-work")}`,
);
const mkdirParentIndex = script.indexOf('mkdir -p "$(dirname "$repo_dir")"');
const cloneIndex = script.indexOf(
'run_quiet_step "Cloning OpenClaw" git clone "$repo_url" "$repo_dir"',
);
expect(mkdirParentIndex).toBeGreaterThan(-1);
expect(cloneIndex).toBeGreaterThan(-1);
expect(mkdirParentIndex).toBeLessThan(cloneIndex);
});
it("skips bootstrap onboarding when legacy HOME config exists with OPENCLAW_HOME", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-legacy-config-"));
const osHome = join(tmp, "os-home");
const openclawHome = join(tmp, "openclaw-home");
const legacyConfigDir = join(osHome, ".openclaw");
const bootstrapDir = join(openclawHome, ".openclaw", "workspace");
mkdirSync(legacyConfigDir, { recursive: true });
mkdirSync(bootstrapDir, { recursive: true });
writeFileSync(join(legacyConfigDir, "openclaw.json"), "{}\n");
writeFileSync(join(bootstrapDir, "BOOTSTRAP.md"), "# bootstrap\n");
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"NO_ONBOARD=0",
"run_bootstrap_onboarding_if_needed",
].join("\n"),
{
HOME: osHome,
OPENCLAW_HOME: openclawHome,
OPENCLAW_CONFIG_PATH: undefined,
TERM: "dumb",
},
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(result?.stdout ?? "").not.toContain("BOOTSTRAP.md found");
expect(result?.stderr ?? "").toBe("");
});
it("rejects OpenClaw GitHub source targets for npm installs", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
set +e
OPENCLAW_VERSION=main
USE_BETA=0
install_openclaw
status=$?
printf 'status=%s\\n' "$status"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("status=1");
expect(result.stdout).toContain("npm installs do not support OpenClaw GitHub source targets");
expect(result.stdout).toContain("--install-method git --version main");
});
it("does not emit before args when npmrc min-release-age computes a before cutoff", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-freshness-"));
const bin = join(tmp, "bin");
const home = join(tmp, "home");
const argsLog = join(tmp, "npm-args.log");
mkdirSync(bin, { recursive: true });
mkdirSync(home, { recursive: true });
writeFileSync(join(home, ".npmrc"), "min-release-age=7\n");
writeNpmFreshnessConflictFixture(join(bin, "npm"), argsLog);
let result: ReturnType<typeof runInstallShell> | undefined;
let argsOutput;
try {
result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`HOME=${JSON.stringify(home)}`,
`PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`,
"NPM_LOGLEVEL=error",
"NPM_SILENT_FLAG=",
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
].join("\n"),
);
argsOutput = readFileSync(argsLog, "utf8");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(argsOutput).toContain("--min-release-age=0");
expect(argsOutput).not.toContain("--before=");
});
it("ignores project npmrc when choosing global install freshness args", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-global-freshness-"));
const bin = join(tmp, "bin");
const home = join(tmp, "home");
const project = join(tmp, "project");
const argsLog = join(tmp, "npm-args.log");
mkdirSync(bin, { recursive: true });
mkdirSync(home, { recursive: true });
mkdirSync(project, { recursive: true });
writeFileSync(join(home, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n");
writeFileSync(join(project, ".npmrc"), "min-release-age=7\n");
writeNpmBeforePolicyFixture(join(bin, "npm"), argsLog);
let result: ReturnType<typeof runInstallShell> | undefined;
let argsOutput;
try {
result = runInstallShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(project)}`,
`source ${JSON.stringify(process.cwd() + "/" + SCRIPT_PATH)}`,
`HOME=${JSON.stringify(home)}`,
`PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`,
"NPM_LOGLEVEL=error",
"NPM_SILENT_FLAG=",
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
].join("\n"),
);
argsOutput = readFileSync(argsLog, "utf8");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(argsOutput).toContain("--before=");
expect(argsOutput).not.toContain("--min-release-age=0");
});
it("exports noninteractive apt env during Linux startup", () => {
expect(script).toMatch(
/detect_os_or_die\s+if \[\[ "\$OS" == "linux" \]\]; then\s+export DEBIAN_FRONTEND="\$\{DEBIAN_FRONTEND:-noninteractive\}"\s+export NEEDRESTART_MODE="\$\{NEEDRESTART_MODE:-a\}"\s+fi/m,
);
expect(script).toContain(
'run_required_step "Configuring NodeSource repository" sudo -E bash "$tmp"',
);
});
it("counts the verify stage when --verify is enabled", () => {
const result = runInstallShell(
[
`source ${JSON.stringify(SCRIPT_PATH)}`,
"parse_args --verify",
"configure_install_stage_total",
'ui_stage "Preparing environment"',
'ui_stage "Installing OpenClaw"',
'ui_stage "Finalizing setup"',
'ui_stage "Verifying installation"',
].join("\n"),
{ TERM: "dumb" },
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("[4/4] Verifying installation");
expect(result.stdout).not.toContain("[4/3] Verifying installation");
});
it("bounds installer npm prefix probes during finalization helpers", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-probe-"));
const npm = join(tmp, "npm");
writeFileSync(
npm,
[
"#!/usr/bin/env bash",
'if [[ "$1" == "prefix" && "$2" == "-g" ]]; then',
" sleep 2",
" exit 0",
"fi",
'if [[ "$1" == "config" && "$2" == "get" && "$3" == "prefix" ]]; then',
' printf "/tmp/openclaw-npm\\n"',
" exit 0",
"fi",
"exit 1",
"",
].join("\n"),
);
chmodSync(npm, 0o755);
try {
const result = runInstallShell(
[`source ${JSON.stringify(SCRIPT_PATH)}`, "npm_global_bin_dir"].join("\n"),
{
OPENCLAW_INSTALL_PROBE_TIMEOUT_SECONDS: "0.1",
PATH: `${tmp}:${process.env.PATH ?? ""}`,
},
);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("/tmp/openclaw-npm/bin");
expect(result.stderr).toContain(
"timed out during installer finalization probe: npm prefix -g",
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("bounds daemon status probes during finalization helpers", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-probe-"));
const claw = join(tmp, "openclaw");
writeFileSync(
claw,
[
"#!/usr/bin/env bash",
'if [[ "$1" == "daemon" && "$2" == "status" && "$3" == "--json" ]]; then',
" sleep 2",
" exit 0",
"fi",
"exit 1",
"",
].join("\n"),
);
chmodSync(claw, 0o755);
try {
const result = runInstallShell(
[
`source ${JSON.stringify(SCRIPT_PATH)}`,
`if is_gateway_daemon_loaded ${JSON.stringify(claw)}; then`,
' printf "loaded\\n"',
"else",
' printf "not-loaded\\n"',
"fi",
].join("\n"),
{ OPENCLAW_INSTALL_PROBE_TIMEOUT_SECONDS: "0.01" },
);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("not-loaded");
expect(result.stderr).toContain(
"timed out during installer finalization probe: openclaw daemon status --json",
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("loads nvm before checking Node.js so stale system Node does not win", () => {
expect(script).toMatch(
/# Step 1: Node\.js[\s\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<typeof runInstallShell> | 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,
NVM_DIR: join(tmp, "stale-nvm"),
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");
});
it("installs Homebrew lazily before macOS Git installs", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=macos
install_homebrew() { echo "install_homebrew"; }
run_quiet_step() { echo "run_quiet_step:$*"; return 0; }
install_git
`);
expect(result.status).toBe(0);
expect(result.stdout).toMatch(
/install_homebrew\s+run_quiet_step:Installing Git brew install git/,
);
});
it("promotes a supported Linux Node binary over stale PATH entries", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-node-promote-"));
const staleBin = join(tmp, "usr-local-bin");
const supportedBin = join(tmp, "usr-bin");
mkdirSync(staleBin, { recursive: true });
mkdirSync(supportedBin, { recursive: true });
const staleNode = join(staleBin, "node");
const supportedNode = join(supportedBin, "node");
writeFileSync(staleNode, "#!/bin/sh\necho v20.20.0\n");
writeFileSync(supportedNode, "#!/bin/sh\necho v22.22.0\n");
chmodSync(staleNode, 0o755);
chmodSync(supportedNode, 0o755);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"type() {",
' if [[ "$*" == "-P -a node" ]]; then',
` printf '%s\\n' ${JSON.stringify(staleNode)} ${JSON.stringify(supportedNode)}`,
" return 0",
" fi",
' builtin type "$@"',
"}",
"set +e",
"OS=linux",
"promote_supported_node_binary",
"promote_status=$?",
"ensure_default_node_active_shell",
"active_status=$?",
'printf "promote=%s\\nactive=%s\\npath=%s\\nversion=%s\\n" "$promote_status" "$active_status" "$(command -v node)" "$(node -v)"',
"exit $active_status",
].join("\n"),
{
PATH: `${staleBin}:${supportedBin}:/usr/bin:/bin`,
TERM: "dumb",
},
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
const output = result?.stdout ?? "";
expect(output).toContain("promote=0");
expect(output).toContain("active=0");
expect(output).toContain(`path=${supportedNode}`);
expect(output).toContain("version=v22.22.0");
});
it("uses the package engine range when accepting existing Node runtimes", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as {
engines?: { node?: string };
};
expect(pkg.engines?.node).toBe(">=22.19.0 <23 || >=23.11.0");
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-node-floor-"));
const bin = join(tmp, "bin");
mkdirSync(bin, { recursive: true });
const nodePath = join(bin, "node");
writeFileSync(
nodePath,
["#!/bin/sh", 'printf "%s\\n" "${FAKE_NODE_VERSION:-v0.0.0}"', ""].join("\n"),
);
chmodSync(nodePath, 0o755);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"set +e",
`PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`,
"export PATH",
"unset -f node 2>/dev/null || true",
"unalias node 2>/dev/null || true",
'node() { printf "%s\\n" "${FAKE_NODE_VERSION:-v0.0.0}"; }',
"for version in 22.18.9 22.19.0 23.7.0 23.10.9 23.11.0 24.0.0; do",
' FAKE_NODE_VERSION="v${version}"',
" export FAKE_NODE_VERSION",
" node_is_supported",
' printf "%s=%s\\n" "$version" "$?"',
"done",
"exit 0",
].join("\n"),
{
PATH: `${bin}:/usr/bin:/bin`,
TERM: "dumb",
},
);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(result?.stdout).toContain("22.18.9=1");
expect(result?.stdout).toContain("22.19.0=0");
expect(result?.stdout).toContain("23.7.0=1");
expect(result?.stdout).toContain("23.10.9=1");
expect(result?.stdout).toContain("23.11.0=0");
expect(result?.stdout).toContain("24.0.0=0");
});
it("persists a supported Linux Node path before noninteractive shell guards", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-linux-node-path-"));
const home = join(tmp, "home");
const oldBin = join(tmp, "old/bin");
const installedBin = join(tmp, "usr/bin");
mkdirSync(home, { recursive: true });
mkdirSync(oldBin, { recursive: true });
mkdirSync(installedBin, { recursive: true });
const oldNode = join(oldBin, "node");
const installedNode = join(installedBin, "node");
writeFileSync(
join(home, ".bashrc"),
[
"case $- in",
" *i*) ;;",
" *) return ;;",
"esac",
`export PATH="${installedBin}:$PATH"`,
"",
].join("\n"),
);
writeFileSync(
oldNode,
[
"#!/usr/bin/env bash",
'if [[ "${1:-}" == "-p" ]]; then echo "20 20"; exit 0; fi',
'if [[ "${1:-}" == "-v" ]]; then echo "v20.20.0"; exit 0; fi',
"",
].join("\n"),
);
writeFileSync(
installedNode,
[
"#!/usr/bin/env bash",
'if [[ "${1:-}" == "-p" ]]; then echo "24 13"; exit 0; fi',
'if [[ "${1:-}" == "-v" ]]; then echo "v24.13.0"; exit 0; fi',
"",
].join("\n"),
);
chmodSync(oldNode, 0o755);
chmodSync(installedNode, 0o755);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
HOME=${JSON.stringify(home)}
PATH=${JSON.stringify(`${oldBin}:${installedBin}:/usr/bin:/bin`)}
ui_info() { :; }
activate_supported_node_on_path
printf 'first=%s\\n' "$(sed -n '1p' "$HOME/.bashrc")"
HOME=${JSON.stringify(home)} PATH=${JSON.stringify(`${oldBin}:${installedBin}:/usr/bin:/bin`)} bash -c 'source_rc() { . "$HOME/.bashrc"; }; source_rc; printf "node=%s\\n" "$(command -v node)"'
`);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(result?.stdout).toContain(`first=export PATH="${installedBin}:$PATH"`);
expect(result?.stdout).toContain(`node=${installedNode}`);
});
it("warns before redirecting an unwritable npm prefix", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-prefix-"));
const home = join(tmp, "home");
const events = join(tmp, "events.log");
mkdirSync(home, { recursive: true });
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
HOME=${JSON.stringify(home)}
prefix=${JSON.stringify(join(tmp, "root-owned-prefix"))}
events=${JSON.stringify(events)}
npm() {
if [[ "$1" == "config" && "$2" == "get" && "$3" == "prefix" ]]; then
printf '%s\\n' "$prefix"
return 0
fi
if [[ "$1" == "config" && "$2" == "set" && "$3" == "prefix" ]]; then
printf 'npm-set:%s\\n' "$4" >> "$events"
return 0
fi
return 1
}
ui_info() { printf 'info:%s\\n' "$*" >> "$events"; }
ui_warn() { printf 'warn:%s\\n' "$*" >> "$events"; }
ui_success() { printf 'success:%s\\n' "$*" >> "$events"; }
fix_npm_permissions
cat "$events"
`);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
const lines = (result?.stdout ?? "").trim().split("\n");
const warningIndex = lines.findIndex((line) =>
line.includes("The installer will switch npm's user prefix"),
);
const npmSetIndex = lines.findIndex((line) => line.startsWith("npm-set:"));
const noSudoWarningIndex = lines.findIndex((line) => line.includes("Avoid sudo npm i -g"));
expect(warningIndex).toBeGreaterThanOrEqual(0);
expect(npmSetIndex).toBeGreaterThan(warningIndex);
expect(noSudoWarningIndex).toBeGreaterThan(npmSetIndex);
expect(result?.stdout).toContain("npm global prefix is not writable");
expect(result?.stdout).toContain("npm normally writes that setting to ~/.npmrc");
expect(result?.stdout).toContain("npm i -g openclaw@latest");
expect(result?.stdout).toContain("using this user prefix");
expect(result?.stdout).not.toContain("has been saved");
});
it("persists npm prefix PATH before noninteractive shell guards", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-prefix-shell-"));
const home = join(tmp, "home");
mkdirSync(home, { recursive: true });
writeFileSync(
join(home, ".bashrc"),
[
"case $- in",
" *i*) ;;",
" *) return ;;",
"esac",
'export PATH="$HOME/.npm-global/bin:$PATH"',
"",
].join("\n"),
);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=linux
HOME=${JSON.stringify(home)}
PATH=/usr/bin:/bin
prefix=${JSON.stringify(join(tmp, "root-owned-prefix"))}
npm() {
if [[ "$1" == "config" && "$2" == "get" && "$3" == "prefix" ]]; then
printf '%s\\n' "$prefix"
return 0
fi
if [[ "$1" == "config" && "$2" == "set" && "$3" == "prefix" ]]; then
return 0
fi
return 1
}
ui_info() { :; }
ui_warn() { :; }
ui_success() { :; }
fix_npm_permissions
printf 'first=%s\\n' "$(sed -n '1p' "$HOME/.bashrc")"
HOME=${JSON.stringify(home)} PATH=/usr/bin:/bin bash -c 'source_rc() { . "$HOME/.bashrc"; }; source_rc; printf "path=%s\\n" "\${PATH%%:*}"'
`);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(result?.stdout).toContain('first=export PATH="$HOME/.npm-global/bin:$PATH"');
expect(result?.stdout).toContain(`path=${home}/.npm-global/bin`);
});
it("uses a quoted absolute openclaw path in follow-up commands when npm bin is not on the original PATH", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-command-"));
const npmBin = join(tmp, "npm bin");
const visibleBin = join(tmp, "visible-bin");
mkdirSync(npmBin, { recursive: true });
mkdirSync(visibleBin, { recursive: true });
const openclawBin = join(npmBin, "openclaw");
writeFileSync(openclawBin, "#!/bin/sh\nexit 0\n");
chmodSync(openclawBin, 0o755);
let result: ReturnType<typeof runInstallShell> | undefined;
try {
result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
ORIGINAL_PATH=${JSON.stringify(`${visibleBin}:/usr/bin:/bin`)}
printf 'missing=%s\\n' "$(openclaw_command_for_user "${openclawBin}")"
ORIGINAL_PATH=${JSON.stringify(`${npmBin}:${visibleBin}:/usr/bin:/bin`)}
printf 'present=%s\\n' "$(openclaw_command_for_user "${openclawBin}")"
`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
expect(result?.status).toBe(0);
expect(result?.stdout).toContain(`missing=${openclawBin.replace(/ /g, "\\ ")}`);
expect(result?.stdout).toContain("present=openclaw");
});
it("resolves requested git install versions to checkout refs", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
npm() {
if [[ "$1" == "view" && "$2" == "openclaw" && "$3" == "dist-tags.beta" ]]; then
printf '2026.5.12-beta.3\\n'
return 0
fi
return 1
}
OPENCLAW_VERSION=v2026.5.12-beta.3
printf 'tag=%s\\n' "$(resolve_git_openclaw_ref)"
OPENCLAW_VERSION=2026.5.12-beta.3
printf 'semver=%s\\n' "$(resolve_git_openclaw_ref)"
OPENCLAW_VERSION=beta
printf 'beta=%s\\n' "$(resolve_git_openclaw_ref)"
OPENCLAW_VERSION=main
printf 'main=%s\\n' "$(resolve_git_openclaw_ref)"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("tag=v2026.5.12-beta.3");
expect(result.stdout).toContain("semver=v2026.5.12-beta.3");
expect(result.stdout).toContain("beta=v2026.5.12-beta.3");
expect(result.stdout).toContain("main=main");
});
it("fetches moving git refs without tags for git installs", () => {
expect(script).toContain('git -C "$repo_dir" fetch --no-tags origin main');
expect(script).toContain(
'git -C "$repo_dir" fetch --no-tags origin "refs/heads/${ref}:refs/remotes/origin/${ref}"',
);
expect(script).toContain('git -C "$repo_dir" pull --rebase --no-tags || true');
const branchCheckIndex = script.indexOf('ls-remote --exit-code --heads origin "$ref"');
const tagFetchIndex = script.indexOf("fetch --tags origin");
expect(branchCheckIndex).toBeGreaterThan(-1);
expect(tagFetchIndex).toBeGreaterThan(-1);
expect(branchCheckIndex).toBeLessThan(tagFetchIndex);
});
it("uses non-frozen lockfile installs only for moving git refs", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
git() {
if [[ "$1" == "-C" && "$3" == "ls-remote" && "\${7:-}" == "feature" ]]; then
return 0
fi
return 1
}
printf 'main=%s\\n' "$(git_install_lockfile_flag /repo main)"
printf 'branch=%s\\n' "$(git_install_lockfile_flag /repo feature)"
printf 'tag=%s\\n' "$(git_install_lockfile_flag /repo v2026.5.12)"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("main=--no-frozen-lockfile");
expect(result.stdout).toContain("branch=--no-frozen-lockfile");
expect(result.stdout).toContain("tag=--frozen-lockfile");
expect(script).toContain(
'CI="${CI:-true}" run_quiet_step "Installing dependencies" run_pnpm -C "$repo_dir" install "$install_lockfile_flag"',
);
});
it("aligns pnpm to the checked-out repo packageManager before installing", () => {
expect(script).toContain("activate_repo_pnpm_version()");
expect(script).toContain('corepack prepare "pnpm@${version}" --activate');
expect(script).toContain('activate_repo_pnpm_version "$repo_dir"');
});
it("uses the repo Corepack pnpm when a global pnpm version is already present", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-pnpm-version-"));
const bin = join(tmp, "bin");
const outer = join(tmp, "outer");
const repo = join(tmp, "repo");
mkdirSync(bin, { recursive: true });
mkdirSync(outer, { recursive: true });
mkdirSync(repo, { recursive: true });
writeFileSync(join(outer, "package.json"), '{\n "packageManager": "yarn@4.5.0"\n}\n');
writeFileSync(
join(repo, "package.json"),
'{\n "packageManager": "pnpm@11.2.2+sha512.test"\n}\n',
);
writeFileSync(
join(bin, "pnpm"),
["#!/bin/bash", '[[ "${1:-}" == "--version" ]] && echo "11.8.0"', ""].join("\n"),
);
writeFileSync(
join(bin, "corepack"),
[
"#!/bin/bash",
'if [[ "${1:-}" == "prepare" ]]; then exit 0; fi',
'if [[ "${1:-}" == "pnpm" && "${2:-}" == "--version" ]]; then',
' if grep -q "pnpm@11.2.2" package.json 2>/dev/null; then echo "11.2.2"; else exit 1; fi',
" exit 0",
"fi",
"exit 1",
"",
].join("\n"),
);
chmodSync(join(bin, "pnpm"), 0o755);
chmodSync(join(bin, "corepack"), 0o755);
try {
const result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
`cd ${JSON.stringify(outer)}`,
`activate_repo_pnpm_version ${JSON.stringify(repo)}`,
'printf "cmd=%s\\n" "${PNPM_CMD[*]}"',
`printf "run=%s\\n" "$(run_pnpm -C ${JSON.stringify(repo)} --version)"`,
].join("\n"),
{ PATH: `${bin}:${process.env.PATH ?? ""}` },
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("cmd=corepack pnpm");
expect(result.stdout).toContain("run=11.2.2");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("does not treat /dev/tty permissions as a controlling terminal", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
if has_controlling_tty; then echo "has_tty=1"; else echo "has_tty=0"; fi
if is_promptable; then echo "promptable=1"; else echo "promptable=0"; fi
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("has_tty=0");
expect(result.stdout).toContain("promptable=0");
});
});
describe("install.sh macOS Homebrew Node behavior", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
it("stops when Homebrew node installation fails", () => {
expect(script).toContain(
'if ! run_quiet_step "Installing node@${NODE_DEFAULT_MAJOR}" brew install "node@${NODE_DEFAULT_MAJOR}"; then',
);
const failedInstallIndex = script.indexOf(
'if ! run_quiet_step "Installing node@${NODE_DEFAULT_MAJOR}" brew install "node@${NODE_DEFAULT_MAJOR}"; then',
);
const brewLinkIndex = script.indexOf(
'brew link "node@${NODE_DEFAULT_MAJOR}" --overwrite --force',
);
expect(failedInstallIndex).toBeGreaterThanOrEqual(0);
expect(brewLinkIndex).toBeGreaterThan(failedInstallIndex);
});
it("aborts before brew link when Homebrew node installation fails at runtime", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=macos
run_quiet_step() { echo "run_quiet_step:$*"; return 1; }
brew() { echo "brew:$*"; return 0; }
ensure_macos_default_node_active() { echo "ensure-called"; return 0; }
if install_node; then
echo "install_node returned success"
else
echo "install_node returned failure"
fi
`);
expect(result.status).toBe(1);
expect(result.stdout).toContain(
"Re-run with --verbose or run 'brew install node@24' directly, then rerun the installer.",
);
expect(result.stdout).not.toContain("brew:link");
expect(result.stdout).not.toContain("ensure-called");
});
it("separates missing Homebrew node from PATH shadowing", () => {
const missingNodeGuardIndex = script.indexOf(
'if [[ -z "$brew_node_prefix" || ! -x "${brew_node_prefix}/bin/node" ]]; then',
);
const pathAdviceIndex = script.indexOf("Add this to your shell profile and restart shell:");
expect(missingNodeGuardIndex).toBeGreaterThanOrEqual(0);
expect(script).toContain(
'ui_error "Homebrew node@${NODE_DEFAULT_MAJOR} is not installed on disk"',
);
expect(script).toContain('echo " export PATH=\\"${brew_node_prefix}/bin:\\$PATH\\""');
expect(pathAdviceIndex).toBeGreaterThan(missingNodeGuardIndex);
});
it("does not print PATH advice when Homebrew node is missing at runtime", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
OS=macos
missing_prefix="$(mktemp -d)/node@24"
brew() {
if [[ "$1" == "--prefix" ]]; then
echo "$missing_prefix"
return 0
fi
return 0
}
node_is_supported() { return 1; }
if ensure_macos_default_node_active; then
echo "ensure returned success"
else
echo "ensure returned failure"
fi
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("Homebrew node@24 is not installed on disk");
expect(result.stdout).toContain("ensure returned failure");
expect(result.stdout).not.toContain("Node.js v24 was installed");
expect(result.stdout).not.toContain("Add this to your shell profile");
});
it("falls back when gum reports raw-mode ioctl failures", () => {
expect(script).toContain("setrawmode|inappropriate ioctl");
expect(script).toContain(
'if "$GUM" spin --spinner dot --title "$title" -- "$@" >"$gum_out" 2>"$gum_err"; then',
);
expect(script).toContain(
'if is_gum_raw_mode_failure "$gum_out" || is_gum_raw_mode_failure "$gum_err"; then',
);
expect(script).toContain(
'ui_warn "Spinner unavailable in this terminal; continuing without spinner"',
);
expect(script).toContain('"$@"\n return $?');
});
it("reruns spinner-wrapped commands when gum reports ioctl failure", () => {
const dir = mkdtempSync(join(tmpdir(), "openclaw-install-sh-gum-"));
try {
const gumPath = join(dir, "gum");
const commandPath = join(dir, "command");
const markerPath = join(dir, "marker");
writeFileSync(
gumPath,
"#!/usr/bin/env bash\nprintf 'inappropriate ioctl for device\\n'\nexit 0\n",
{ mode: 0o755 },
);
writeFileSync(commandPath, `#!/usr/bin/env bash\nprintf 'ran' >"${markerPath}"\n`, {
mode: 0o755,
});
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
gum_is_tty() { return 0; }
GUM="${gumPath}"
run_with_spinner "Installing node" "${commandPath}"
cat "${markerPath}"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain(
"Spinner unavailable in this terminal; continuing without spinner",
);
expect(result.stdout).toContain("ran");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("install.sh duplicate OpenClaw install detection", () => {
it("warns with concrete package paths and versions for duplicate npm roots", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
root="$(mktemp -d)"
trap 'rm -rf "$root"' EXIT
mkdir -p "$root/brew/openclaw" "$root/fnm/openclaw"
printf '{"version":"2026.3.7"}\\n' > "$root/brew/openclaw/package.json"
printf '{"version":"2026.3.1"}\\n' > "$root/fnm/openclaw/package.json"
collect_openclaw_npm_root_candidates() { printf '%s\\n' "$root/brew" "$root/fnm"; }
OPENCLAW_BIN="$root/fnm/.bin/openclaw"
ui_warn() { echo "WARN: $*"; }
warn_duplicate_openclaw_global_installs
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("Multiple OpenClaw global installs detected");
expect(result.stdout).toContain("2026.3.7");
expect(result.stdout).toContain("2026.3.1");
expect(result.stdout).toContain("/brew/openclaw");
expect(result.stdout).toContain("/fnm/openclaw");
expect(result.stdout).toContain("Active openclaw:");
expect(result.stdout).toContain("npm uninstall -g openclaw");
});
it("stays quiet when only one OpenClaw npm root exists", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
root="$(mktemp -d)"
trap 'rm -rf "$root"' EXIT
mkdir -p "$root/only/openclaw"
printf '{"version":"2026.3.7"}\\n' > "$root/only/openclaw/package.json"
collect_openclaw_npm_root_candidates() { printf '%s\\n' "$root/only"; }
ui_warn() { echo "WARN: $*"; }
warn_duplicate_openclaw_global_installs
`);
expect(result.status).toBe(0);
expect(result.stdout).not.toContain("Multiple OpenClaw global installs detected");
});
});
describe("install.sh doctor cancellation and dashboard guard", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
it("guards every run_doctor caller against failure", () => {
// Both run_doctor call sites must guard the return value so a
// failed or cancelled doctor does not launch the dashboard.
// The upgrade path uses: if run_doctor; then should_open_dashboard=true; fi
// The existing-config path must also guard: if run_doctor; then ...
expect(script).toContain("if run_doctor; then");
// Ensure there is no bare "run_doctor" call followed by
// "should_open_dashboard=true" without an if-guard
const bareDoctor = /^\s+run_doctor\s*$/m;
const lines = script.split("\n");
for (let i = 0; i < lines.length; i++) {
if (bareDoctor.test(lines[i])) {
// A bare run_doctor is only acceptable inside the run_doctor
// function definition itself, not at a call site
const context = lines.slice(Math.max(0, i - 3), i + 3).join("\n");
if (!context.includes("run_doctor()")) {
throw new Error(
`Unguarded run_doctor call at line ${i + 1}. ` +
`All run_doctor callers must check the return value.`,
);
}
}
}
});
it("clears dashboard flag when doctor fails during upgrade", () => {
// The upgrade interactive doctor path must clear should_open_dashboard
// when doctor_exit is non-zero.
expect(script).toContain("should_open_dashboard=false");
expect(script).toContain("if (( doctor_exit != 0 )); then");
});
it("propagates signal exit codes through run_quiet_step", () => {
// run_quiet_step preserves signal exit codes (130=SIGINT, 143=SIGTERM)
// so run_doctor can detect user cancellation.
expect(script).toContain("if (( cmd_exit > 128 )); then");
expect(script).toContain('return "$cmd_exit"');
});
it("aborts on SIGINT (exit 130) from doctor", () => {
// Both the run_doctor function and the interactive doctor path
// must call abort_install_int on exit code 130.
expect(script).toContain("if (( doctor_exit == 130 )); then");
expect(script).toContain("abort_install_int");
});
});