mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-07 05:45:59 +08:00
* 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>
1751 lines
62 KiB
TypeScript
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");
|
|
});
|
|
});
|