mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* feat(browser): unify local Chrome setup across desktop and terminal Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> * feat(browser): unify local Chrome setup across desktop and terminal OpenClaw-Publication: d19e865e-b5a0-4c70-8876-c1662f6e7ef2 Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> * chore(linux): format Chrome setup fixture Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> * feat(browser): keep desktop Chrome setup local and preserve pairing Delegate Windows registration to the shared native management owner, preserve released native bridge compatibility and saved launcher profiles, and integrate serialized desktop setup through isolated local runtimes. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(browser): repair native setup CI contracts Keep Windows installer dependencies acyclic, validate Unicode within the package library target, and remove unused private exports. Require all eight packaged native-host proof cases and update lazy CLI inventory. Apply native Swift formatter diagnostics without changing behavior. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test(cli): account for plugin-owned browser extension catalog Keep the core-only registration invariant aligned with the Browser plugin owner already exercised by its lazy registration tests. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test(macos): retain released Chrome bridge request expectation Align the native bridge test with the shipped contract1 request retained by the canonical setup owner, and reject extra legacy payload fields. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test(tui): give command handler harness a unique export Rename the shared TUI test helper and both consumers to avoid the Gateway placement harness export collision. No alias or guard waiver. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * chore(sdk): allow canonical browser config path resolver Apply the approved single public-export and callable allowance for resolveConfigPath. Preserve canonical pre-config path ownership and all other SDK surface checks. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(browser): preserve desktop setup selection and supported actions Keep native automatic setup selector-free and resolve saved local browser selection through the canonical setup owner before installation. Respect Mac action advertisements and the released legacy install projection in the Apps card, and document the public config-path resolver contract. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(auth): retry model selection after concurrent credential refresh Adopt upstream PR #152426, commit32298b10f6, without changing its five source files. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test: preserve native setup selection and await dashboard document Match the selector-free native CLI arguments exactly and preserve a saved work-profile result. Wait through the existing document-readiness owner only at the quota test browser-proof boundary, after auth assertions. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(ui): avoid preloading already imported modules Remove exact direct static JavaScript imports from lazy preload tables using the emitted build graph. Preserve HTML, lazy-only JavaScript, CSS, and locale hints. Source-exact CI merge reproduction drops startup gzip from 363283 to 362968 bytes without changing budgets. Add a real emitted-bundle regression. Apply rustfmt layout to the native Chrome selector-free expected arguments. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix: preserve Chrome profiles and attachment follow-up branch binding Let the TUI canonical setup controller retain its saved browser profile and project only a bounded returned name. Align both native first-run fixture expectations with selector-free setup. Join pending chat history before the composer task handoff can expose an admitted attachment to restored-outbox delivery. Preserve idempotency, attachment custody, restored delivery semantics, and all existing assertions and timeouts. Add a deterministic regression reproduced on the exact failed CI merge and its main parent. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test(state): adopt canonical worker-custody fixture repair Adopt src/plugin-state/plugin-state-worker.test.ts byte-for-byte from upstreambfec65a2a0(#152456). The former fixture held its late competing owner until after awaiting off-thread acquisition. Preserve that overlap, assert continued host authority checks and noncompletion, release custody, then assert the original result and persisted state. No production locking, guard, deadline or outcome assertion is relaxed. Both prior failures reproduced on the exact CI main parent with independently installed frozen dependencies and Node 24.19.0. All 12 repaired file tests, selected state-logging types, scoped typed lint and fresh P0-P2 review passed. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(browser): retain saved Windows setup profiles Recover configured extension profiles through bounded serial read-only C# inspection. Select only independently validated current matching descriptors, confirm the selected generation before effects, and leave the single mutation under the existing C# owner. Preserve POSIX behavior, existing manual relay verification and explicit same-profile repair. Missing descriptors, runtime/origin drift and unknown or changing observations fail closed without automatic mutation. Keep raw management facts private and populate the existing browserProfile field only from validated binding metadata. No ABI, schema, SDK, configuration flag or registry/activation owner change. 29 actual CLI/controller/Windows-adapter boundary cases plus sibling coverage: 94 tests pass. Canonical changed checks, full production build and fresh independent P0-P2 review passed. Actual C# native proof remains separately coordinated. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(browser): preserve saved profiles after POSIX bundle relocation Separate validated native registration ownership from supported origin-migration readiness. Recover the profile only after full private manifest and exact launcher validation; preserve the existing one-slot migration rule and all unsupported-origin, ACL and foreign-host refusals. Fail closed before selector-free installation when the saved selection cannot be proved. Extract the unchanged shared origin helpers into a cohesive sibling to satisfy the existing line-cap guard without waivers. Windows admission, ABI and selector behavior remain unchanged. Actual Linux/Darwin CLI-to-filesystem relocation regressions: 18 failures on original production, all 22 cases repaired. Preserve the private relay key and inode, config, Chrome preferences, work relay19444 and explicit-profile intent. 137 focused tests, eight real POSIX native-host E2E cases, canonical changed checks, full production build and fresh P0-P2 review passed. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(ui): retain input handoff through the shared outbox owner Remove the superseded pending-history no-yield workaround after main introduced foreground submission custody in the shared outbox owner. Restore chat-submit-guard.ts exactly to pinned main cc7 rather than retaining competing timing policies. Keep passive drains fenced while the input task yields. Preserve the retained history regression with explicit MessageChannel admission, no passive send before resume, and the same terminal leaf, idempotency key, attachment bytes and exactly-once assertions after completion. Original composed source fails all five focused cases; the repair passes 67 handoff/attachment cases and 20 real Chromium cases in the canonical secretless network-none runner. Canonical checks, UI build/performance and fresh P0-P2 review pass. No assertion, timeout, origin or proxy-policy weakening. Browser POSIX/Windows repairs remain byte-identical to accepted255f. The failed e40e CI receipts remain preserved; fresh exact-head CI and parent handoff are still required. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(tasks): preserve reads across native event finalization Hand joined event publication to its exact native successor after the native flow and observer publication frame completes. Keep worker settlement and cleanup, reversible claim transfer, current-authority and ABA checks, and post-commit delivery in their existing owners without replaying writes. Cover pre-result and readback finalization, native and reentrant successor chains, rollback, failed publication, delivery, and terminal activity cleanup. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test(ui): retain rail baseline geometry on readiness failure Keep the exact existing readiness predicate, fixtures, case inventory, assertion and timeout. When the predicate is false, retain synthetic marker identity and numeric geometry so hosted CI can distinguish scroll, visibility and viewport failures. This is diagnostic evidence, not a repair or waiver of the unresolved rail failure. Local rootless browser infrastructure is unavailable; the existing hosted CI lane will verify the reviewed task-publication repair and collect meaningful rail evidence. Canonical changed checks and P0-P2 diagnostic review pass. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * docs(linux): describe saved-profile Chrome setup selection Match the selector-free adapter argument vector and its regression test. Address the fresh P3 review finding without changing runtime behavior. Markdown syntax and diff checks pass; the generic formatter excludes this subtree. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * test(transcripts): join configured startup before cleanup faults Observe and await the real startTranscripts promise through a narrow call-through spy while retaining the configured service entry point and real SQLite/provider work. Bind the await to the existing test lifetime instead of charging startup to the subsequent short active-map poll. Gate provider return after persisted utterance to prove readiness does not settle early; retain both missing/unreadable row injections and all cleanup, private-source, lifecycle-token and summary assertions. Cover real startup rejection explicitly. No production change, timeout increase, retries or broad module/storage mocks. The deterministic ordering boundary fails with the old fire-and-forget readiness and passes with the real promise join. Final 39 tests across 3 files, canonical changed checks and full-owner P0-P2 review pass. This does not recover whether the historical CI startup was late or rejected. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(browser): preserve automatic desktop status inspection Restore read-only Device-page inspection for current and released Mac bridges while keeping installation and verification explicit. Preserve the native filesystem prerequisite proof, split installer repair tests within the existing line cap, and remove the superseded constant export. * fix(browser): preserve registered setup configuration Require canonical setup to match an owned launcher's effective state and config selection before installation or relay access. Preserve equivalent implicit/explicit default selections and the saved launch context. Recheck automatic profile selection before effects and the current manifest before publication through the existing registration owner. Keep manual install and relocation repair contracts unchanged. Cover mismatched configs, legacy selectors, equivalent defaults, selection drift, and actual bootstrap after refused setup. Restore the missing Command import in the existing Unix-only companion CLI test. Focused tests, types, lint, fresh review, clean package build and sealed Mac ARM64 runtime proof pass. The separate historical clock-jump CI failure has bounded replay evidence and remains documented without a speculative fix. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> * fix(browser): keep setup registration types acyclic Move the private registration status contract beside its context policy and point both consumers at that owner. Remove the publication-module back-edge without keeping an unused compatibility export. The full architecture gate, extension production/test types, typed lint and fresh independent review pass. Node's transformed JavaScript is byte-identical for all three affected modules, so the existing runtime proof remains valid. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> * test(linux): handle Chrome setup in desktop sharing fixture Recognize the exact automatic Chrome setup invocation and require its native no-respawn flag. Keep unknown-command rejection, selected-auth validation, process-group ownership and joined teardown assertions unchanged. The original fixture reproduces the CI rejection against the real Linux app. The repaired fixture passes all nine checks against that same binary, with five Chrome setup calls and five node starts and joined stops. Fresh review is clean; production app behavior is unchanged. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> --------- Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
2381 lines
88 KiB
TypeScript
2381 lines
88 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.each([false, true])(
|
|
"keeps browser runtime-only installation free of service and onboarding effects (runtimeOnly=%s)",
|
|
(runtimeOnly) => {
|
|
const root = tempDirs.make("openclaw-browser-runtime-install-");
|
|
const prefix = join(root, "private-runtime");
|
|
const commandLog = join(root, "commands.log");
|
|
mkdirSync(join(prefix, "bin"), { recursive: true });
|
|
writeFileSync(
|
|
join(prefix, "bin", "openclaw"),
|
|
[
|
|
"#!/bin/bash",
|
|
'printf "%s\\n" "$*" >> "$COMMAND_LOG"',
|
|
'if [[ "$1" == --version ]]; then printf "OpenClaw 2026.9.4\\n"; fi',
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
const result = runInstallCliShell(
|
|
[
|
|
"set -euo pipefail",
|
|
`source ${JSON.stringify(SCRIPT_PATH)}`,
|
|
"install_node() { :; }; install_openclaw() { :; }",
|
|
'ensure_git() { printf "git\\n" >> "$COMMAND_LOG"; }',
|
|
'refresh_gateway_service_if_loaded() { printf "service-refresh\\n" >> "$COMMAND_LOG"; }',
|
|
`main --json --npm --onboard ${runtimeOnly ? "--runtime-only" : ""} --prefix ${JSON.stringify(prefix)}`,
|
|
].join("\n"),
|
|
{ COMMAND_LOG: commandLog },
|
|
);
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
expect(readFileSync(commandLog, "utf8").trim().split("\n")).toEqual(
|
|
runtimeOnly ? ["--version"] : ["git", "--version", "service-refresh", "onboard"],
|
|
);
|
|
expect(result.stdout).toContain('"event":"done"');
|
|
},
|
|
);
|
|
|
|
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.each([17, 43])(
|
|
"uses the configured %s-second budget for curl connection and transfer stalls",
|
|
(budget) => {
|
|
const result = runInstallCliShell(`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
UPDATE_NETWORK_TIMEOUT_SECONDS=${budget}
|
|
DOWNLOADER=curl
|
|
curl() { printf '%s\n' "$*"; return 28; }
|
|
set +e
|
|
download_file "https://example.invalid/archive.tgz" "/tmp/archive.tgz"
|
|
printf 'status=%s\n' "$?"
|
|
`);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain(`--connect-timeout ${budget}`);
|
|
expect(result.stdout).toContain(`--speed-limit 1 --speed-time ${budget}`);
|
|
expect(result.stdout).toContain("--retry 3 --retry-delay 1 --retry-connrefused");
|
|
expect(result.stdout).toContain("--proto =https");
|
|
expect(result.stdout).toContain("--tlsv1.2");
|
|
expect(result.stdout).not.toContain("--max-time");
|
|
expect(result.stdout).toContain("status=28");
|
|
},
|
|
);
|
|
|
|
it("bounds stalled 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' "$?"
|
|
wget() {
|
|
printf 'wget=%s\n' "$*"
|
|
return 4
|
|
}
|
|
DOWNLOADER=wget
|
|
download_file "https://example.invalid/node.tar.gz" "/tmp/node.tar.gz"
|
|
printf 'wget-status=%s\n' "$?"
|
|
`);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("--speed-limit 1 --speed-time 300");
|
|
expect(result.stdout).toContain("--connect-timeout 300");
|
|
expect(result.stdout).not.toContain("--max-time");
|
|
expect(result.stdout).toContain("--retry 3 --retry-delay 1 --retry-connrefused");
|
|
expect(result.stdout).toContain("status=28");
|
|
expect(result.stdout).toContain("--timeout=300");
|
|
expect(result.stdout).toContain("wget-status=4");
|
|
});
|
|
|
|
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.each(
|
|
["path", "apk"].flatMap((route) =>
|
|
["version", "npm", "sqlite"].map((failure) => ({ route, failure })),
|
|
),
|
|
)("preserves the active runtime when $route rejects $failure", ({ route, failure }) => {
|
|
const root = tempDirs.make("openclaw-install-cli-preserve-runtime-");
|
|
const bin = join(root, "candidate");
|
|
const prefix = join(root, "prefix");
|
|
const oldRuntime = join(root, "old-runtime");
|
|
mkdirSync(bin);
|
|
linkRequiredShellTools(bin);
|
|
mkdirSync(join(oldRuntime, "bin"), { recursive: true });
|
|
symlinkSync(nodeExecutable, join(oldRuntime, "bin", "node"));
|
|
mkdirSync(join(prefix, "tools"), { recursive: true });
|
|
symlinkSync(oldRuntime, join(prefix, "tools", "node"));
|
|
if (failure === "version") {
|
|
writeFileSync(join(bin, "node"), "#!/bin/bash\nprintf 'v22.18.0\\n'\n", {
|
|
mode: 0o755,
|
|
});
|
|
} else if (failure === "sqlite") {
|
|
writeFileSync(
|
|
join(bin, "node"),
|
|
'#!/bin/bash\nif [[ "$1" == -e ]]; then exit 1; fi\nexec "$FIXTURE_NODE" "$@"\n',
|
|
{ mode: 0o755 },
|
|
);
|
|
} else {
|
|
symlinkSync(nodeExecutable, join(bin, "node"));
|
|
}
|
|
writeFileSync(join(bin, "npm"), `#!/bin/bash\nexit ${failure === "npm" ? 42 : 0}\n`, {
|
|
mode: 0o755,
|
|
});
|
|
const result = runInstallCliShell(
|
|
`
|
|
source ${SCRIPT_PATH}
|
|
PREFIX="$FIXTURE_PREFIX"
|
|
PATH="$FIXTURE_BIN"
|
|
export PATH
|
|
APK_NODE_BIN_DIR="$FIXTURE_BIN"
|
|
is_root() { return 0; }
|
|
apk() { printf 'apk called\\n'; }
|
|
${route === "path" ? "try_link_usable_node_runtime_from_path" : "install_alpine_node"}
|
|
`,
|
|
{ FIXTURE_PREFIX: prefix, FIXTURE_BIN: bin, FIXTURE_NODE: nodeExecutable },
|
|
);
|
|
expect(result.status, result.stdout + result.stderr).toBe(1);
|
|
if (route === "apk") {
|
|
expect(result.stdout).toContain("apk called");
|
|
expect(result.stdout).toContain("Alpine Node package must provide Node >=");
|
|
}
|
|
expect(readlinkSync(join(prefix, "tools", "node"))).toBe(oldRuntime);
|
|
expect(readlinkSync(join(oldRuntime, "bin", "node"))).toBe(nodeExecutable);
|
|
expect(existsSync(join(prefix, "tools", "node-v24.19.0"))).toBe(false);
|
|
});
|
|
|
|
it.each(["alias path", "empty entry"])(
|
|
"excludes active runtime aliases from %s when selecting Node and optional tools",
|
|
(entry) => {
|
|
const root = tempDirs.make("openclaw-install-cli-runtime-alias-");
|
|
const bin = join(root, "system-bin");
|
|
const prefix = join(root, "prefix");
|
|
const oldRuntime = join(root, "old-runtime");
|
|
const oldBin = join(oldRuntime, "bin");
|
|
const aliasBin = join(root, "alias-bin");
|
|
mkdirSync(bin);
|
|
linkRequiredShellTools(bin);
|
|
mkdirSync(oldBin, { recursive: true });
|
|
for (const target of [bin, oldBin]) {
|
|
symlinkSync(nodeExecutable, join(target, "node"));
|
|
writeFileSync(join(target, "npm"), "#!/bin/bash\nexit 0\n", { mode: 0o755 });
|
|
}
|
|
for (const tool of ["npx", "corepack"]) {
|
|
writeFileSync(join(oldBin, tool), "#!/bin/bash\nexit 0\n", { mode: 0o755 });
|
|
}
|
|
mkdirSync(join(prefix, "tools"), { recursive: true });
|
|
symlinkSync(oldRuntime, join(prefix, "tools", "node"));
|
|
symlinkSync(join(prefix, "tools", "node", "bin"), aliasBin);
|
|
const result = runInstallCliShell(
|
|
`
|
|
source ${SCRIPT_PATH}
|
|
PREFIX="$FIXTURE_PREFIX"
|
|
${entry === "empty entry" ? 'cd "$FIXTURE_ALIAS"' : ""}
|
|
PATH="${entry === "empty entry" ? "" : "$FIXTURE_ALIAS"}:$FIXTURE_BIN"
|
|
export PATH
|
|
try_link_usable_node_runtime_from_path
|
|
`,
|
|
{ FIXTURE_PREFIX: prefix, FIXTURE_ALIAS: aliasBin, FIXTURE_BIN: bin },
|
|
);
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
const active = join(prefix, "tools", "node", "bin");
|
|
expect(readlinkSync(join(active, "node"))).toBe(join(bin, "node"));
|
|
expect(readlinkSync(join(active, "npm"))).toBe(join(bin, "npm"));
|
|
for (const tool of ["npx", "corepack"]) {
|
|
expect(readdirSync(active)).not.toContain(tool);
|
|
expect(readFileSync(join(oldBin, tool), "utf8")).toBe("#!/bin/bash\nexit 0\n");
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([".", "", "bin dir"])(
|
|
"publishes usable runtime links for relative PATH entry %j",
|
|
(pathEntry) => {
|
|
const root = tempDirs.make("openclaw-install-cli-relative-runtime-");
|
|
const source = join(root, "source dir");
|
|
const bin = join(source, pathEntry || ".");
|
|
const optional = join(source, "tools dir");
|
|
const fallback = join(root, "fallback");
|
|
const elsewhere = join(root, "elsewhere");
|
|
const prefix = join(root, "prefix");
|
|
mkdirSync(bin, { recursive: true });
|
|
mkdirSync(optional);
|
|
mkdirSync(fallback);
|
|
mkdirSync(elsewhere);
|
|
linkRequiredShellTools(fallback);
|
|
for (const [target, version] of [
|
|
[bin, "11.19.1"],
|
|
[fallback, "11.19.2"],
|
|
] as const) {
|
|
symlinkSync(nodeExecutable, join(target, "node"));
|
|
writeFileSync(
|
|
join(target, "npm"),
|
|
`#!/usr/bin/env node\nconsole.log(${JSON.stringify(version)});\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
}
|
|
for (const tool of ["npx", "corepack"]) {
|
|
writeFileSync(join(optional, tool), `#!/bin/bash\nprintf '${tool}-ok\\n'\n`, {
|
|
mode: 0o755,
|
|
});
|
|
}
|
|
const result = runInstallCliShell(
|
|
`
|
|
source ${SCRIPT_PATH}
|
|
PREFIX="$FIXTURE_PREFIX"
|
|
cd "$FIXTURE_SOURCE"
|
|
PATH="$FIXTURE_PATH:tools dir:$FIXTURE_FALLBACK"
|
|
export PATH
|
|
try_link_usable_node_runtime_from_path
|
|
cd "$FIXTURE_ELSEWHERE"
|
|
PATH="$PREFIX/tools/node/bin:$FIXTURE_FALLBACK"
|
|
"$PREFIX/tools/node/bin/node" -p '"node-ok"'
|
|
"$PREFIX/tools/node/bin/npm" --version
|
|
"$PREFIX/tools/node/bin/npx"
|
|
"$PREFIX/tools/node/bin/corepack"
|
|
`,
|
|
{
|
|
FIXTURE_PREFIX: prefix,
|
|
FIXTURE_SOURCE: source,
|
|
FIXTURE_PATH: pathEntry,
|
|
FIXTURE_FALLBACK: fallback,
|
|
FIXTURE_ELSEWHERE: elsewhere,
|
|
},
|
|
);
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
expect(result.stdout.trim()).toBe("node-ok\n11.19.1\nnpx-ok\ncorepack-ok");
|
|
for (const tool of ["node", "npm", "npx", "corepack"]) {
|
|
expect(readlinkSync(join(prefix, "tools", "node", "bin", tool))).toMatch(/^\//);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(
|
|
["command", "runtime"].flatMap((route) =>
|
|
["empty", "trailing", "leading", "absolute first"].map((order) => ({ route, order })),
|
|
),
|
|
)("preserves PATH lookup order for $route with $order entries", ({ route, order }) => {
|
|
const root = tempDirs.make("openclaw-install-cli-path-order-");
|
|
const current = join(root, "current");
|
|
const other = join(root, "other");
|
|
const prefix = join(root, "prefix");
|
|
for (const bin of [current, other]) {
|
|
mkdirSync(bin);
|
|
linkRequiredShellTools(bin);
|
|
if (bin === other && order === "trailing") {
|
|
continue;
|
|
}
|
|
symlinkSync(nodeExecutable, join(bin, "node"));
|
|
writeFileSync(
|
|
join(bin, "npm"),
|
|
`#!/bin/bash\nprintf '${bin === current ? "current" : "other"}-npm\\n'\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
}
|
|
const search = order === "empty" ? "" : order === "leading" ? `:${other}` : `${other}:`;
|
|
const result = runInstallCliShell(
|
|
`
|
|
source ${SCRIPT_PATH}
|
|
PREFIX="$FIXTURE_PREFIX"
|
|
cd "$FIXTURE_CURRENT"
|
|
PATH="$FIXTURE_SEARCH"
|
|
export PATH
|
|
${route === "command" ? 'selected="$(command_path_without_node_prefix npm)"' : 'try_link_usable_node_runtime_from_path; selected="$(npm_bin)"'}
|
|
"$selected" --version
|
|
`,
|
|
{ FIXTURE_PREFIX: prefix, FIXTURE_CURRENT: current, FIXTURE_SEARCH: search },
|
|
);
|
|
expect(result.status, result.stdout + result.stderr).toBe(0);
|
|
expect(result.stdout.trim()).toBe(order === "absolute first" ? "other-npm" : "current-npm");
|
|
});
|
|
|
|
it.each(["excluded path", "empty managed cwd"])(
|
|
"does not invent a cwd search after filtering %s",
|
|
(entry) => {
|
|
const root = tempDirs.make("openclaw-install-cli-filtered-path-");
|
|
const prefix = join(root, "prefix");
|
|
const managed = join(prefix, "tools", "node-v24.19.0", "bin");
|
|
const current = entry === "empty managed cwd" ? managed : join(root, "current");
|
|
mkdirSync(managed, { recursive: true });
|
|
mkdirSync(current, { recursive: true });
|
|
writeFileSync(join(current, "npm"), "#!/bin/bash\nexit 0\n", { mode: 0o755 });
|
|
const result = runInstallCliShell(
|
|
`
|
|
source ${SCRIPT_PATH}
|
|
PREFIX="$FIXTURE_PREFIX"
|
|
cd "$FIXTURE_CURRENT"
|
|
PATH="$FIXTURE_SEARCH"
|
|
command_path_without_node_prefix npm ${entry === "empty managed cwd" ? "1" : "0"}
|
|
`,
|
|
{
|
|
FIXTURE_PREFIX: prefix,
|
|
FIXTURE_CURRENT: current,
|
|
FIXTURE_SEARCH: entry === "empty managed cwd" ? "" : managed,
|
|
},
|
|
);
|
|
expect(result.status, result.stdout + result.stderr).toBe(1);
|
|
expect(result.stdout).toBe("");
|
|
},
|
|
);
|
|
|
|
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.each(["linux", "darwin"])(
|
|
"rejects OpenClaw GitHub source targets for npm installs on %s",
|
|
(os) => {
|
|
const result = runInstallCliShell(`
|
|
set -euo pipefail
|
|
source "${SCRIPT_PATH}"
|
|
os_detect() { printf '${os}\\n'; }
|
|
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);
|
|
});
|