diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 90a8682903ca..0d318e49d1d6 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -1283,23 +1283,15 @@ install_openclaw() { fix_npm_prefix_if_needed fi - if [[ "${requested}" == "latest" ]]; then - if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}"; then - log "npm install openclaw@latest failed; retrying openclaw@next" - emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"retry\",\"version\":\"next\"}" - resolved_requested="next" - if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then - resolved_requested="$(resolve_npm_openclaw_version next)" - if [[ -z "$resolved_requested" ]]; then - fail "Could not resolve OpenClaw next before compatibility checking." - fi - require_openclaw_version_compatible "$resolved_requested" - fi - env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}" - requested="next" + local installed_entry + installed_entry="$(node_dir)/lib/node_modules/openclaw/dist/entry.js" + if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}" || [[ ! -f "$installed_entry" ]]; then + log "npm install openclaw@${resolved_requested} did not produce a usable package; retrying once" + if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}" || [[ ! -f "$installed_entry" ]]; then + emit_json '{"event":"error","message":"npm install did not produce a usable OpenClaw package"}' + log "ERROR: npm install did not produce a usable OpenClaw package" + return 1 fi - else - env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}" fi mkdir -p "${PREFIX}/bin" diff --git a/scripts/install.sh b/scripts/install.sh index 92c077bd6fd5..a9a66ca52254 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2169,7 +2169,8 @@ fix_npm_permissions() { ensure_openclaw_bin_link() { local npm_root="" npm_root="$(npm root -g 2>/dev/null || true)" - if [[ -z "$npm_root" || ! -d "$npm_root/openclaw" ]]; then + local launcher="${npm_root}/openclaw/openclaw.mjs" + if [[ -z "$npm_root" || ! -x "$launcher" ]]; then return 1 fi local npm_bin="" @@ -2179,10 +2180,10 @@ ensure_openclaw_bin_link() { fi mkdir -p "$npm_bin" if [[ ! -x "${npm_bin}/openclaw" ]]; then - ln -sf "$npm_root/openclaw/dist/entry.js" "${npm_bin}/openclaw" + ln -sf "$launcher" "${npm_bin}/openclaw" ui_info "Created openclaw bin link at ${npm_bin}/openclaw" fi - return 0 + "${npm_bin}/openclaw" --version >/dev/null 2>&1 } # Check for existing OpenClaw installation @@ -3040,22 +3041,15 @@ install_openclaw() { local install_spec="" install_spec="$(resolve_package_install_spec "${package_name}" "${OPENCLAW_VERSION}")" - if ! install_openclaw_npm "${install_spec}"; then - ui_warn "npm install failed; retrying" + if ! install_openclaw_npm "${install_spec}" || ! ensure_openclaw_bin_link; then + ui_warn "npm install did not produce a usable OpenClaw package; retrying" cleanup_npm_openclaw_paths - install_openclaw_npm "${install_spec}" - fi - - if [[ "${OPENCLAW_VERSION}" == "latest" && "${package_name}" == "openclaw" ]]; then - if ! resolve_openclaw_bin &> /dev/null; then - ui_warn "npm install openclaw@latest failed; retrying openclaw@next" - cleanup_npm_openclaw_paths - install_openclaw_npm "openclaw@next" + if ! install_openclaw_npm "${install_spec}" || ! ensure_openclaw_bin_link; then + ui_error "npm install did not produce a usable OpenClaw package" + return 1 fi fi - ensure_openclaw_bin_link || true - ui_success "OpenClaw installed" } diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index d088dd7ca07a..11e4696f58e1 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -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); diff --git a/test/scripts/install-npm-fixtures.ts b/test/scripts/install-npm-fixtures.ts index 2ccd767bcee6..ba53001892d4 100644 --- a/test/scripts/install-npm-fixtures.ts +++ b/test/scripts/install-npm-fixtures.ts @@ -1,6 +1,38 @@ // NPM CLI fixture writers used by installer shell-script tests. import { chmodSync, writeFileSync } from "node:fs"; +export function writeNpmInstallRetryFixture(path: string) { + writeFileSync( + path, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'if [[ "${1:-}" == "config" ]]; then printf "null\\n"; exit 0; fi', + 'if [[ "${1:-}" == "view" ]]; then printf "2026.8.1\\n"; exit 0; fi', + 'if [[ "${1:-}" == "root" ]]; then printf "%s\\n" "${NPM_FAKE_ROOT:-}"; exit 0; fi', + "is_install=0", + 'for arg in "$@"; do [[ "$arg" == "install" ]] && is_install=1; done', + 'if [[ "$is_install" -eq 0 ]]; then exit 0; fi', + 'spec="${!#}"', + 'printf "%s\\n" "$spec" >> "$NPM_FAKE_CALLS"', + 'attempt="$(wc -l < "$NPM_FAKE_CALLS")"', + 'if [[ "$NPM_FAKE_OUTCOME" == "success" || "$NPM_FAKE_OUTCOME" == "transient" && "$attempt" -eq 2 ]]; then', + ' if [[ -n "${NPM_FAKE_PACKAGE_DIR:-}" ]]; then', + ' mkdir -p "$NPM_FAKE_PACKAGE_DIR/dist"', + ' printf "#!/bin/sh\\nprintf \'2026.8.1\\\\n\'\\n" > "$NPM_FAKE_PACKAGE_DIR/openclaw.mjs"', + ' printf "#!/usr/bin/env node\\n" > "$NPM_FAKE_PACKAGE_DIR/dist/entry.js"', + ' chmod +x "$NPM_FAKE_PACKAGE_DIR/openclaw.mjs" "$NPM_FAKE_PACKAGE_DIR/dist/entry.js"', + " fi", + " exit 0", + "fi", + 'printf "%s (attempt %s)\\n" "$NPM_FAKE_ERROR" "$attempt" >&2', + "exit 1", + "", + ].join("\n"), + ); + chmodSync(path, 0o755); +} + export function writeNpmFreshnessConflictFixture(path: string, argsLog: string) { writeFileSync( path, diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index bce15156e364..23be9bc60300 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -15,6 +15,7 @@ import { describe, expect, it } from "vitest"; import { writeNpmBeforePolicyFixture, writeNpmFreshnessConflictFixture, + writeNpmInstallRetryFixture, } from "./install-npm-fixtures.js"; const SCRIPT_PATH = "scripts/install.sh"; @@ -1343,6 +1344,222 @@ NODE expect(result.stdout).toContain("--install-method git --version main"); }); + it("links the executable package launcher when dist/entry.js is not executable", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-bin-link-")); + const bin = join(tmp, "bin"); + const packageDir = join(tmp, "lib", "node_modules", "openclaw"); + mkdirSync(join(packageDir, "dist"), { recursive: true }); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(packageDir, "dist", "entry.js"), "export {};\n"); + writeFileSync( + join(packageDir, "openclaw.mjs"), + '#!/usr/bin/env node\nprocess.stdout.write("OpenClaw fixture\\n");\n', + ); + chmodSync(join(packageDir, "openclaw.mjs"), 0o755); + + try { + const result = runInstallShell( + [ + "set -euo pipefail", + `source ${JSON.stringify(SCRIPT_PATH)}`, + `npm() { [[ "$1" == "root" ]] && printf '%s\\n' ${JSON.stringify(join(tmp, "lib", "node_modules"))}; }`, + `npm_global_bin_dir() { printf '%s\\n' ${JSON.stringify(bin)}; }`, + "ensure_openclaw_bin_link", + `${JSON.stringify(join(bin, "openclaw"))} --version`, + ].join("\n"), + ); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain("OpenClaw fixture"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("rejects an installed package whose executable launcher is missing", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-missing-bin-")); + const bin = join(tmp, "bin"); + const packageDir = join(tmp, "lib", "node_modules", "openclaw"); + mkdirSync(join(packageDir, "dist"), { recursive: true }); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(packageDir, "dist", "entry.js"), "#!/usr/bin/env node\n"); + chmodSync(join(packageDir, "dist", "entry.js"), 0o755); + + try { + const result = runInstallShell( + [ + "set -euo pipefail", + `source ${JSON.stringify(SCRIPT_PATH)}`, + `npm() { [[ "$1" == "root" ]] && printf '%s\\n' ${JSON.stringify(join(tmp, "lib", "node_modules"))}; }`, + `npm_global_bin_dir() { printf '%s\\n' ${JSON.stringify(bin)}; }`, + "ensure_openclaw_bin_link", + ].join("\n"), + ); + + expect(result.status).toBe(1); + expect(existsSync(join(bin, "openclaw"))).toBe(false); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("rejects an installed package whose launcher fails version validation", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-invalid-bin-")); + const bin = join(tmp, "bin"); + const packageDir = join(tmp, "lib", "node_modules", "openclaw"); + mkdirSync(packageDir, { recursive: true }); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(packageDir, "openclaw.mjs"), "#!/bin/sh\nexit 7\n"); + chmodSync(join(packageDir, "openclaw.mjs"), 0o755); + + try { + const result = runInstallShell( + [ + "set -euo pipefail", + `source ${JSON.stringify(SCRIPT_PATH)}`, + `npm() { [[ "$1" == "root" ]] && printf '%s\\n' ${JSON.stringify(join(tmp, "lib", "node_modules"))}; }`, + `npm_global_bin_dir() { printf '%s\\n' ${JSON.stringify(bin)}; }`, + "ensure_openclaw_bin_link", + ].join("\n"), + ); + + expect(result.status).toBe(7); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + 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: expectedStatus }) => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npm-retry-")); + const bin = join(tmp, "bin"); + const calls = join(tmp, "calls"); + const npmRoot = join(tmp, "lib", "node_modules"); + mkdirSync(bin, { recursive: true }); + writeNpmInstallRetryFixture(join(bin, "npm")); + + try { + const result = runInstallShell( + [ + "set -euo pipefail", + `source ${JSON.stringify(SCRIPT_PATH)}`, + `PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`, + `OPENCLAW_VERSION=${requested}`, + "USE_BETA=0", + "NPM_LOGLEVEL=error", + "NPM_SILENT_FLAG=", + `npm_global_bin_dir() { printf '%s\\n' ${JSON.stringify(bin)}; }`, + "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(npmRoot, "openclaw"), + NPM_FAKE_ROOT: npmRoot, + }, + ); + + expect(result.status).toBe(expectedStatus); + expect(readFileSync(calls, "utf8").trim().split("\n")).toEqual( + Array.from({ length: expectedCalls }, () => `openclaw@${requested}`), + ); + if (expectedStatus !== 0) { + expect(`${result.stdout}\n${result.stderr}`).toContain(`${error} (attempt 2)`); + } + 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-npm-empty-success-")); + const bin = join(tmp, "bin"); + const calls = join(tmp, "calls"); + const npmRoot = join(tmp, "lib", "node_modules"); + mkdirSync(bin, { recursive: true }); + writeNpmInstallRetryFixture(join(bin, "npm")); + + try { + const result = runInstallShell( + [ + "set -euo pipefail", + `source ${JSON.stringify(SCRIPT_PATH)}`, + `PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`, + "OPENCLAW_VERSION=latest", + "USE_BETA=0", + "NPM_LOGLEVEL=error", + "NPM_SILENT_FLAG=", + `npm_global_bin_dir() { printf '%s\\n' ${JSON.stringify(bin)}; }`, + "install_openclaw", + ].join("\n"), + { + NPM_FAKE_CALLS: calls, + NPM_FAKE_ERROR: "", + NPM_FAKE_OUTCOME: "success", + NPM_FAKE_ROOT: npmRoot, + }, + ); + + expect(result.status).toBe(1); + expect(readFileSync(calls, "utf8").trim().split("\n")).toEqual([ + "openclaw@latest", + "openclaw@latest", + ]); + expect(`${result.stdout}\n${result.stderr}`).toContain( + "npm install did not produce a usable OpenClaw package", + ); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("openclaw@next"); + } 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-npm-freshness-")); const bin = join(tmp, "bin");