import { spawnSync } from "node:child_process"; import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; const SCRIPT_PATH = "scripts/install-cli.sh"; function runInstallCliShell(script: string, env: NodeJS.ProcessEnv = {}) { return spawnSync("/bin/bash", ["-c", script], { encoding: "utf8", env: { ...process.env, OPENCLAW_INSTALL_CLI_SH_NO_RUN: "1", ...env, }, }); } function linkRequiredShellTools(bin: string) { for (const tool of ["ln", "mkdir"]) { symlinkSync(`/bin/${tool}`, join(bin, tool)); } } describe("install-cli.sh", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); it("keeps HOME for default prefix while OPENCLAW_HOME controls git checkout paths", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-home-")); const osHome = join(tmp, "os-home"); const openclawHome = join(tmp, "openclaw-home"); mkdirSync(osHome, { recursive: true }); mkdirSync(openclawHome, { recursive: true }); let result: ReturnType | undefined; try { result = runInstallCliShell( [ `cd ${JSON.stringify(process.cwd())}`, `source ${JSON.stringify(SCRIPT_PATH)}`, 'printf "prefix=%s\\ngit=%s\\n" "$PREFIX" "$GIT_DIR"', ].join("\n"), { HOME: osHome, OPENCLAW_HOME: openclawHome, OPENCLAW_GIT_DIR: undefined, OPENCLAW_PREFIX: undefined, }, ); } finally { rmSync(tmp, { force: true, recursive: true }); } expect(result?.status).toBe(0); const output = result?.stdout ?? ""; expect(output).toContain(`prefix=${join(osHome, ".openclaw")}`); expect(output).toContain(`git=${join(openclawHome, "openclaw")}`); }); it("resolves requested git install versions to checkout refs", () => { const result = runInstallCliShell(` set -euo pipefail source "${SCRIPT_PATH}" npm_bin() { echo npm; } 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 = runInstallCliShell(` 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}" SHARP_IGNORE_GLOBAL_LIBVIPS="$SHARP_IGNORE_GLOBAL_LIBVIPS" 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_cmd" prepare "pnpm@${version}" --activate'); expect(script).toContain('activate_repo_pnpm_version "$repo_dir"'); }); it("links an existing usable Alpine/musl Node runtime without sudo", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-alpine-")); const bin = join(tmp, "bin"); const prefix = join(tmp, "prefix"); const apkLog = join(tmp, "apk.log"); const fakeApk = join(bin, "apk"); const fakeNode = join(bin, "node"); const fakeNpm = join(bin, "npm"); mkdirSync(bin, { recursive: true }); linkRequiredShellTools(bin); writeFileSync( fakeApk, [ "#!/bin/bash", 'printf "%s\\n" "$*" >> "$APK_LOG"', "exit 99", "", ].join("\n"), ); writeFileSync( fakeNode, [ "#!/bin/bash", 'if [[ "${1:-}" == "-v" ]]; then', " printf 'v22.22.2\\n'", " exit 0", "fi", 'if [[ "${1:-}" == "-e" ]]; then', " exit 0", "fi", "exit 0", "", ].join("\n"), ); writeFileSync( fakeNpm, [ "#!/bin/bash", "exit 0", "", ].join("\n"), ); chmodSync(fakeApk, 0o755); chmodSync(fakeNode, 0o755); chmodSync(fakeNpm, 0o755); try { const result = runInstallCliShell( [ "set -euo pipefail", `cd ${JSON.stringify(process.cwd())}`, `source ${JSON.stringify(SCRIPT_PATH)}`, "os_detect() { printf 'linux\\n'; }", "arch_detect() { printf 'x64\\n'; }", "is_musl_linux() { return 0; }", "is_root() { return 1; }", `PREFIX=${JSON.stringify(prefix)}`, `APK_NODE_BIN_DIR=${JSON.stringify(bin)}`, "NODE_VERSION=22.22.0", "install_node", ].join("\n"), { APK_LOG: apkLog, PATH: bin, }, ); expect(result.status).toBe(0); expect(result.stdout).not.toContain("Installing Node via apk"); expect(() => readFileSync(apkLog, "utf8")).toThrow(); const nodeLink = join(prefix, "tools", "node-v22.22.0", "bin", "node"); const npmLink = join(prefix, "tools", "node-v22.22.0", "bin", "npm"); expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true); expect(readlinkSync(nodeLink)).toBe(fakeNode); expect(readlinkSync(npmLink)).toBe(fakeNpm); expect(script).toContain("apk add --no-cache git"); } finally { rmSync(tmp, { force: true, recursive: true }); } }); it("replaces a stale Alpine/musl prefix Node before the generic skip", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-alpine-stale-")); const bin = join(tmp, "bin"); const oldBin = join(tmp, "old-bin"); const prefix = join(tmp, "prefix"); const nodePrefixBin = join(prefix, "tools", "node-v22.22.0", "bin"); const apkLog = join(tmp, "apk.log"); const fakeApk = join(bin, "apk"); const fakeNode = join(bin, "node"); const fakeNpm = join(bin, "npm"); const oldNode = join(oldBin, "node"); const oldNpm = join(oldBin, "npm"); const staleNode = join(nodePrefixBin, "node"); mkdirSync(bin, { recursive: true }); linkRequiredShellTools(bin); mkdirSync(oldBin, { recursive: true }); mkdirSync(nodePrefixBin, { recursive: true }); writeFileSync( fakeApk, [ "#!/bin/bash", 'printf "%s\\n" "$*" >> "$APK_LOG"', "exit 99", "", ].join("\n"), ); writeFileSync( staleNode, [ "#!/bin/bash", 'if [[ "${1:-}" == "-v" ]]; then', " printf 'v22.22.0\\n'", " exit 0", "fi", 'if [[ "${1:-}" == "-e" ]]; then', " exit 1", "fi", "exit 0", "", ].join("\n"), ); writeFileSync( fakeNode, [ "#!/bin/bash", 'if [[ "${1:-}" == "-v" ]]; then', " printf 'v22.22.2\\n'", " exit 0", "fi", 'if [[ "${1:-}" == "-e" ]]; then', " exit 0", "fi", "exit 0", "", ].join("\n"), ); writeFileSync( oldNode, [ "#!/bin/bash", 'if [[ "${1:-}" == "-v" ]]; then', " printf 'v18.20.0\\n'", " exit 0", "fi", 'if [[ "${1:-}" == "-e" ]]; then', " exit 1", "fi", "exit 0", "", ].join("\n"), ); writeFileSync( oldNpm, [ "#!/bin/bash", "exit 0", "", ].join("\n"), ); writeFileSync( fakeNpm, [ "#!/bin/bash", "exit 0", "", ].join("\n"), ); chmodSync(fakeApk, 0o755); chmodSync(staleNode, 0o755); chmodSync(oldNode, 0o755); chmodSync(oldNpm, 0o755); chmodSync(fakeNode, 0o755); chmodSync(fakeNpm, 0o755); try { const result = runInstallCliShell( [ "set -euo pipefail", `cd ${JSON.stringify(process.cwd())}`, `source ${JSON.stringify(SCRIPT_PATH)}`, "os_detect() { printf 'linux\\n'; }", "arch_detect() { printf 'x64\\n'; }", "is_musl_linux() { return 0; }", "is_root() { return 1; }", `PREFIX=${JSON.stringify(prefix)}`, "NODE_VERSION=22.22.0", "NODE_VERSION_REQUESTED=1", "install_node", ].join("\n"), { APK_LOG: apkLog, PATH: `${nodePrefixBin}:${oldBin}:${bin}`, }, ); expect(result.status).toBe(0); expect(result.stdout).not.toContain("Installing Node via apk"); expect(() => readFileSync(apkLog, "utf8")).toThrow(); const nodeLink = join(prefix, "tools", "node-v22.22.0", "bin", "node"); const npmLink = join(prefix, "tools", "node-v22.22.0", "bin", "npm"); expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true); expect(readlinkSync(nodeLink)).toBe(fakeNode); expect(readlinkSync(npmLink)).toBe(fakeNpm); } finally { rmSync(tmp, { force: true, recursive: true }); } }); it("uses apk-managed Node and Git on Alpine/musl when the existing Node is unusable", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-alpine-apk-")); const bin = join(tmp, "bin"); const prefix = join(tmp, "prefix"); const apkLog = join(tmp, "apk.log"); const nodeState = join(tmp, "node-state"); const fakeApk = join(bin, "apk"); const fakeNode = join(bin, "node"); const fakeNpm = join(bin, "npm"); mkdirSync(bin, { recursive: true }); linkRequiredShellTools(bin); writeFileSync( fakeApk, [ "#!/bin/bash", 'printf "%s\\n" "$*" >> "$APK_LOG"', 'printf "new\\n" > "$NODE_STATE"', "exit 0", "", ].join("\n"), ); writeFileSync( fakeNode, [ "#!/bin/bash", 'if [[ "${1:-}" == "-v" ]]; then', ' if [[ -f "$NODE_STATE" ]]; then', " printf 'v22.22.2\\n'", " else", " printf 'v18.20.0\\n'", " fi", " exit 0", "fi", 'if [[ "${1:-}" == "-e" ]]; then', ' [[ -f "$NODE_STATE" ]]', " exit $?", "fi", "exit 0", "", ].join("\n"), ); writeFileSync( fakeNpm, [ "#!/bin/bash", "exit 0", "", ].join("\n"), ); chmodSync(fakeApk, 0o755); chmodSync(fakeNode, 0o755); chmodSync(fakeNpm, 0o755); try { const result = runInstallCliShell( [ "set -euo pipefail", `cd ${JSON.stringify(process.cwd())}`, `source ${JSON.stringify(SCRIPT_PATH)}`, "os_detect() { printf 'linux\\n'; }", "arch_detect() { printf 'x64\\n'; }", "is_musl_linux() { return 0; }", "is_root() { return 0; }", `PREFIX=${JSON.stringify(prefix)}`, `APK_NODE_BIN_DIR=${JSON.stringify(bin)}`, "NODE_VERSION=22.22.0", "NODE_VERSION_REQUESTED=1", "install_node", ].join("\n"), { APK_LOG: apkLog, NODE_STATE: nodeState, PATH: bin, }, ); expect(result.status).toBe(0); expect(result.stdout).toContain("Installing Node via apk"); expect(readFileSync(apkLog, "utf8")).toContain("add --no-cache nodejs npm"); const nodeLink = join(prefix, "tools", "node-v22.22.0", "bin", "node"); const npmLink = join(prefix, "tools", "node-v22.22.0", "bin", "npm"); expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true); expect(readlinkSync(nodeLink)).toBe(fakeNode); expect(readlinkSync(npmLink)).toBe(fakeNpm); expect(script).toContain("apk add --no-cache git"); } finally { rmSync(tmp, { force: true, recursive: true }); } }); it("rejects Alpine/musl Node packages below the requested runtime floor", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-alpine-old-node-")); const bin = join(tmp, "bin"); const prefix = join(tmp, "prefix"); const apkLog = join(tmp, "apk.log"); const fakeApk = join(bin, "apk"); const fakeNode = join(bin, "node"); const fakeNpm = join(bin, "npm"); mkdirSync(bin, { recursive: true }); linkRequiredShellTools(bin); writeFileSync( fakeApk, [ "#!/bin/bash", 'printf "%s\\n" "$*" >> "$APK_LOG"', "exit 0", "", ].join("\n"), ); writeFileSync( fakeNode, [ "#!/bin/bash", 'if [[ "${1:-}" == "-v" ]]; then', " printf 'v22.18.0\\n'", " exit 0", "fi", 'if [[ "${1:-}" == "-e" ]]; then', " exit 0", "fi", "exit 0", "", ].join("\n"), ); writeFileSync( fakeNpm, [ "#!/bin/bash", "exit 0", "", ].join("\n"), ); chmodSync(fakeApk, 0o755); chmodSync(fakeNode, 0o755); chmodSync(fakeNpm, 0o755); try { const result = runInstallCliShell( [ "set -euo pipefail", `cd ${JSON.stringify(process.cwd())}`, `source ${JSON.stringify(SCRIPT_PATH)}`, "os_detect() { printf 'linux\\n'; }", "arch_detect() { printf 'x64\\n'; }", "is_musl_linux() { return 0; }", "is_root() { return 0; }", `PREFIX=${JSON.stringify(prefix)}`, `APK_NODE_BIN_DIR=${JSON.stringify(bin)}`, "NODE_VERSION=22.22.0", "NODE_VERSION_REQUESTED=1", "install_node", ].join("\n"), { APK_LOG: apkLog, PATH: bin, }, ); expect(result.status).toBe(1); expect(readFileSync(apkLog, "utf8")).toContain("add --no-cache nodejs npm"); expect(result.stdout).toContain("Alpine Node package must provide Node >= 22.22.0 with node:sqlite"); expect(result.stdout).toContain("found v22.18.0"); } finally { rmSync(tmp, { force: true, recursive: true }); } }); it("clears npm freshness filters for package installs", () => { expect(script).toContain('freshness_flag="--min-release-age=0"'); expect(script).toContain('npm_raw_config_has_key "min-release-age"'); expect(script).toContain('freshness_flag="--before=$(date -u'); expect(script).toContain("env -u NPM_CONFIG_BEFORE -u npm_config_before"); }); it("does not emit --before when raw user npmrc config contains min-release-age", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-npmrc-")); const bin = join(tmp, "bin"); const npmrc = join(tmp, "user.npmrc"); const installArgs = join(tmp, "npm-install-args.txt"); const prefix = join(tmp, "prefix"); const nodeDir = join(tmp, "node"); mkdirSync(bin, { recursive: true }); mkdirSync(nodeDir, { recursive: true }); writeFileSync(npmrc, "min-release-age=7\n"); const fakeNpm = join(bin, "npm"); writeFileSync( fakeNpm, [ "#!/bin/bash", '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 = runInstallCliShell( [ "set -euo pipefail", `cd ${JSON.stringify(process.cwd())}`, `source ${JSON.stringify(SCRIPT_PATH)}`, `npm_bin() { printf '%s\\n' ${JSON.stringify(fakeNpm)}; }`, `node_dir() { printf '%s\\n' ${JSON.stringify(nodeDir)}; }`, "emit_json() { :; }", "log() { :; }", `PREFIX=${JSON.stringify(prefix)}`, "SET_NPM_PREFIX=0", "OPENCLAW_VERSION=1.2.3", "install_openclaw", ].join("\n"), { NPM_CONFIG_USERCONFIG: npmrc, NPM_FAKE_INSTALL_ARGS: installArgs, PATH: `${bin}:${process.env.PATH}`, }, ); expect(result.status).toBe(0); expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n"); expect(readFileSync(installArgs, "utf8")).not.toContain("--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-cli-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"); const installPrefix = join(tmp, "install-prefix"); const nodeDir = join(tmp, "node"); mkdirSync(bin, { recursive: true }); mkdirSync(home, { recursive: true }); mkdirSync(nodeDir, { recursive: true }); mkdirSync(join(prefix, "etc"), { recursive: true }); writeFileSync(npmrc, "min-release-age=7\n"); const fakeNpm = join(bin, "npm"); writeFileSync( fakeNpm, [ "#!/bin/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 = runInstallCliShell( [ "set -euo pipefail", `cd ${JSON.stringify(process.cwd())}`, `source ${JSON.stringify(SCRIPT_PATH)}`, `npm_bin() { printf '%s\\n' ${JSON.stringify(fakeNpm)}; }`, `node_dir() { printf '%s\\n' ${JSON.stringify(nodeDir)}; }`, "emit_json() { :; }", "log() { :; }", `PREFIX=${JSON.stringify(installPrefix)}`, "SET_NPM_PREFIX=0", "OPENCLAW_VERSION=1.2.3", "install_openclaw", ].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(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-cli-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"); const installPrefix = join(tmp, "install-prefix"); const nodeDir = join(tmp, "node"); mkdirSync(bin, { recursive: true }); mkdirSync(home, { recursive: true }); mkdirSync(nodeDir, { recursive: true }); writeFileSync(npmrc, "min-release-age=7\n"); const fakeNpm = join(bin, "npm"); writeFileSync( fakeNpm, [ "#!/bin/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 = runInstallCliShell( [ "set -euo pipefail", `cd ${JSON.stringify(process.cwd())}`, `source ${JSON.stringify(SCRIPT_PATH)}`, `npm_bin() { printf '%s\\n' ${JSON.stringify(fakeNpm)}; }`, `node_dir() { printf '%s\\n' ${JSON.stringify(nodeDir)}; }`, "emit_json() { :; }", "log() { :; }", `PREFIX=${JSON.stringify(installPrefix)}`, "SET_NPM_PREFIX=0", "OPENCLAW_VERSION=1.2.3", "install_openclaw", ].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(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("rejects OpenClaw GitHub source targets for npm installs", () => { const result = runInstallCliShell(` set -euo pipefail source "${SCRIPT_PATH}" OPENCLAW_VERSION=main install_openclaw `); expect(result.status).toBe(1); expect(result.stdout).toContain("npm installs do not support OpenClaw GitHub source targets"); expect(result.stdout).toContain("--install-method git --version main"); }); });