fix(install): keep stable channel when npm install retries (#124778)

* fix(installer): keep npm channel target immutable

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* fix(install): fail after repeated CLI package install errors

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* fix(installer): verify npm package publication

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* fix(install): satisfy shellcheck for entry validation

* fix(installer): link the packaged OpenClaw launcher

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* test(installer): keep retry fixture version-valid

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-17 00:17:26 -07:00
committed by GitHub
parent 1027837d96
commit 2b2c11e748
5 changed files with 409 additions and 32 deletions
+143 -1
View File
@@ -19,6 +19,7 @@ import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
import {
writeNpmBeforePolicyFixture,
writeNpmFreshnessConflictFixture,
writeNpmInstallRetryFixture,
} from "./install-npm-fixtures.js";
const SCRIPT_PATH = "scripts/install-cli.sh";
@@ -41,6 +42,12 @@ function linkRequiredShellTools(bin: string) {
}
}
function writeInstalledOpenClawEntry(nodeDir: string) {
const entry = join(nodeDir, "lib", "node_modules", "openclaw", "dist", "entry.js");
mkdirSync(join(entry, ".."), { recursive: true });
writeFileSync(entry, "");
}
describe("install-cli.sh", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
@@ -494,7 +501,7 @@ describe("install-cli.sh", () => {
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"install_node() { :; }",
'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)}; }`,
@@ -1442,6 +1449,7 @@ describe("install-cli.sh", () => {
const nodeDir = join(tmp, "node");
mkdirSync(bin, { recursive: true });
mkdirSync(nodeDir, { recursive: true });
writeInstalledOpenClawEntry(nodeDir);
writeFileSync(npmrc, "min-release-age=7\n");
const fakeNpm = join(bin, "npm");
writeFileSync(
@@ -1517,6 +1525,7 @@ describe("install-cli.sh", () => {
mkdirSync(bin, { recursive: true });
mkdirSync(home, { recursive: true });
mkdirSync(nodeDir, { recursive: true });
writeInstalledOpenClawEntry(nodeDir);
if (source === "global") {
mkdirSync(join(prefix, "etc"), { recursive: true });
}
@@ -1598,6 +1607,137 @@ describe("install-cli.sh", () => {
expect(result.stdout).toContain("--install-method git --version main");
});
it.each([
{ requested: "latest", outcome: "success", error: "", calls: 1, status: 0 },
{
requested: "beta",
outcome: "transient",
error: "ECONNRESET socket hang up",
calls: 2,
status: 0,
},
{
requested: "next",
outcome: "transient",
error: "ECONNRESET socket hang up",
calls: 2,
status: 0,
},
{
requested: "2026.8.1",
outcome: "transient",
error: "ECONNRESET socket hang up",
calls: 2,
status: 0,
},
{
requested: "latest",
outcome: "persistent",
error: "EACCES permission denied",
calls: 2,
status: 1,
},
{
requested: "beta",
outcome: "persistent",
error: "ENOSPC no space left",
calls: 2,
status: 1,
},
])(
"keeps openclaw@$requested immutable across $outcome npm installs",
({ requested, outcome, error, calls: expectedCalls, status }) => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-npm-retry-"));
const fakeNpm = join(tmp, "npm");
const calls = join(tmp, "calls");
const nodeDir = join(tmp, "node");
const prefix = join(tmp, "prefix");
writeNpmInstallRetryFixture(fakeNpm);
try {
const result = runInstallCliShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`npm_bin() { printf '%s\\n' ${JSON.stringify(fakeNpm)}; }`,
`node_dir() { printf '%s\\n' ${JSON.stringify(nodeDir)}; }`,
"npm_config_has_raw_key() { return 1; }",
`PREFIX=${JSON.stringify(prefix)}`,
`OPENCLAW_VERSION=${requested}`,
"JSON=1",
"set +e",
"install_openclaw",
"status=$?",
'exit "$status"',
].join("\n"),
{
NPM_FAKE_CALLS: calls,
NPM_FAKE_ERROR: error,
NPM_FAKE_OUTCOME: outcome,
NPM_FAKE_PACKAGE_DIR: join(nodeDir, "lib", "node_modules", "openclaw"),
},
);
expect(result.status).toBe(status);
expect(readFileSync(calls, "utf8").trim().split("\n")).toEqual(
Array.from({ length: expectedCalls }, () => `openclaw@${requested}`),
);
if (status !== 0) {
expect(result.stderr).toContain(`${error} (attempt 2)`);
expect(result.stdout).not.toContain('"status":"ok"');
expect(existsSync(join(prefix, "bin", "openclaw"))).toBe(false);
}
if (requested !== "next") {
expect(`${result.stdout}\n${result.stderr}`).not.toContain("openclaw@next");
}
} finally {
rmSync(tmp, { force: true, recursive: true });
}
},
);
it("fails after retrying the exact npm spec when npm exits zero without installing OpenClaw", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-empty-success-"));
const fakeNpm = join(tmp, "npm");
const calls = join(tmp, "calls");
const nodeDir = join(tmp, "node");
const prefix = join(tmp, "prefix");
writeNpmInstallRetryFixture(fakeNpm);
try {
const result = runInstallCliShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`npm_bin() { printf '%s\\n' ${JSON.stringify(fakeNpm)}; }`,
`node_dir() { printf '%s\\n' ${JSON.stringify(nodeDir)}; }`,
"npm_config_has_raw_key() { return 1; }",
`PREFIX=${JSON.stringify(prefix)}`,
"OPENCLAW_VERSION=latest",
"JSON=1",
"install_openclaw",
].join("\n"),
{
NPM_FAKE_CALLS: calls,
NPM_FAKE_ERROR: "",
NPM_FAKE_OUTCOME: "success",
},
);
expect(result.status).toBe(1);
expect(readFileSync(calls, "utf8").trim().split("\n")).toEqual([
"openclaw@latest",
"openclaw@latest",
]);
expect(result.stdout).toContain("npm install did not produce a usable OpenClaw package");
expect(result.stdout).not.toContain('"status":"ok"');
expect(result.stdout).not.toContain("openclaw@next");
expect(existsSync(join(prefix, "bin", "openclaw"))).toBe(false);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
});
it("does not emit before args when npmrc min-release-age computes a before cutoff", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-freshness-"));
const prefix = join(tmp, "prefix");
@@ -1606,6 +1746,7 @@ describe("install-cli.sh", () => {
const argsLog = join(tmp, "npm-args.log");
mkdirSync(nodeBin, { recursive: true });
mkdirSync(home, { recursive: true });
writeInstalledOpenClawEntry(join(prefix, "tools", "node-v24.15.0"));
writeFileSync(join(home, ".npmrc"), "min-release-age=7\n");
writeNpmFreshnessConflictFixture(join(nodeBin, "npm"), argsLog);
@@ -1643,6 +1784,7 @@ describe("install-cli.sh", () => {
mkdirSync(nodeBin, { recursive: true });
mkdirSync(home, { recursive: true });
mkdirSync(project, { recursive: true });
writeInstalledOpenClawEntry(join(prefix, "tools", "node-v24.15.0"));
writeFileSync(join(home, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n");
writeFileSync(join(project, ".npmrc"), "min-release-age=7\n");
writeNpmBeforePolicyFixture(join(nodeBin, "npm"), argsLog);