mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
2079 lines
76 KiB
TypeScript
2079 lines
76 KiB
TypeScript
// Install Cli tests cover install cli script behavior.
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
readlinkSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { isSupportedOpenClawNodeVersion } from "../../node-version.mjs";
|
|
import { requireNodeTool } from "../helpers/node-toolchain.js";
|
|
import { NODE_RELEASE_VERSION_CASES } from "../helpers/node-version-cases.js";
|
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|
import {
|
|
createInstallGitBranchFallbackFixtureScript,
|
|
createInstallGitCloneFixtureScript,
|
|
createInstallGitUpdateFixtureScript,
|
|
createInstallGitRebaseRecoveryFixtureScript,
|
|
createInstallGitHookRefusalFixtureScript,
|
|
createInstallGitCommitFixtureScript,
|
|
createInstallGitTagPreferenceFixtureScript,
|
|
} from "./install-git-fixtures.js";
|
|
import { writeNpmLifecycleFixture } from "./install-npm-fixtures.js";
|
|
import { findDarwinReexecBash } from "./install-reexec-fixtures.js";
|
|
import {
|
|
defineInstallerNpmConfigContract,
|
|
defineInstallerNpmArchiveIdentityContract,
|
|
defineInstallerNpmDirectoryIdentityContract,
|
|
defineInstallerNpmRetryContract,
|
|
defineInstallerNpmFreshnessContract,
|
|
defineInstallerPnpmContract,
|
|
defineInstallerShellIsolationContract,
|
|
} from "./install-test-contract.js";
|
|
|
|
const SCRIPT_PATH = "scripts/install-cli.sh";
|
|
const nodeExecutable = requireNodeTool("node");
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
|
|
function runInstallCliShell(script: string, env: NodeJS.ProcessEnv = {}) {
|
|
return spawnSync("/bin/bash", ["--noprofile", "--norc", "-c", script], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_INSTALL_CLI_SH_NO_RUN: "1",
|
|
...env,
|
|
BASH_ENV: "",
|
|
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");
|
|
const installerContract = {
|
|
scriptPath: SCRIPT_PATH,
|
|
runShell: runInstallCliShell,
|
|
nodeExecutable,
|
|
prefix: true,
|
|
createTempDir: (prefix: string) => tempDirs.make(prefix),
|
|
};
|
|
|
|
defineInstallerShellIsolationContract(installerContract);
|
|
|
|
it("installs only Node into the requested prefix without entering package or service setup", () => {
|
|
const result = runInstallCliShell(`
|
|
source ${SCRIPT_PATH}
|
|
is_musl_linux() { return 1; }
|
|
os_detect() { echo linux; }
|
|
arch_detect() { echo x64; }
|
|
install_node() { printf 'node:%s:%s:%s\\n' "$1" "$2" "$PREFIX"; }
|
|
preflight_fresh_git_disk_space() { exit 91; }
|
|
install_openclaw_from_git() { exit 92; }
|
|
install_openclaw() { exit 93; }
|
|
refresh_gateway_service_if_loaded() { exit 94; }
|
|
main --node-only --prefix '/tmp/private node' --git --onboard
|
|
`);
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
expect(result.stdout.trim()).toBe("node:linux:x64:/tmp/private node");
|
|
});
|
|
|
|
it("refuses musl Node-only recovery before an installer can invoke system package changes", () => {
|
|
const result = runInstallCliShell(`
|
|
source ${SCRIPT_PATH}
|
|
is_musl_linux() { return 0; }
|
|
install_node() { echo unexpected-node-install; }
|
|
main --node-only
|
|
`);
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout + result.stderr).toContain("unavailable on musl Linux");
|
|
expect(result.stdout).not.toContain("unexpected-node-install");
|
|
});
|
|
|
|
it.each(
|
|
["directory", "missing", "file"].flatMap((tempBase) =>
|
|
[false, true].map((downloadFails) => ({ tempBase, downloadFails })),
|
|
),
|
|
)(
|
|
"owns and cleans private temporary storage with $tempBase TMPDIR (download fails: $downloadFails)",
|
|
({ tempBase, downloadFails }) => {
|
|
const root = tempDirs.make("openclaw-install-cli-temp-");
|
|
const inheritedTemp = join(root, "inherited temp");
|
|
if (tempBase === "directory") {
|
|
mkdirSync(inheritedTemp, { mode: 0o755 });
|
|
} else if (tempBase === "file") {
|
|
writeFileSync(inheritedTemp, "preserve this file");
|
|
}
|
|
const prefix = join(root, "prefix");
|
|
const payload = join(root, "node-payload");
|
|
mkdirSync(join(payload, "bin"), { recursive: true });
|
|
writeFileSync(
|
|
join(payload, "bin", "node"),
|
|
'#!/bin/bash\nif [[ "${1:-}" == "-v" ]]; then printf "v24.19.0\\n"; fi\n',
|
|
{ mode: 0o755 },
|
|
);
|
|
writeFileSync(join(payload, "bin", "npm"), "#!/bin/bash\nexit 0\n", { mode: 0o755 });
|
|
const archive = join(root, "node.tgz");
|
|
const packed = spawnSync("tar", ["-czf", archive, "-C", root, "node-payload"], {
|
|
encoding: "utf8",
|
|
});
|
|
expect(packed.status, packed.stdout + packed.stderr).toBe(0);
|
|
const digest = createHash("sha256").update(readFileSync(archive)).digest("hex");
|
|
const observationPath = join(root, "temporary-storage.json");
|
|
const observer = join(root, "observe-temp.cjs");
|
|
writeFileSync(
|
|
observer,
|
|
`const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const tempDirectory = process.env.TMPDIR;
|
|
const metadata = fs.statSync(tempDirectory);
|
|
fs.mkdtempSync(path.join(tempDirectory, "child-"));
|
|
fs.writeFileSync(process.env.FIXTURE_OBSERVATION, JSON.stringify({
|
|
tempDirectory, mode: metadata.mode & 0o777, uid: metadata.uid,
|
|
stagingDirectory: path.dirname(process.argv[2]),
|
|
}));
|
|
`,
|
|
);
|
|
|
|
try {
|
|
const result = runInstallCliShell(
|
|
`
|
|
source ${SCRIPT_PATH}
|
|
is_musl_linux() { return 1; }
|
|
detect_downloader() { :; }
|
|
download_file() {
|
|
"$FIXTURE_NODE" "$FIXTURE_OBSERVER" "$2" || return
|
|
if [[ "$FIXTURE_DOWNLOAD_FAILS" == 1 ]]; then return 42; fi
|
|
case "$1" in
|
|
*/SHASUMS256.txt)
|
|
printf '%s node-v24.19.0-%s-%s.tar.gz\\n' "$FIXTURE_SHA" "$(os_detect)" "$(arch_detect)" > "$2"
|
|
;;
|
|
*) cp "$FIXTURE_ARCHIVE" "$2" ;;
|
|
esac
|
|
}
|
|
main --json --node-only --node-version 24.19.0 --prefix "$FIXTURE_PREFIX"
|
|
`,
|
|
{
|
|
HOME: root,
|
|
TMPDIR: inheritedTemp,
|
|
FIXTURE_PREFIX: prefix,
|
|
FIXTURE_NODE: nodeExecutable,
|
|
FIXTURE_OBSERVER: observer,
|
|
FIXTURE_OBSERVATION: observationPath,
|
|
FIXTURE_DOWNLOAD_FAILS: downloadFails ? "1" : "0",
|
|
FIXTURE_ARCHIVE: archive,
|
|
FIXTURE_SHA: digest,
|
|
},
|
|
);
|
|
expect(result.status, result.stdout + result.stderr).toBe(downloadFails ? 42 : 0);
|
|
const observation = JSON.parse(readFileSync(observationPath, "utf8")) as {
|
|
tempDirectory: string;
|
|
mode: number;
|
|
uid: number;
|
|
stagingDirectory: string;
|
|
};
|
|
expect(observation.tempDirectory).not.toBe(inheritedTemp);
|
|
expect(observation.mode).toBe(0o700);
|
|
expect(observation.uid).toBe(process.getuid?.());
|
|
expect(observation.stagingDirectory.startsWith(`${observation.tempDirectory}/`)).toBe(true);
|
|
expect(existsSync(observation.tempDirectory)).toBe(false);
|
|
expect(existsSync(observation.stagingDirectory)).toBe(false);
|
|
expect(existsSync(join(prefix, "tools", "node", "bin", "node"))).toBe(!downloadFails);
|
|
if (tempBase === "directory") {
|
|
expect(readdirSync(inheritedTemp)).toEqual([]);
|
|
} else if (tempBase === "file") {
|
|
expect(readFileSync(inheritedTemp, "utf8")).toBe("preserve this file");
|
|
} else {
|
|
expect(existsSync(inheritedTemp)).toBe(false);
|
|
}
|
|
} finally {
|
|
if (existsSync(observationPath)) {
|
|
const observation = JSON.parse(readFileSync(observationPath, "utf8")) as {
|
|
tempDirectory: string;
|
|
};
|
|
if (observation.tempDirectory !== inheritedTemp) {
|
|
rmSync(observation.tempDirectory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([true, false])(
|
|
"preserves the caller TMPDIR for service refresh and onboarding (originally set: %s)",
|
|
(originallySet) => {
|
|
const root = tempDirs.make("openclaw-install-cli-temp-lifecycle-");
|
|
const inheritedTemp = join(root, "inherited");
|
|
mkdirSync(inheritedTemp);
|
|
const cli = join(root, "cli");
|
|
writeFileSync(
|
|
cli,
|
|
`#!/bin/bash
|
|
if [[ "$1" == --version ]]; then
|
|
printf '2026.9.12\\n'
|
|
else
|
|
printf '%s' "\${TMPDIR-<unset>}" > "$FIXTURE_ONBOARD"
|
|
fi
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const installTempPath = join(root, "install-temp");
|
|
const refreshPath = join(root, "refresh-temp");
|
|
const onboardPath = join(root, "onboard-temp");
|
|
const callerPath = join(root, "caller-temp");
|
|
const result = runInstallCliShell(
|
|
`
|
|
source ${SCRIPT_PATH}
|
|
install_node() { printf '%s' "$TMPDIR" > "$FIXTURE_INSTALL_TEMP"; }
|
|
ensure_git() { :; }
|
|
install_openclaw() { mkdir -p "$PREFIX/bin"; cp "$FIXTURE_CLI" "$PREFIX/bin/openclaw"; }
|
|
refresh_gateway_service_if_loaded() { printf '%s' "\${TMPDIR-<unset>}" > "$FIXTURE_REFRESH"; }
|
|
main --onboard --prefix "$FIXTURE_PREFIX"
|
|
printf '%s' "\${TMPDIR-<unset>}" > "$FIXTURE_CALLER"
|
|
`,
|
|
{
|
|
HOME: root,
|
|
TMPDIR: originallySet ? inheritedTemp : undefined,
|
|
OPENCLAW_NO_ONBOARD: "0",
|
|
FIXTURE_PREFIX: join(root, "prefix"),
|
|
FIXTURE_CLI: cli,
|
|
FIXTURE_INSTALL_TEMP: installTempPath,
|
|
FIXTURE_REFRESH: refreshPath,
|
|
FIXTURE_ONBOARD: onboardPath,
|
|
FIXTURE_CALLER: callerPath,
|
|
},
|
|
);
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
const originalValue = originallySet ? inheritedTemp : "<unset>";
|
|
for (const observedPath of [refreshPath, onboardPath, callerPath]) {
|
|
expect(readFileSync(observedPath, "utf8")).toBe(originalValue);
|
|
}
|
|
const installTemp = readFileSync(installTempPath, "utf8");
|
|
expect(installTemp).not.toBe(inheritedTemp);
|
|
expect(existsSync(installTemp)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("re-execs a streamed installer on Darwin Bash 5.3+ without leaving a temp file", (context) => {
|
|
const bash = findDarwinReexecBash();
|
|
if (!bash) {
|
|
context.skip("Requires a Darwin host with Bash 5.3+ installed");
|
|
return;
|
|
}
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-reexec-"));
|
|
try {
|
|
const result = spawnSync(bash, ["-s", "--", "--help"], {
|
|
input: script,
|
|
encoding: "utf8",
|
|
timeout: 10_000,
|
|
env: {
|
|
...process.env,
|
|
HOME: tmp,
|
|
TMPDIR: tmp,
|
|
BASH_ENV: "",
|
|
ENV: "",
|
|
OPENCLAW_INSTALL_SH_NO_RUN: "0",
|
|
OPENCLAW_INSTALL_CLI_SH_NO_RUN: "0",
|
|
},
|
|
});
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("Usage: install-cli.sh [options]");
|
|
expect(result.stderr).not.toContain("Run this installer with /bin/bash");
|
|
expect(readdirSync(tmp)).toEqual([]);
|
|
} finally {
|
|
rmSync(tmp, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("fails a low-space fresh Git install before Node or checkout work", () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-disk-low-"));
|
|
const commandLog = join(tmp, "commands.log");
|
|
const repo = join(tmp, "new", "openclaw");
|
|
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"available_disk_kib() { printf '2097152\\n'; }",
|
|
`install_node() { printf 'node\\n' >> ${JSON.stringify(commandLog)}; }`,
|
|
`install_openclaw_from_git() { printf 'git\\n' >> ${JSON.stringify(commandLog)}; }`,
|
|
`main --json --git --git-dir ${JSON.stringify(repo)}`,
|
|
].join("\n"),
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(existsSync(commandLog)).toBe(false);
|
|
const events = result.stdout
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line) as { event: string; name?: string; message?: string });
|
|
expect(events).toEqual([
|
|
{ event: "step", name: "disk-space", status: "start" },
|
|
{
|
|
event: "error",
|
|
message:
|
|
"Fresh Git installs require at least 6 GiB of free disk space; only 2.0 GiB is available. Free disk space and retry.",
|
|
},
|
|
]);
|
|
} finally {
|
|
rmSync(tmp, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("allows a fresh Git install with enough free space", () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-disk-ok-"));
|
|
const repo = join(tmp, "new", "openclaw");
|
|
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"JSON=1",
|
|
"available_disk_kib() { printf '7340032\\n'; }",
|
|
`preflight_fresh_git_disk_space ${JSON.stringify(repo)}`,
|
|
"printf 'continued\\n'",
|
|
].join("\n"),
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain('{"event":"step","name":"disk-space","status":"start"}');
|
|
expect(result.stdout).toContain('{"event":"step","name":"disk-space","status":"ok"}');
|
|
expect(result.stdout).toContain("continued");
|
|
} finally {
|
|
rmSync(tmp, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("does not apply the fresh-install disk threshold to an existing checkout", () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-disk-existing-"));
|
|
const repo = join(tmp, "openclaw");
|
|
mkdirSync(join(repo, ".git"), { recursive: true });
|
|
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"JSON=1",
|
|
"available_disk_kib() { printf 'disk check should not run\\n' >&2; return 99; }",
|
|
`preflight_fresh_git_disk_space ${JSON.stringify(repo)}`,
|
|
].join("\n"),
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toBe("");
|
|
} finally {
|
|
rmSync(tmp, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("emits ordered stages for an existing Git checkout build", () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-events-"));
|
|
const repo = join(tmp, "openclaw");
|
|
mkdirSync(join(repo, ".git"), { recursive: true });
|
|
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"JSON=1",
|
|
`PREFIX=${JSON.stringify(join(tmp, "prefix"))}`,
|
|
"ensure_git() { :; }",
|
|
"ensure_pnpm() { :; }",
|
|
"ensure_pnpm_git_prepare_allowlist() { :; }",
|
|
"cleanup_legacy_submodules() { :; }",
|
|
"resolve_git_openclaw_ref() { printf 'main\\n'; }",
|
|
"checkout_git_openclaw_ref() { :; }",
|
|
"run_pnpm() { :; }",
|
|
"git() {",
|
|
' if [[ "$1" == --git-dir=* ]]; then return 0; fi',
|
|
' if [[ "$1" == "-C" && "$3" == "status" ]]; then return 0; fi',
|
|
" return 0",
|
|
"}",
|
|
`install_openclaw_from_git ${JSON.stringify(repo)}`,
|
|
].join("\n"),
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
const stages = result.stdout
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line) as { event: string; name?: string; status?: string })
|
|
.filter((event) => event.event === "step")
|
|
.map((event) => `${event.name}:${event.status}`);
|
|
expect(stages).toEqual([
|
|
"openclaw:start",
|
|
"git-tools:start",
|
|
"git-tools:ok",
|
|
"git-update:start",
|
|
"git-update:ok",
|
|
"dependencies:start",
|
|
"dependencies:ok",
|
|
"control-ui:start",
|
|
"control-ui:ok",
|
|
"cli-build:start",
|
|
"cli-build:ok",
|
|
"openclaw:ok",
|
|
]);
|
|
} finally {
|
|
rmSync(tmp, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("round-trips dynamic installer values through independent NDJSON records", () => {
|
|
const root = tempDirs.make("openclaw-install-cli-json-events-");
|
|
const dynamicValue = `quote"\\café项目lobster🦞${String.fromCharCode(
|
|
...Array.from({ length: 31 }, (_, index) => index + 1),
|
|
)}end`;
|
|
const repo = join(root, dynamicValue);
|
|
const legacyDir = join(repo, "Peekaboo");
|
|
const fakeNode = join(root, "node");
|
|
mkdirSync(legacyDir, { recursive: true });
|
|
writeFileSync(fakeNode, '#!/bin/bash\nprintf "%s" "$EVENT_VALUE"\n');
|
|
chmodSync(fakeNode, 0o755);
|
|
|
|
const success = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"JSON=1",
|
|
'cleanup_legacy_submodules "$REPO"',
|
|
"try_link_usable_node_runtime_from_path() { return 0; }",
|
|
`node_bin() { printf '%s\\n' ${JSON.stringify(fakeNode)}; }`,
|
|
"install_alpine_node",
|
|
'emit_json done version "$EVENT_VALUE"',
|
|
].join("\n"),
|
|
{ EVENT_VALUE: dynamicValue, REPO: repo },
|
|
);
|
|
|
|
expect(success.status, success.stderr || success.stdout).toBe(0);
|
|
expect(existsSync(legacyDir)).toBe(false);
|
|
const successLines = success.stdout.trimEnd().split("\n");
|
|
expect(successLines).toHaveLength(5);
|
|
const successEvents = successLines.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
expect(successEvents).toEqual([
|
|
{ event: "step", name: "legacy-submodule", status: "start", path: legacyDir },
|
|
{ event: "step", name: "legacy-submodule", status: "ok", path: legacyDir },
|
|
{ event: "step", name: "node", status: "start", method: "apk" },
|
|
{ event: "step", name: "node", status: "ok", method: "system", version: dynamicValue },
|
|
{ event: "done", ok: true, version: dynamicValue },
|
|
]);
|
|
|
|
const failure = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"JSON=1",
|
|
'fail "$EVENT_VALUE"',
|
|
].join("\n"),
|
|
{ EVENT_VALUE: dynamicValue },
|
|
);
|
|
|
|
expect(failure.status).toBe(1);
|
|
expect(failure.stdout.trimEnd().split("\n")).toHaveLength(1);
|
|
expect(JSON.parse(failure.stdout)).toEqual({ event: "error", message: dynamicValue });
|
|
});
|
|
|
|
it("rejects a git checkout without a commit before updating it", () => {
|
|
const result = runInstallCliShell(`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
tmp="$(mktemp -d)"
|
|
repo="$tmp/repo"
|
|
mkdir -p "$repo/.git"
|
|
ensure_git() { :; }
|
|
ensure_pnpm() { :; }
|
|
git() {
|
|
[[ "$1" == "--git-dir=$repo/.git" ]] &&
|
|
[[ "$2" == "--work-tree=$repo" ]] &&
|
|
[[ "$3" == "rev-parse" ]] &&
|
|
[[ "$4" == "--verify" ]] &&
|
|
[[ "$5" == "--quiet" ]] &&
|
|
[[ "$6" == "HEAD^{commit}" ]] &&
|
|
return 1
|
|
return 99
|
|
}
|
|
|
|
set +e
|
|
(install_openclaw_from_git "$repo")
|
|
status="$?"
|
|
set -e
|
|
[[ "$status" -eq 1 ]]
|
|
[[ -d "$repo/.git" ]]
|
|
`);
|
|
|
|
expect(result.status).toBe(0);
|
|
});
|
|
|
|
it("keeps a pre-existing empty Git install destination retryable after clone failure", () => {
|
|
const root = tempDirs.make("openclaw-install-cli-empty-retry-");
|
|
const repo = join(root, "openclaw");
|
|
mkdirSync(repo);
|
|
const runAttempt = (cloneMode: "failure" | "success") =>
|
|
runInstallCliShell(
|
|
`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
ensure_git() { :; }
|
|
ensure_pnpm() { :; }
|
|
resolve_git_openclaw_ref() { printf 'main\\n'; }
|
|
checkout_git_openclaw_ref() { :; }
|
|
cleanup_legacy_submodules() { :; }
|
|
ensure_pnpm_git_prepare_allowlist() { :; }
|
|
git_install_lockfile_flag() { printf '%s\\n' '--frozen-lockfile'; }
|
|
run_pnpm() { :; }
|
|
git() {
|
|
if [[ "$1" == "clone" ]]; then
|
|
target="\${*: -1}"
|
|
mkdir -p "$target/.git"
|
|
if [[ "$CLONE_MODE" == "failure" ]]; then
|
|
return 42
|
|
fi
|
|
printf 'complete\\n' > "$target/checkout.marker"
|
|
fi
|
|
return 0
|
|
}
|
|
install_openclaw_from_git "$REPO"
|
|
`,
|
|
{ CLONE_MODE: cloneMode, REPO: repo },
|
|
);
|
|
|
|
const failed = runAttempt("failure");
|
|
expect(failed.status, failed.stderr || failed.stdout).toBe(42);
|
|
expect(existsSync(repo)).toBe(true);
|
|
expect(readdirSync(repo)).toEqual([]);
|
|
|
|
const succeeded = runAttempt("success");
|
|
expect(succeeded.status, succeeded.stderr || succeeded.stdout).toBe(0);
|
|
expect(readFileSync(join(repo, "checkout.marker"), "utf8")).toBe("complete\n");
|
|
});
|
|
|
|
it("publishes fresh Git clones only after success and cleans failed staging directories", () => {
|
|
const root = tempDirs.make("openclaw-install-cli-transactional-clone-");
|
|
const result = runInstallCliShell(
|
|
createInstallGitCloneFixtureScript(SCRIPT_PATH, 'root="$ROOT"') +
|
|
`
|
|
clone_git_checkout_transactionally https://example.invalid/openclaw.git "$CONCURRENT_REPO"
|
|
`,
|
|
{ ROOT: root },
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout + result.stderr).toContain("Git install dir appeared while cloning");
|
|
expect(readFileSync(join(root, "concurrent", "user.marker"), "utf8")).toBe("keep\n");
|
|
expect(existsSync(join(root, "concurrent", "checkout.marker"))).toBe(false);
|
|
expect(readdirSync(root).filter((entry) => entry.startsWith(".openclaw-clone."))).toEqual([]);
|
|
});
|
|
|
|
it("keeps the full Git install on the canonical checkout after an alias is retargeted", () => {
|
|
const root = tempDirs.make("openclaw-install-cli-retargeted-alias-");
|
|
const result = runInstallCliShell(
|
|
`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
target="$ROOT/target"
|
|
replacement="$ROOT/replacement"
|
|
alias_path="$ROOT/alias"
|
|
mkdir -p "$target" "$replacement"
|
|
ln -s "$target" "$alias_path"
|
|
PREFIX="$ROOT/prefix"
|
|
|
|
ensure_git() { :; }
|
|
resolve_git_openclaw_ref() { printf 'main\\n'; }
|
|
checkout_git_openclaw_ref() {
|
|
[[ "$1" == "$target" && "$2" == "main" ]] || return 1
|
|
GIT_REF_KIND=moving
|
|
}
|
|
cleanup_legacy_submodules() { [[ "$1" == "$target" ]]; }
|
|
ensure_pnpm_git_prepare_allowlist() { [[ "$1" == "$target" ]]; }
|
|
ensure_pnpm() { [[ "$1" == "$target" ]]; }
|
|
run_pnpm() {
|
|
[[ "$1" == "-C" && "$2" == "$target" ]] || return 1
|
|
if [[ "\${3:-}" == "install" ]]; then
|
|
[[ " $* " == *" --no-frozen-lockfile "* ]]
|
|
fi
|
|
}
|
|
git() {
|
|
if [[ "$1" == "clone" ]]; then
|
|
local clone_target="\${*: -1}"
|
|
mkdir -p "$clone_target/.git"
|
|
printf 'complete\\n' > "$clone_target/checkout.marker"
|
|
rm "$alias_path"
|
|
ln -s "$replacement" "$alias_path"
|
|
return 0
|
|
fi
|
|
[[ "$1" == "-C" && "$2" == "$target" ]]
|
|
}
|
|
|
|
install_openclaw_from_git "$alias_path"
|
|
grep -F "$target/dist/entry.js" "$PREFIX/bin/openclaw"
|
|
[[ -z "$(ls -A "$replacement")" ]]
|
|
[[ -z "$(find "$target" -maxdepth 1 -name '.openclaw-clone.*' -print -quit)" ]]
|
|
`,
|
|
{ ROOT: root },
|
|
);
|
|
|
|
expect(result.status, result.stderr || result.stdout).toBe(0);
|
|
});
|
|
|
|
it("bounds stalled curl downloads and propagates timeout failures", () => {
|
|
const result = runInstallCliShell(`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
curl() {
|
|
printf 'curl=%s\n' "$*"
|
|
return 28
|
|
}
|
|
DOWNLOADER=curl
|
|
set +e
|
|
download_file "https://example.invalid/node.tar.gz" "/tmp/node.tar.gz"
|
|
printf 'status=%s\n' "$?"
|
|
`);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("--speed-limit 1 --speed-time 30");
|
|
expect(result.stdout).not.toContain("--connect-timeout");
|
|
expect(result.stdout).toContain("--retry 3 --retry-delay 1 --retry-connrefused");
|
|
expect(result.stdout).toContain("status=28");
|
|
});
|
|
|
|
it("does not clean an unrelated legacy checkout during the default npm install", () => {
|
|
const main = script.slice(script.indexOf("\nmain() {"));
|
|
expect(main).not.toContain("cleanup_legacy_submodules");
|
|
expect(script).toContain('cleanup_legacy_submodules "$repo_dir"');
|
|
});
|
|
|
|
it("matches the canonical release-label contract for installed Node runtimes", () => {
|
|
expect(script).toContain("SELECT sqlite_version() AS version");
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"set +e",
|
|
...NODE_RELEASE_VERSION_CASES.flatMap((version, index) => [
|
|
`node_release_version_is_supported ${JSON.stringify(version)}`,
|
|
`printf '${index}=%s\\n' "$?"`,
|
|
]),
|
|
].join("\n"),
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
for (const [index, version] of NODE_RELEASE_VERSION_CASES.entries()) {
|
|
const expectedStatus = isSupportedOpenClawNodeVersion(version) ? 0 : 1;
|
|
expect(result.stdout, version).toContain(`${index}=${expectedStatus}`);
|
|
}
|
|
});
|
|
|
|
it("reuses the minimum supported runtime unless a newer version was explicitly requested", () => {
|
|
const result = runInstallCliShell(`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
NODE_VERSION=26.1.0
|
|
NODE_VERSION_REQUESTED=0
|
|
printf 'default=%s\n' "$(required_node_version)"
|
|
NODE_VERSION_REQUESTED=1
|
|
printf 'requested=%s\n' "$(required_node_version)"
|
|
`);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("default=24.16.0");
|
|
expect(result.stdout).toContain("requested=26.1.0");
|
|
});
|
|
|
|
it.each([0, 1])("rejects Linux ARMv7 before installing Node (explicit=%s)", (requested) => {
|
|
const result = runInstallCliShell(`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
NODE_VERSION_REQUESTED=${requested}
|
|
os_detect() { printf 'linux\\n'; }
|
|
arch_detect() { printf 'armv7l\\n'; }
|
|
install_node() { printf 'unexpected-install\\n'; }
|
|
main
|
|
`);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toContain(
|
|
"Linux ARMv7 is unsupported: official Node 24+ binaries are unavailable",
|
|
);
|
|
expect(result.stdout).not.toContain("unexpected-install");
|
|
});
|
|
|
|
it("rejects an explicitly requested vulnerable Node release", () => {
|
|
const result = runInstallCliShell(`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
NODE_VERSION=24.14.1
|
|
install_node linux x64
|
|
`);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toContain(
|
|
"Node 24.14.1 is unsupported; use Node 24.16.0+ or Node 26.1.0+.",
|
|
);
|
|
expect(result.stdout).not.toContain("Installing Node 24.14.1");
|
|
});
|
|
|
|
it("rejects installer options with missing values", () => {
|
|
const result = runInstallCliShell(`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
parse_args --prefix --no-onboard
|
|
`);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout + result.stderr).toContain("Missing value for --prefix");
|
|
expect(result.stdout + result.stderr).not.toContain("unbound variable");
|
|
});
|
|
|
|
it("matches the Gateway future-config compatibility rule", () => {
|
|
const result = runInstallCliShell(`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
node_bin() { command -v node; }
|
|
set +e
|
|
for pair in \
|
|
2026.7.1-2:2026.7.2 \
|
|
2026.7.2-beta.6:2026.7.2-beta.7 \
|
|
2026.7.2:2026.7.2-beta.7 \
|
|
2026.7.2-beta.7:2026.7.2 \
|
|
2026.7.2-1:2026.7.2-2 \
|
|
2026.7.3-beta.1:2026.7.2; do
|
|
candidate="\${pair%%:*}"
|
|
writer="\${pair#*:}"
|
|
openclaw_version_is_compatible_with "$candidate" "$writer"
|
|
printf '%s=%s\\n' "$pair" "$?"
|
|
done
|
|
`);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("2026.7.1-2:2026.7.2=1");
|
|
expect(result.stdout).toContain("2026.7.2-beta.6:2026.7.2-beta.7=1");
|
|
expect(result.stdout).toContain("2026.7.2:2026.7.2-beta.7=0");
|
|
expect(result.stdout).toContain("2026.7.2-beta.7:2026.7.2=0");
|
|
expect(result.stdout).toContain("2026.7.2-1:2026.7.2-2=0");
|
|
expect(result.stdout).toContain("2026.7.3-beta.1:2026.7.2=0");
|
|
});
|
|
|
|
it("rejects an incompatible channel before replacing an existing managed CLI", () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-compatible-"));
|
|
const prefix = join(tmp, "prefix");
|
|
const bin = join(prefix, "bin");
|
|
const openclaw = join(bin, "openclaw");
|
|
mkdirSync(bin, { recursive: true });
|
|
writeFileSync(openclaw, "existing-managed-cli\n");
|
|
|
|
try {
|
|
const result = runInstallCliShell(`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
PREFIX=${JSON.stringify(prefix)}
|
|
OPENCLAW_VERSION=latest
|
|
REQUIRED_COMPATIBLE_VERSION=2026.7.2
|
|
node_bin() { command -v node; }
|
|
npm_bin() { printf 'npm\\n'; }
|
|
npm_config_has_raw_key() { return 1; }
|
|
npm() {
|
|
if [[ "$1" == "view" ]]; then printf '2026.7.1-2\\n'; return 0; fi
|
|
if [[ "$1" == "config" ]]; then printf 'null\\n'; return 0; fi
|
|
printf 'unexpected mutation: %s\\n' "$*" >&2
|
|
return 99
|
|
}
|
|
install_openclaw
|
|
`);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toContain("OpenClaw 2026.7.1-2 is older than config writer 2026.7.2");
|
|
expect(result.stderr).not.toContain("unexpected mutation");
|
|
expect(readFileSync(openclaw, "utf8")).toBe("existing-managed-cli\n");
|
|
} finally {
|
|
rmSync(tmp, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("checks a git checkout version before dependency install or wrapper replacement", () => {
|
|
const checkoutIndex = script.indexOf('checkout_git_openclaw_ref "$repo_dir" "$git_ref"');
|
|
const compatibilityIndex = script.indexOf(
|
|
'require_openclaw_version_compatible "$resolved_version"',
|
|
);
|
|
const dependencyInstallIndex = script.indexOf(
|
|
'CI="${CI:-true}" run_pnpm -C "$repo_dir" install ${pnpm_prefer_offline_args[@]+"${pnpm_prefer_offline_args[@]}"} "$install_lockfile_flag"',
|
|
);
|
|
const wrapperIndex = script.indexOf(
|
|
'publish_executable_wrapper "${PREFIX}/bin/openclaw"',
|
|
compatibilityIndex,
|
|
);
|
|
|
|
expect(checkoutIndex).toBeGreaterThan(-1);
|
|
expect(compatibilityIndex).toBeGreaterThan(checkoutIndex);
|
|
expect(dependencyInstallIndex).toBeGreaterThan(compatibilityIndex);
|
|
expect(wrapperIndex).toBeGreaterThan(compatibilityIndex);
|
|
});
|
|
|
|
it.each(["none", "unsupported", "missing"])(
|
|
"reports a successful runtime replacement (%s) without restarting again",
|
|
(replaced) => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-gateway-refresh-"));
|
|
const prefix = join(tmp, "prefix");
|
|
const bin = join(prefix, "bin");
|
|
const commandLog = join(tmp, "commands.log");
|
|
const openclaw = join(bin, "openclaw");
|
|
mkdirSync(bin, { recursive: true });
|
|
writeFileSync(
|
|
openclaw,
|
|
[
|
|
"#!/bin/bash",
|
|
'printf "%s\\n" "$*" >> "$COMMAND_LOG"',
|
|
'if [[ "$*" == "gateway install --force" ]]; then',
|
|
' printf "%s\\n" "incidental-output-canary"',
|
|
' if [[ "$REPLACED" == unsupported ]]; then printf "%s\\n" "Replacing unsupported Gateway service Node 22.23.1 (/old/node) with /new/node; refreshing the install."; fi',
|
|
' if [[ "$REPLACED" == missing ]]; then printf "%s\\n" "Replacing missing Gateway service Node (/old/node) with /new/node; refreshing the install."; fi',
|
|
"fi",
|
|
].join("\n"),
|
|
);
|
|
chmodSync(openclaw, 0o755);
|
|
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
`PREFIX=${JSON.stringify(prefix)}`,
|
|
"is_gateway_daemon_loaded() { return 0; }",
|
|
"refresh_gateway_service_if_loaded",
|
|
].join("\n"),
|
|
{ COMMAND_LOG: commandLog, REPLACED: replaced },
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr.includes("Gateway service Node runtime replaced.")).toBe(
|
|
replaced !== "none",
|
|
);
|
|
expect(result.stdout + result.stderr).not.toContain("incidental-output-canary");
|
|
expect(result.stdout + result.stderr).not.toContain("/old/node");
|
|
expect(result.stdout + result.stderr).not.toContain("/new/node");
|
|
expect(readFileSync(commandLog, "utf8").trim().split("\n")).toEqual([
|
|
"gateway install --force",
|
|
"gateway status --probe --json",
|
|
]);
|
|
} finally {
|
|
rmSync(tmp, { force: true, recursive: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ error: "SERVICE_DEFINITION_SEALED: protected", args: "", stream: "stderr" },
|
|
{ error: "SERVICE_DEFINITION_SEALED: protected", args: "--json", stream: "stdout" },
|
|
{ error: "SERVICE_DEFINITION_UNKNOWN: inaccessible", args: "--json", stream: "stderr" },
|
|
{ error: "service manager unavailable", args: "--json", stream: "stderr" },
|
|
])("handles a traced $error refresh in $stream", ({ args, error, stream }) => {
|
|
const root = tempDirs.make("openclaw-install-cli-definition-");
|
|
const prefix = join(root, "prefix");
|
|
const openclaw = join(prefix, "bin", "openclaw");
|
|
const secretCanary = "installer-cli-secret-canary-never-render";
|
|
const commandLog = join(root, "commands.log");
|
|
mkdirSync(join(prefix, "bin"), { recursive: true });
|
|
writeFileSync(
|
|
openclaw,
|
|
[
|
|
"#!/bin/bash",
|
|
'printf "%s\\n" "$*" >> "$COMMAND_LOG"',
|
|
'if [[ "$1" == "--version" ]]; then printf "OpenClaw 2026.8.25\\n"; exit 0; fi',
|
|
'if [[ "$*" == "gateway install --force" ]]; then',
|
|
' printf "%s\\n" "Replacing unsupported Gateway service Node 22.23.1 (/old/node) with /new/node; refreshing the install."',
|
|
' if [[ "$SERVICE_STREAM" == stdout ]]; then printf "%s\\n" "$SERVICE_ERROR"; else printf "%s\\n" "$SERVICE_ERROR" >&2; fi',
|
|
' printf "%s\\n" "$SECRET_CANARY" >&2; exit 1',
|
|
"fi",
|
|
].join("\n"),
|
|
);
|
|
chmodSync(openclaw, 0o755);
|
|
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"install_node() { :; }; ensure_git() { :; }; install_openclaw() { :; }",
|
|
"is_gateway_daemon_loaded() { return 0; }",
|
|
"set -x",
|
|
`main ${args} --prefix ${JSON.stringify(prefix)}`,
|
|
].join("\n"),
|
|
{
|
|
COMMAND_LOG: commandLog,
|
|
SECRET_CANARY: secretCanary,
|
|
SERVICE_ERROR: error,
|
|
SERVICE_STREAM: stream,
|
|
},
|
|
);
|
|
|
|
const denied = error.startsWith("SERVICE_DEFINITION_");
|
|
expect(readFileSync(commandLog, "utf8").split("\n")).not.toContain("gateway restart");
|
|
expect(result.status).toBe(0);
|
|
expect(result.stderr).toContain("+ main");
|
|
expect(result.stdout + result.stderr).not.toContain(secretCanary);
|
|
expect(result.stdout + result.stderr).not.toContain("Gateway service Node runtime replaced");
|
|
if (denied) {
|
|
expect(result.stderr).toContain("gateway service definition left unchanged");
|
|
expect(result.stderr).toContain(
|
|
error.includes("SEALED")
|
|
? "privileged deployment owner"
|
|
: "inspect service-definition access",
|
|
);
|
|
if (args) {
|
|
expect(result.stdout).toContain('"event":"done"');
|
|
expect(result.stdout).toContain('"reason":"definition-mutation-denied"');
|
|
} else {
|
|
expect(result.stdout).toContain("OpenClaw installed (OpenClaw 2026.8.25).");
|
|
}
|
|
} else {
|
|
expect(result.stdout).toContain('"reason":"install-failed"');
|
|
expect(result.stdout).toContain('"event":"done"');
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ args: "--json", mode: "JSON" },
|
|
{ args: "", mode: "human" },
|
|
])(
|
|
"rejects a package without a runnable CLI in $mode mode before service refresh",
|
|
({ args }) => {
|
|
const tmp = tempDirs.make("openclaw-install-cli-invalid-package-");
|
|
const prefix = join(tmp, "prefix");
|
|
const refreshLog = join(tmp, "gateway-refresh.log");
|
|
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"npm_lifecycle_allow_arg() { :; }",
|
|
'install_node() { mkdir -p "$(node_dir)/lib/node_modules/openclaw/dist"; : > "$(node_dir)/lib/node_modules/openclaw/dist/entry.js"; }',
|
|
"ensure_git() { :; }",
|
|
'npm_bin() { printf "/usr/bin/true\\n"; }',
|
|
`refresh_gateway_service_if_loaded() { touch ${JSON.stringify(refreshLog)}; }`,
|
|
`main ${args} --prefix ${JSON.stringify(prefix)} --version 0.0.0`,
|
|
].join("\n"),
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toContain("Installed OpenClaw CLI did not return a version");
|
|
expect(result.stdout).not.toContain('"event":"done"');
|
|
expect(result.stdout).not.toContain("OpenClaw installed.");
|
|
expect(existsSync(refreshLog)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ args: "--json", mode: "JSON" },
|
|
{ args: "", mode: "human" },
|
|
])(
|
|
"rejects a version command that prints output and fails in $mode mode before service refresh",
|
|
({ args }) => {
|
|
const tmp = tempDirs.make("openclaw-install-cli-failed-version-");
|
|
const prefix = join(tmp, "prefix");
|
|
const bin = join(prefix, "bin");
|
|
const openclaw = join(bin, "openclaw");
|
|
const refreshLog = join(tmp, "gateway-refresh.log");
|
|
mkdirSync(bin, { recursive: true });
|
|
writeFileSync(openclaw, '#!/bin/bash\nprintf "OpenClaw 2026.8.1\\n"\nexit 1\n');
|
|
chmodSync(openclaw, 0o755);
|
|
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"install_node() { :; }",
|
|
"ensure_git() { :; }",
|
|
"install_openclaw() { :; }",
|
|
`refresh_gateway_service_if_loaded() { touch ${JSON.stringify(refreshLog)}; }`,
|
|
`main ${args} --prefix ${JSON.stringify(prefix)} --version 0.0.0`,
|
|
].join("\n"),
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toContain("Installed OpenClaw CLI did not return a version");
|
|
expect(result.stdout).not.toContain('"event":"done"');
|
|
expect(result.stdout).not.toContain("OpenClaw installed.");
|
|
expect(existsSync(refreshLog)).toBe(false);
|
|
},
|
|
);
|
|
|
|
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<typeof runInstallCliShell> | 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.each([
|
|
{ input: "arguments", method: "npm" },
|
|
{ input: "environment", method: "npm" },
|
|
{ input: "literal tilde", method: "npm" },
|
|
{ input: "arguments", method: "git" },
|
|
{ input: "environment", method: "git" },
|
|
{ input: "literal tilde", method: "git" },
|
|
] as const)(
|
|
"keeps a generated $method launcher working after $input supplied paths change cwd",
|
|
({ input, method }) => {
|
|
const tmp = mkdtempSync(join(tmpdir(), `openclaw-install-cli-relative-${method}-`));
|
|
const installRoot = join(tmp, "install-root");
|
|
const otherRoot = join(tmp, "other-root");
|
|
const home = join(tmp, "home");
|
|
const prefixInput = input === "literal tilde" ? "~/openclaw-local" : "openclaw-local";
|
|
const prefix = join(input === "literal tilde" ? home : installRoot, "openclaw-local");
|
|
const nodeDir = join(prefix, "tools", "node-v24.19.0");
|
|
const repoInput = input === "literal tilde" ? "~/openclaw-source" : "openclaw-source";
|
|
const repo = join(input === "literal tilde" ? home : installRoot, "openclaw-source");
|
|
mkdirSync(installRoot, { recursive: true });
|
|
mkdirSync(join(nodeDir, "bin"), { recursive: true });
|
|
mkdirSync(join(nodeDir, "lib", "node_modules", "openclaw", "dist"), { recursive: true });
|
|
mkdirSync(join(repo, ".git"), { recursive: true });
|
|
mkdirSync(join(repo, "dist"), { recursive: true });
|
|
mkdirSync(otherRoot, { recursive: true });
|
|
symlinkSync(nodeExecutable, join(nodeDir, "bin", "node"));
|
|
symlinkSync("node-v24.19.0", join(prefix, "tools", "node"));
|
|
writeFileSync(
|
|
join(nodeDir, "bin", "npm"),
|
|
'#!/bin/bash\nif [[ "$1" == "--version" ]]; then printf "11.15.0\\n"; elif [[ "$1" == "config" ]]; then printf "null\\n"; fi\n',
|
|
);
|
|
chmodSync(join(nodeDir, "bin", "npm"), 0o755);
|
|
for (const entry of [
|
|
join(nodeDir, "lib", "node_modules", "openclaw", "dist", "entry.js"),
|
|
join(repo, "dist", "entry.js"),
|
|
]) {
|
|
writeFileSync(entry, 'console.log("fixture cli");\n');
|
|
}
|
|
|
|
try {
|
|
const args =
|
|
input !== "environment"
|
|
? `--prefix ${JSON.stringify(prefixInput)}${
|
|
method === "git" ? ` --git-dir ${JSON.stringify(repoInput)}` : ""
|
|
}`
|
|
: "";
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(installRoot)}`,
|
|
`source ${JSON.stringify(join(process.cwd(), SCRIPT_PATH))}`,
|
|
"install_node() { :; }",
|
|
"ensure_git() { :; }",
|
|
"refresh_gateway_service_if_loaded() { :; }",
|
|
...(method === "git"
|
|
? [
|
|
"preflight_fresh_git_disk_space() { :; }",
|
|
"ensure_pnpm() { :; }",
|
|
"ensure_pnpm_git_prepare_allowlist() { :; }",
|
|
"cleanup_legacy_submodules() { :; }",
|
|
"resolve_git_openclaw_ref() { printf 'main\\n'; }",
|
|
"checkout_git_openclaw_ref() { :; }",
|
|
"git_install_lockfile_flag() { printf '%s\\n' '--no-frozen-lockfile'; }",
|
|
"run_pnpm() { :; }",
|
|
"git() { return 0; }",
|
|
]
|
|
: []),
|
|
`main --${method} ${args}`,
|
|
`cd ${JSON.stringify(otherRoot)}`,
|
|
`${JSON.stringify(join(prefix, "bin", "openclaw"))} --version`,
|
|
].join("\n"),
|
|
{
|
|
HOME: home,
|
|
OPENCLAW_GIT_DIR: input === "environment" && method === "git" ? repoInput : undefined,
|
|
OPENCLAW_PREFIX: input === "environment" ? prefixInput : undefined,
|
|
},
|
|
);
|
|
|
|
expect(result.status, result.stderr || result.stdout).toBe(0);
|
|
expect(result.stdout.trim().split("\n").at(-1)).toBe("fixture cli");
|
|
} finally {
|
|
rmSync(tmp, { force: true, recursive: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
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("keeps ref resolution and rebase failures explicit", () => {
|
|
expect(script).toContain(
|
|
'git -C "$repo_dir" fetch --no-tags origin "refs/heads/main:refs/remotes/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" ls-remote --exit-code origin');
|
|
expect(script).toContain('git -C "$repo_dir" checkout --detach "refs/tags/${ref}"');
|
|
expect(script).toContain('git -C "$repo_dir" rebase origin/main');
|
|
expect(script).not.toContain('git -C "$repo_dir" pull --rebase --no-tags || true');
|
|
});
|
|
|
|
it.each(["bundle", "remote"] as const)("pins a full commit from a %s", (source) => {
|
|
const result = runInstallCliShell(createInstallGitCommitFixtureScript(source), {
|
|
OPENCLAW_INSTALLER_SCRIPT: SCRIPT_PATH,
|
|
});
|
|
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("kind=immutable");
|
|
expect(result.stdout).toContain("rejected=HEAD~1");
|
|
});
|
|
|
|
it("prefers a release tag over a same-named branch", () => {
|
|
const result = runInstallCliShell(createInstallGitTagPreferenceFixtureScript(SCRIPT_PATH));
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("kind=immutable");
|
|
expect(result.stdout).toContain("selected=");
|
|
});
|
|
|
|
it("falls back to a v-prefixed branch when no matching release tag exists", () => {
|
|
const result = runInstallCliShell(createInstallGitBranchFallbackFixtureScript(SCRIPT_PATH));
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("kind=moving");
|
|
expect(result.stdout).toContain("selected=");
|
|
});
|
|
|
|
it("updates a stale existing main checkout from the remote tracking ref", () => {
|
|
const result = runInstallCliShell(createInstallGitUpdateFixtureScript(SCRIPT_PATH));
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("head=");
|
|
expect(result.stdout).toContain("tracking=");
|
|
expect(result.stdout).toContain("remote=");
|
|
});
|
|
|
|
it("restores an existing main checkout after a failed rebase", () => {
|
|
const result = runInstallCliShell(createInstallGitRebaseRecoveryFixtureScript(SCRIPT_PATH));
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("recovery=head-restored status-clean rebase-state-cleared");
|
|
});
|
|
|
|
it("verifies unchanged state when a hook refuses rebase before it starts", () => {
|
|
const result = runInstallCliShell(createInstallGitHookRefusalFixtureScript(SCRIPT_PATH));
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain(
|
|
"hook-refusal=head-verified status-verified rebase-state-absent",
|
|
);
|
|
});
|
|
|
|
it("uses non-frozen lockfile installs only for moving git refs", () => {
|
|
const result = runInstallCliShell(`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
printf 'moving=%s\\n' "$(git_install_lockfile_flag moving)"
|
|
printf 'immutable=%s\\n' "$(git_install_lockfile_flag immutable)"
|
|
`);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("moving=--no-frozen-lockfile");
|
|
expect(result.stdout).toContain("immutable=--frozen-lockfile");
|
|
expect(script).toContain(
|
|
'CI="${CI:-true}" run_pnpm -C "$repo_dir" install ${pnpm_prefer_offline_args[@]+"${pnpm_prefer_offline_args[@]}"} "$install_lockfile_flag"',
|
|
);
|
|
});
|
|
|
|
defineInstallerPnpmContract(installerContract);
|
|
|
|
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 'v24.16.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)}`,
|
|
`export PATH=${JSON.stringify(bin)}`,
|
|
"is_musl_linux() { return 0; }",
|
|
"is_root() { return 1; }",
|
|
`PREFIX=${JSON.stringify(prefix)}`,
|
|
`APK_NODE_BIN_DIR=${JSON.stringify(bin)}`,
|
|
"install_node linux x64",
|
|
].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-v24.19.0", "bin", "node");
|
|
const npmLink = join(prefix, "tools", "node-v24.19.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-v24.16.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 'v24.16.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 'v24.16.0\\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)}`,
|
|
`export PATH=${JSON.stringify(`${nodePrefixBin}:${oldBin}:${bin}`)}`,
|
|
"is_musl_linux() { return 0; }",
|
|
"is_root() { return 1; }",
|
|
`PREFIX=${JSON.stringify(prefix)}`,
|
|
"NODE_VERSION=24.16.0",
|
|
"install_node linux x64",
|
|
].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-v24.16.0", "bin", "node");
|
|
const npmLink = join(prefix, "tools", "node-v24.16.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 'v24.16.0\\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)}`,
|
|
`export PATH=${JSON.stringify(bin)}`,
|
|
"is_musl_linux() { return 0; }",
|
|
"is_root() { return 0; }",
|
|
`PREFIX=${JSON.stringify(prefix)}`,
|
|
`APK_NODE_BIN_DIR=${JSON.stringify(bin)}`,
|
|
"NODE_VERSION=24.16.0",
|
|
"install_node linux x64",
|
|
].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-v24.16.0", "bin", "node");
|
|
const npmLink = join(prefix, "tools", "node-v24.16.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("skips PATH Node runtimes whose npm command cannot start", () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-broken-npm-"));
|
|
const badBin = join(tmp, "bad-bin");
|
|
const goodBin = join(tmp, "good-bin");
|
|
const prefix = join(tmp, "prefix");
|
|
const badNpmLog = join(tmp, "bad-npm.log");
|
|
const goodNpmLog = join(tmp, "good-npm.log");
|
|
const goodNodeLog = join(tmp, "good-node.log");
|
|
const badNode = join(badBin, "node");
|
|
const badNpm = join(badBin, "npm");
|
|
const goodNode = join(goodBin, "node");
|
|
const goodNpm = join(goodBin, "npm");
|
|
|
|
mkdirSync(badBin, { recursive: true });
|
|
mkdirSync(goodBin, { recursive: true });
|
|
symlinkSync(nodeExecutable, badNode);
|
|
writeFileSync(
|
|
goodNode,
|
|
[
|
|
"#!/bin/bash",
|
|
'printf "%s\\n" "$*" >> "$GOOD_NODE_LOG"',
|
|
`exec ${JSON.stringify(nodeExecutable)} "$@"`,
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
writeFileSync(
|
|
badNpm,
|
|
["#!/bin/bash", 'printf "%s\\n" "$*" >> "$BAD_NPM_LOG"', "exit 42", ""].join("\n"),
|
|
);
|
|
writeFileSync(
|
|
goodNpm,
|
|
[
|
|
"#!/usr/bin/env node",
|
|
'require("node:fs").appendFileSync(',
|
|
" process.env.GOOD_NPM_LOG,",
|
|
' `${process.argv.slice(2).join(" ")}\\n`,',
|
|
");",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
chmodSync(badNpm, 0o755);
|
|
chmodSync(goodNode, 0o755);
|
|
chmodSync(goodNpm, 0o755);
|
|
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
`export PATH=${JSON.stringify(`${badBin}:${goodBin}:${process.env.PATH ?? ""}`)}`,
|
|
`PREFIX=${JSON.stringify(prefix)}`,
|
|
"try_link_usable_node_runtime_from_path",
|
|
].join("\n"),
|
|
{
|
|
BAD_NPM_LOG: badNpmLog,
|
|
GOOD_NPM_LOG: goodNpmLog,
|
|
GOOD_NODE_LOG: goodNodeLog,
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
const nodeLink = join(prefix, "tools", "node-v24.19.0", "bin", "node");
|
|
const npmLink = join(prefix, "tools", "node-v24.19.0", "bin", "npm");
|
|
expect(readFileSync(badNpmLog, "utf8")).toBe("--version\n");
|
|
expect(readFileSync(goodNpmLog, "utf8")).toBe("--version\n");
|
|
expect(readFileSync(goodNodeLog, "utf8")).toContain("npm --version");
|
|
expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true);
|
|
expect(readlinkSync(nodeLink)).toBe(goodNode);
|
|
expect(readlinkSync(npmLink)).toBe(goodNpm);
|
|
} 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)}`,
|
|
`export PATH=${JSON.stringify(bin)}`,
|
|
"is_musl_linux() { return 0; }",
|
|
"is_root() { return 0; }",
|
|
`PREFIX=${JSON.stringify(prefix)}`,
|
|
`APK_NODE_BIN_DIR=${JSON.stringify(bin)}`,
|
|
"NODE_VERSION=24.16.0",
|
|
"install_node linux x64",
|
|
].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 >= 24.16.0 with WAL-reset-safe SQLite 3.51.3+, 3.50.7+ within 3.50.x, or 3.44.6+ within 3.44.x",
|
|
);
|
|
expect(result.stdout).toContain("found Node v22.18.0, SQLite unavailable");
|
|
} finally {
|
|
rmSync(tmp, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("replaces cached generic Node runtimes below the runtime floor", () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-generic-stale-node-"));
|
|
const prefix = join(tmp, "prefix");
|
|
const nodePrefixBin = join(prefix, "tools", "node-v24.16.0", "bin");
|
|
const staleNode = join(nodePrefixBin, "node");
|
|
const staleNpm = join(nodePrefixBin, "npm");
|
|
const newNode = join(tmp, "new-node");
|
|
const newNpm = join(tmp, "new-npm");
|
|
|
|
mkdirSync(nodePrefixBin, { recursive: true });
|
|
writeFileSync(
|
|
staleNode,
|
|
[
|
|
"#!/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(staleNpm, ["#!/bin/bash", "exit 0", ""].join("\n"));
|
|
writeFileSync(
|
|
newNode,
|
|
[
|
|
"#!/bin/bash",
|
|
'if [[ "${1:-}" == "-v" ]]; then',
|
|
" printf 'v24.16.0\\n'",
|
|
" exit 0",
|
|
"fi",
|
|
'if [[ "${1:-}" == "-e" ]]; then',
|
|
" exit 0",
|
|
"fi",
|
|
"exit 0",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
writeFileSync(newNpm, ["#!/bin/bash", "exit 0", ""].join("\n"));
|
|
chmodSync(staleNode, 0o755);
|
|
chmodSync(staleNpm, 0o755);
|
|
chmodSync(newNode, 0o755);
|
|
chmodSync(newNpm, 0o755);
|
|
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"is_musl_linux() { return 1; }",
|
|
"detect_downloader() { :; }",
|
|
"require_bin() { :; }",
|
|
"download_file() {",
|
|
' case "$1" in',
|
|
" */SHASUMS256.txt) printf 'fixture-sha node-v24.16.0-linux-x64.tar.gz\\n' > \"$2\" ;;",
|
|
" *) printf 'node tarball fixture\\n' > \"$2\" ;;",
|
|
" esac",
|
|
"}",
|
|
"sha256_file() { printf 'fixture-sha\\n'; }",
|
|
"tar() {",
|
|
" local dest=''",
|
|
" while [[ $# -gt 0 ]]; do",
|
|
' if [[ "$1" == \'-C\' ]]; then dest="$2"; shift 2; else shift; fi',
|
|
" done",
|
|
' mkdir -p "$dest/bin"',
|
|
' cp "$NEW_NODE" "$dest/bin/node"',
|
|
' cp "$NEW_NPM" "$dest/bin/npm"',
|
|
"}",
|
|
`PREFIX=${JSON.stringify(prefix)}`,
|
|
"NODE_VERSION=24.16.0",
|
|
"install_node linux x64",
|
|
].join("\n"),
|
|
{
|
|
NEW_NODE: newNode,
|
|
NEW_NPM: newNpm,
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("Installing Node 24.16.0 (user-space)");
|
|
expect(result.stdout).not.toContain('"status":"skip"');
|
|
expect(readFileSync(staleNode, "utf8")).toContain("v24.16.0");
|
|
} finally {
|
|
rmSync(tmp, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ existing: false, starts: true },
|
|
{ existing: false, starts: false },
|
|
{ existing: true, starts: true },
|
|
{ existing: true, starts: false },
|
|
])(
|
|
"keeps the active runtime until a downloaded replacement is usable ($existing, $starts)",
|
|
({ existing, starts }) => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-generic-old-node-"));
|
|
const prefix = join(tmp, "prefix");
|
|
const newNode = join(tmp, "new-node");
|
|
const newNpm = join(tmp, "new-npm");
|
|
const activeNode = join(prefix, "tools", "node");
|
|
const oldNodeDir = join(prefix, "tools", "node-v22.23.2");
|
|
const oldPackage = join(oldNodeDir, "lib", "node_modules", "openclaw", "package.json");
|
|
if (existing) {
|
|
mkdirSync(join(oldNodeDir, "bin"), { recursive: true });
|
|
writeFileSync(join(oldNodeDir, "bin", "node"), "#!/bin/bash\nprintf 'v22.23.2\\n'\n");
|
|
chmodSync(join(oldNodeDir, "bin", "node"), 0o755);
|
|
mkdirSync(join(oldPackage, ".."), { recursive: true });
|
|
writeFileSync(oldPackage, '{"name":"openclaw","version":"2026.9.2"}\n');
|
|
symlinkSync(oldNodeDir, activeNode);
|
|
}
|
|
|
|
writeFileSync(
|
|
newNode,
|
|
[
|
|
"#!/bin/bash",
|
|
...(starts ? [] : ["exit 126"]),
|
|
'if [[ "${1:-}" == "-v" ]]; then',
|
|
" printf 'v22.22.2\\n'",
|
|
" exit 0",
|
|
"fi",
|
|
'if [[ "${1:-}" == "-e" ]]; then',
|
|
" exit 0",
|
|
"fi",
|
|
"exit 0",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
writeFileSync(newNpm, ["#!/bin/bash", "exit 0", ""].join("\n"));
|
|
chmodSync(newNode, 0o755);
|
|
chmodSync(newNpm, 0o755);
|
|
|
|
try {
|
|
const install = () =>
|
|
runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"is_musl_linux() { return 1; }",
|
|
"detect_downloader() { :; }",
|
|
"require_bin() { :; }",
|
|
"download_file() {",
|
|
' case "$1" in',
|
|
" */SHASUMS256.txt) printf 'fixture-sha node-v24.16.0-linux-x64.tar.gz\\n' > \"$2\" ;;",
|
|
" *) printf 'node tarball fixture\\n' > \"$2\" ;;",
|
|
" esac",
|
|
"}",
|
|
"sha256_file() { printf 'fixture-sha\\n'; }",
|
|
"tar() {",
|
|
" local dest=''",
|
|
" while [[ $# -gt 0 ]]; do",
|
|
' if [[ "$1" == \'-C\' ]]; then dest="$2"; shift 2; else shift; fi',
|
|
" done",
|
|
' mkdir -p "$dest/bin"',
|
|
' cp "$NEW_NODE" "$dest/bin/node"',
|
|
' cp "$NEW_NPM" "$dest/bin/npm"',
|
|
"}",
|
|
`PREFIX=${JSON.stringify(prefix)}`,
|
|
"NODE_VERSION=24.16.0",
|
|
"install_node linux x64",
|
|
].join("\n"),
|
|
{
|
|
NEW_NODE: newNode,
|
|
NEW_NPM: newNpm,
|
|
},
|
|
);
|
|
const result = install();
|
|
expect(result.status).toBe(1);
|
|
expect(result.stdout).toContain(
|
|
"Installed Node 24.16.0 must provide Node >= 24.16.0 with WAL-reset-safe SQLite",
|
|
);
|
|
expect(result.stdout).toContain(
|
|
starts
|
|
? "found Node v22.22.2, SQLite unavailable"
|
|
: "found Node unknown, SQLite unavailable",
|
|
);
|
|
if (existing) {
|
|
expect(readlinkSync(activeNode)).toBe(oldNodeDir);
|
|
expect(
|
|
spawnSync(join(activeNode, "bin", "node"), ["-v"], { encoding: "utf8" }).stdout,
|
|
).toBe("v22.23.2\n");
|
|
expect(readFileSync(oldPackage, "utf8")).toBe(
|
|
'{"name":"openclaw","version":"2026.9.2"}\n',
|
|
);
|
|
} else {
|
|
expect(existsSync(activeNode)).toBe(false);
|
|
}
|
|
|
|
writeFileSync(
|
|
newNode,
|
|
"#!/bin/bash\nif [[ \"${1:-}\" == '-v' ]]; then printf 'v24.16.0\\n'; fi\nexit 0\n",
|
|
);
|
|
const retry = install();
|
|
expect(retry.status, retry.stdout + retry.stderr).toBe(0);
|
|
expect(readlinkSync(activeNode)).toBe(join(prefix, "tools", "node-v24.16.0"));
|
|
expect(
|
|
spawnSync(join(activeNode, "bin", "node"), ["-v"], { encoding: "utf8" }).stdout,
|
|
).toBe("v24.16.0\n");
|
|
if (existing) {
|
|
expect(readFileSync(oldPackage, "utf8")).toBe(
|
|
'{"name":"openclaw","version":"2026.9.2"}\n',
|
|
);
|
|
}
|
|
} finally {
|
|
rmSync(tmp, { force: true, recursive: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("removes the Node staging directory when download fails", () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-node-cleanup-"));
|
|
const prefix = join(tmp, "prefix");
|
|
const stagingDir = join(tmp, "node-staging");
|
|
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"is_musl_linux() { return 1; }",
|
|
"linked_node_is_usable() { return 1; }",
|
|
"detect_downloader() { :; }",
|
|
"require_bin() { :; }",
|
|
`mktemp() { mkdir -p ${JSON.stringify(stagingDir)}; printf '%s\\n' ${JSON.stringify(stagingDir)}; }`,
|
|
"download_file() { return 42; }",
|
|
`PREFIX=${JSON.stringify(prefix)}`,
|
|
"NODE_VERSION=24.16.0",
|
|
"install_node linux x64",
|
|
].join("\n"),
|
|
);
|
|
|
|
expect(result.status).toBe(42);
|
|
expect(() => lstatSync(stagingDir)).toThrow();
|
|
} finally {
|
|
rmSync(tmp, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("removes the workspace rewrite temp file when rewriting fails", () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-workspace-cleanup-"));
|
|
const repo = join(tmp, "repo");
|
|
const workspaceFile = join(repo, "pnpm-workspace.yaml");
|
|
const rewriteTemp = join(tmp, "workspace-rewrite");
|
|
const workspace = 'packages:\n - "packages/*"\n\nallowBuilds:\n';
|
|
mkdirSync(repo, { recursive: true });
|
|
writeFileSync(workspaceFile, workspace);
|
|
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`cd ${JSON.stringify(process.cwd())}`,
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
`mktemp() { : > ${JSON.stringify(rewriteTemp)}; printf '%s\\n' ${JSON.stringify(rewriteTemp)}; }`,
|
|
"awk() { return 43; }",
|
|
`ensure_pnpm_git_prepare_allowlist ${JSON.stringify(repo)}`,
|
|
].join("\n"),
|
|
);
|
|
|
|
expect(result.status).toBe(43);
|
|
expect(() => lstatSync(rewriteTemp)).toThrow();
|
|
expect(readFileSync(workspaceFile, "utf8")).toBe(workspace);
|
|
} 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_config_has_raw_key "$(npm_bin)" "min-release-age"');
|
|
expect(script).toContain('freshness_flag="--before=$(date -u');
|
|
expect(script).toContain("env -u NPM_CONFIG_BEFORE -u npm_config_before");
|
|
});
|
|
|
|
it.each([
|
|
{ expected: "", version: "11.15.0" },
|
|
{ expected: "--allow-scripts=openclaw", version: "11.16.0" },
|
|
{ expected: "--allow-scripts=openclaw", version: "12.0.0" },
|
|
])("resolves canonical npm lifecycle policy for npm $version", ({ expected, version }) => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-lifecycle-"));
|
|
const npm = join(tmp, "npm");
|
|
writeNpmLifecycleFixture(npm);
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
`node_bin() { printf '%s\n' ${JSON.stringify(nodeExecutable)}; }`,
|
|
`result="$(npm_lifecycle_allow_arg ${JSON.stringify(npm)} openclaw@latest)"`,
|
|
`printf '%s' "$result"`,
|
|
].join("\n"),
|
|
{ NPM_FAKE_VERSION: version },
|
|
);
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toBe(expected);
|
|
const tool = runInstallCliShell(
|
|
[
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
`node_bin() { printf '%s\\n' ${JSON.stringify(nodeExecutable)}; }`,
|
|
`npm_lifecycle_allow_arg ${JSON.stringify(npm)} pnpm@12.0.0 "$PWD" pnpm@12.0.0`,
|
|
].join("\n"),
|
|
{ NPM_FAKE_VERSION: version },
|
|
);
|
|
expect(tool.status).toBe(0);
|
|
expect(tool.stdout).toBe(expected ? "--allow-scripts=pnpm@12.0.0" : "");
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each(["invalid", "npm 12.0.0 warning"])(
|
|
"rejects npm version %s before mutation",
|
|
(version) => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-lifecycle-invalid-"));
|
|
const npm = join(tmp, "npm");
|
|
const args = join(tmp, "args");
|
|
writeNpmLifecycleFixture(npm);
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
`node_bin() { printf '%s\n' ${JSON.stringify(nodeExecutable)}; }`,
|
|
`npm_lifecycle_allow_arg ${JSON.stringify(npm)} openclaw@latest`,
|
|
].join("\n"),
|
|
{ NPM_FAKE_ARGS: args, NPM_FAKE_VERSION: version },
|
|
);
|
|
expect(result.status).not.toBe(0);
|
|
expect(existsSync(args)).toBe(false);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["openclaw@npm:@scope/candidate@1.0.0", "--allow-scripts=@scope/candidate"],
|
|
["openclaw@npm:@scope/candidate.tgz@1.0.0", "--allow-scripts=@scope/candidate.tgz"],
|
|
["file:/tmp/openclaw.tgz", "--allow-scripts=file:/tmp/openclaw.tgz"],
|
|
["vendor/repo.tgz", "--allow-scripts=vendor/repo.tgz"],
|
|
["vendor/repo#release.tgz", "--allow-scripts=vendor/repo#release.tgz"],
|
|
[
|
|
"https://example.invalid/openclaw.tgz",
|
|
"--allow-scripts=https://example.invalid/openclaw.tgz",
|
|
],
|
|
])("uses npm-resolved lifecycle identity for %s", (spec, expected) => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-identity-"));
|
|
const npm = join(tmp, "npm");
|
|
writeNpmLifecycleFixture(npm);
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
`node_bin() { printf '%s\n' ${JSON.stringify(nodeExecutable)}; }`,
|
|
`npm_lifecycle_allow_arg ${JSON.stringify(npm)} ${JSON.stringify(spec)}`,
|
|
].join("\n"),
|
|
{ NPM_FAKE_VERSION: "12.0.0" },
|
|
);
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout.trim()).toBe(expected);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
defineInstallerNpmArchiveIdentityContract(installerContract);
|
|
|
|
it.each([
|
|
{ version: "11.16.0", advisory: true },
|
|
{ version: "12.0.0", advisory: false },
|
|
])(
|
|
"handles comma tarball identity under npm $version before mutation",
|
|
({ version, advisory }) => {
|
|
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-archive-comma,"));
|
|
const npm = join(tmp, "npm");
|
|
const args = join(tmp, "args");
|
|
writeNpmLifecycleFixture(npm);
|
|
try {
|
|
const result = runInstallCliShell(
|
|
[
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
`node_bin() { printf '%s\\n' ${JSON.stringify(nodeExecutable)}; }`,
|
|
`cd ${JSON.stringify(tmp)}`,
|
|
`npm_lifecycle_allow_arg ${JSON.stringify(npm)} ${JSON.stringify(join(tmp, "candidate.tgz"))} "$PWD"`,
|
|
].join("\n"),
|
|
{ NPM_FAKE_VERSION: version, NPM_FAKE_ARGS: args },
|
|
);
|
|
expect(result.status).toBe(advisory ? 0 : 1);
|
|
if (advisory) {
|
|
expect(result.stdout).toBe("--allow-scripts=./candidate.tgz");
|
|
} else {
|
|
expect(result.stderr).toContain("without commas");
|
|
}
|
|
expect(existsSync(args)).toBe(false);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
defineInstallerNpmDirectoryIdentityContract(installerContract);
|
|
|
|
defineInstallerNpmConfigContract(installerContract);
|
|
|
|
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");
|
|
});
|
|
|
|
defineInstallerNpmRetryContract(installerContract);
|
|
|
|
defineInstallerNpmFreshnessContract(installerContract);
|
|
});
|