diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7e1070603b..17c46e61206e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Tests: run `test:serial` through a Node wrapper so targeted serial Vitest commands work on native Windows. - Tests: normalize Vitest config path assertions so the infra config suite runs on native Windows paths. - Scripts: run the optional Discord native opus installer through the shared pnpm launcher and Windows CI coverage so native Windows installs avoid shell-mode package-manager shims. +- Installer: avoid the incompatible generated `--before` install filter when raw npm `min-release-age` config is present. (#85491) Thanks @TurboTheTurtle. - Agents/MCP: bound bundled MCP `tools/list` catalog discovery so hung MCP servers do not block session tool materialization. (#85063) Thanks @nxmxbbd. - Scripts: run generated-module formatting through the shared pnpm launcher and Windows CI coverage so native Windows generator checks avoid shell-mode package-manager shims. - Channels/iMessage: advance the startup catchup cursor from live-handled rows after a completed catchup pass, including rows received while catchup is still running, so restarts do not replay them. (#85475) Thanks @TurboTheTurtle. diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index e72766e0d8d2..f09ceaeec50e 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -666,6 +666,81 @@ fix_npm_prefix_if_needed() { log "Configured npm prefix to ${target}" } +expand_npm_config_path() { + local path="$1" + if [[ -z "$path" ]]; then + return 1 + fi + case "$path" in + "\${HOME}/"*) path="${HOME:-}/${path#\$\{HOME\}/}" ;; + "\$HOME/"*) path="${HOME:-}/${path#\$HOME/}" ;; + [~]/*) path="${HOME:-}/${path#\~/}" ;; + esac + printf '%s\n' "$path" +} + +npm_config_file_has_key() { + local file + file="$(expand_npm_config_path "$1")" || return 1 + local key="$2" + [[ -f "$file" ]] || return 1 + grep -E "^[[:space:]]*${key}[[:space:]]*=" "$file" >/dev/null 2>&1 +} + +npm_command_path() { + local npm_cmd="$1" + local npm_path="$npm_cmd" + if [[ "$npm_path" != */* ]]; then + npm_path="$(command -v "$npm_cmd" 2>/dev/null)" || return 1 + fi + if command -v node >/dev/null 2>&1; then + node -e 'const fs = require("node:fs"); console.log(fs.realpathSync(process.argv[1]));' "$npm_path" 2>/dev/null && return 0 + fi + printf '%s\n' "$npm_path" +} + +npm_builtin_config_path() { + local npm_cmd="$1" + local npm_path + npm_path="$(npm_command_path "$npm_cmd")" || return 1 + local npm_root + npm_root="$(cd "$(dirname "$npm_path")/.." >/dev/null 2>&1 && pwd -P)" || return 1 + printf '%s\n' "${npm_root}/npmrc" +} + +npm_raw_config_has_key() { + local key="$1" + local npm_cmd="${2:-npm}" + local user_config="${NPM_CONFIG_USERCONFIG:-${npm_config_userconfig:-}}" + local global_config="${NPM_CONFIG_GLOBALCONFIG:-${npm_config_globalconfig:-}}" + local prefix="${NPM_CONFIG_PREFIX:-${npm_config_prefix:-}}" + + npm_config_file_has_key ".npmrc" "$key" && return 0 + if [[ -n "$user_config" ]]; then + npm_config_file_has_key "$user_config" "$key" && return 0 + elif [[ -n "${HOME:-}" ]]; then + npm_config_file_has_key "${HOME}/.npmrc" "$key" && return 0 + fi + if [[ -n "$global_config" ]]; then + npm_config_file_has_key "$global_config" "$key" && return 0 + else + local resolved_global_config="" + resolved_global_config="$(env -u NPM_CONFIG_BEFORE -u npm_config_before "$npm_cmd" config get globalconfig 2>/dev/null || true)" + if [[ -n "$resolved_global_config" && "$resolved_global_config" != "null" && "$resolved_global_config" != "undefined" ]]; then + npm_config_file_has_key "$resolved_global_config" "$key" && return 0 + fi + fi + if [[ -n "$prefix" ]]; then + npm_config_file_has_key "${prefix}/etc/npmrc" "$key" && return 0 + fi + local builtin_config="" + builtin_config="$(npm_builtin_config_path "$npm_cmd" 2>/dev/null || true)" + if [[ -n "$builtin_config" ]]; then + npm_config_file_has_key "$builtin_config" "$key" && return 0 + fi + return 1 +} + install_openclaw() { local requested="${OPENCLAW_VERSION:-latest}" if is_openclaw_source_package_install_spec "$requested"; then @@ -674,7 +749,7 @@ install_openclaw() { local freshness_flag="--min-release-age=0" local min_release_age="" min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before "$(npm_bin)" config get min-release-age 2>/dev/null || true)" - if [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then + if ! npm_raw_config_has_key "min-release-age" "$(npm_bin)" && [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then local before_value="" before_value="$(env -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" config get before 2>/dev/null || true)" if [[ -n "$before_value" && "$before_value" != "null" && "$before_value" != "undefined" ]]; then diff --git a/scripts/install.sh b/scripts/install.sh index b7fbbe47155e..878d1fb82126 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -744,6 +744,81 @@ auto_install_build_tools_for_npm_failure() { return 0 } +expand_npm_config_path() { + local path="$1" + if [[ -z "$path" ]]; then + return 1 + fi + case "$path" in + "\${HOME}/"*) path="${HOME:-}/${path#\$\{HOME\}/}" ;; + "\$HOME/"*) path="${HOME:-}/${path#\$HOME/}" ;; + [~]/*) path="${HOME:-}/${path#\~/}" ;; + esac + printf '%s\n' "$path" +} + +npm_config_file_has_key() { + local file + file="$(expand_npm_config_path "$1")" || return 1 + local key="$2" + [[ -f "$file" ]] || return 1 + grep -E "^[[:space:]]*${key}[[:space:]]*=" "$file" >/dev/null 2>&1 +} + +npm_command_path() { + local npm_cmd="$1" + local npm_path="$npm_cmd" + if [[ "$npm_path" != */* ]]; then + npm_path="$(command -v "$npm_cmd" 2>/dev/null)" || return 1 + fi + if command -v node >/dev/null 2>&1; then + node -e 'const fs = require("node:fs"); console.log(fs.realpathSync(process.argv[1]));' "$npm_path" 2>/dev/null && return 0 + fi + printf '%s\n' "$npm_path" +} + +npm_builtin_config_path() { + local npm_cmd="$1" + local npm_path + npm_path="$(npm_command_path "$npm_cmd")" || return 1 + local npm_root + npm_root="$(cd "$(dirname "$npm_path")/.." >/dev/null 2>&1 && pwd -P)" || return 1 + printf '%s\n' "${npm_root}/npmrc" +} + +npm_raw_config_has_key() { + local key="$1" + local npm_cmd="${2:-npm}" + local user_config="${NPM_CONFIG_USERCONFIG:-${npm_config_userconfig:-}}" + local global_config="${NPM_CONFIG_GLOBALCONFIG:-${npm_config_globalconfig:-}}" + local prefix="${NPM_CONFIG_PREFIX:-${npm_config_prefix:-}}" + + npm_config_file_has_key ".npmrc" "$key" && return 0 + if [[ -n "$user_config" ]]; then + npm_config_file_has_key "$user_config" "$key" && return 0 + elif [[ -n "${HOME:-}" ]]; then + npm_config_file_has_key "${HOME}/.npmrc" "$key" && return 0 + fi + if [[ -n "$global_config" ]]; then + npm_config_file_has_key "$global_config" "$key" && return 0 + else + local resolved_global_config="" + resolved_global_config="$(env -u NPM_CONFIG_BEFORE -u npm_config_before "$npm_cmd" config get globalconfig 2>/dev/null || true)" + if [[ -n "$resolved_global_config" && "$resolved_global_config" != "null" && "$resolved_global_config" != "undefined" ]]; then + npm_config_file_has_key "$resolved_global_config" "$key" && return 0 + fi + fi + if [[ -n "$prefix" ]]; then + npm_config_file_has_key "${prefix}/etc/npmrc" "$key" && return 0 + fi + local builtin_config="" + builtin_config="$(npm_builtin_config_path "$npm_cmd" 2>/dev/null || true)" + if [[ -n "$builtin_config" ]]; then + npm_config_file_has_key "$builtin_config" "$key" && return 0 + fi + return 1 +} + run_npm_global_install() { local spec="$1" local log="$2" @@ -751,7 +826,7 @@ run_npm_global_install() { local freshness_flag="--min-release-age=0" local min_release_age="" min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before npm config get min-release-age 2>/dev/null || true)" - if [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then + if ! npm_raw_config_has_key "min-release-age" "npm" && [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then local before_value="" before_value="$(env -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age npm config get before 2>/dev/null || true)" if [[ -n "$before_value" && "$before_value" != "null" && "$before_value" != "undefined" ]]; then diff --git a/src/agents/model-catalog-visibility.test.ts b/src/agents/model-catalog-visibility.test.ts index 6d21a9edc8f4..9034f87e4dd3 100644 --- a/src/agents/model-catalog-visibility.test.ts +++ b/src/agents/model-catalog-visibility.test.ts @@ -5,7 +5,7 @@ import type { ModelCatalogEntry } from "./model-catalog.types.js"; describe("resolveVisibleModelCatalog", () => { it("can use static auth checks for gateway read-only model lists", async () => { - const authChecker = vi.fn(async (provider: string) => provider === "openai"); + const authChecker = vi.fn((provider: string) => provider === "openai"); const catalog: ModelCatalogEntry[] = [ { provider: "anthropic", id: "claude-test", name: "Claude Test" }, { provider: "openai", id: "gpt-test", name: "GPT Test" }, @@ -27,7 +27,7 @@ describe("resolveVisibleModelCatalog", () => { }); it("limits visible catalog to provider wildcard entries after default discovery", async () => { - const authChecker = vi.fn(async (provider: string) => provider !== "blocked"); + const authChecker = vi.fn((provider: string) => provider !== "blocked"); const catalog: ModelCatalogEntry[] = [ { provider: "anthropic", id: "claude-test", name: "Claude Test" }, { provider: "openai-codex", id: "gpt-codex-test", name: "GPT Codex Test" }, @@ -67,7 +67,7 @@ describe("resolveVisibleModelCatalog", () => { }); it("does not broaden visibility when selected providers have no catalog rows", async () => { - const authChecker = vi.fn(async () => true); + const authChecker = vi.fn(() => true); const cfg = { agents: { diff --git a/src/agents/model-catalog-visibility.ts b/src/agents/model-catalog-visibility.ts index 29021193a7e3..caf449134f76 100644 --- a/src/agents/model-catalog-visibility.ts +++ b/src/agents/model-catalog-visibility.ts @@ -5,6 +5,7 @@ import { buildConfiguredModelCatalog, modelKey } from "./model-selection.js"; import { createModelVisibilityPolicy } from "./model-visibility-policy.js"; type ModelCatalogVisibilityView = "default" | "configured" | "all"; +type ProviderAuthChecker = (provider: string) => boolean | Promise; function sortModelCatalogEntries(entries: ModelCatalogEntry[]): ModelCatalogEntry[] { return entries.toSorted( @@ -36,7 +37,7 @@ export async function resolveVisibleModelCatalog(params: { env?: NodeJS.ProcessEnv; view?: ModelCatalogVisibilityView; runtimeAuthDiscovery?: boolean; - providerAuthChecker?: (provider: string) => Promise; + providerAuthChecker?: ProviderAuthChecker; }): Promise { if (params.view === "all") { return params.catalog; diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index 5bfff10a9a80..dc1f25a387c8 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -127,10 +127,229 @@ describe("install-cli.sh", () => { it("clears npm freshness filters for package installs", () => { expect(script).toContain('freshness_flag="--min-release-age=0"'); + expect(script).toContain('npm_raw_config_has_key "min-release-age"'); expect(script).toContain('freshness_flag="--before=$(date -u'); expect(script).toContain("env -u NPM_CONFIG_BEFORE -u npm_config_before"); }); + it("does not emit --before when raw user npmrc config contains min-release-age", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-npmrc-")); + const bin = join(tmp, "bin"); + const npmrc = join(tmp, "user.npmrc"); + const installArgs = join(tmp, "npm-install-args.txt"); + const prefix = join(tmp, "prefix"); + const nodeDir = join(tmp, "node"); + mkdirSync(bin, { recursive: true }); + mkdirSync(nodeDir, { recursive: true }); + writeFileSync(npmrc, "min-release-age=7\n"); + const fakeNpm = join(bin, "npm"); + writeFileSync( + fakeNpm, + [ + "#!/usr/bin/env bash", + 'if [[ "$1" == "config" && "$2" == "get" ]]; then', + ' if [[ "$3" == "min-release-age" ]]; then', + " printf 'null\\n'", + " exit 0", + " fi", + ' if [[ "$3" == "before" ]]; then', + " printf '2026-01-01T00:00:00.000Z\\n'", + " exit 0", + " fi", + "fi", + 'printf "%s\\n" "$@" > "$NPM_FAKE_INSTALL_ARGS"', + "exit 0", + "", + ].join("\n"), + ); + chmodSync(fakeNpm, 0o755); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + `npm_bin() { printf '%s\\n' ${JSON.stringify(fakeNpm)}; }`, + `node_dir() { printf '%s\\n' ${JSON.stringify(nodeDir)}; }`, + "emit_json() { :; }", + "log() { :; }", + `PREFIX=${JSON.stringify(prefix)}`, + "SET_NPM_PREFIX=0", + "OPENCLAW_VERSION=1.2.3", + "install_openclaw", + ].join("\n"), + { + NPM_CONFIG_USERCONFIG: npmrc, + NPM_FAKE_INSTALL_ARGS: installArgs, + PATH: `${bin}:${process.env.PATH}`, + }, + ); + + expect(result.status).toBe(0); + expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n"); + expect(readFileSync(installArgs, "utf8")).not.toContain("--before="); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("does not emit --before when default global npmrc config contains min-release-age", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-global-npmrc-")); + const bin = join(tmp, "bin"); + const home = join(tmp, "home"); + const prefix = join(tmp, "prefix"); + const npmrc = join(prefix, "etc", "npmrc"); + const calls = join(tmp, "npm-calls.txt"); + const installArgs = join(tmp, "npm-install-args.txt"); + const installPrefix = join(tmp, "install-prefix"); + const nodeDir = join(tmp, "node"); + mkdirSync(bin, { recursive: true }); + mkdirSync(home, { recursive: true }); + mkdirSync(nodeDir, { recursive: true }); + mkdirSync(join(prefix, "etc"), { recursive: true }); + writeFileSync(npmrc, "min-release-age=7\n"); + const fakeNpm = join(bin, "npm"); + writeFileSync( + fakeNpm, + [ + "#!/usr/bin/env bash", + 'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"', + 'if [[ "$1" == "config" && "$2" == "get" ]]; then', + ' if [[ "$3" == "min-release-age" ]]; then', + " printf 'null\\n'", + " exit 0", + " fi", + ' if [[ "$3" == "globalconfig" ]]; then', + ' printf "%s\\n" "$NPM_FAKE_GLOBALCONFIG"', + " exit 0", + " fi", + ' if [[ "$3" == "before" ]]; then', + " printf '2026-01-01T00:00:00.000Z\\n'", + " exit 0", + " fi", + "fi", + 'printf "%s\\n" "$@" > "$NPM_FAKE_INSTALL_ARGS"', + "exit 0", + "", + ].join("\n"), + ); + chmodSync(fakeNpm, 0o755); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + `npm_bin() { printf '%s\\n' ${JSON.stringify(fakeNpm)}; }`, + `node_dir() { printf '%s\\n' ${JSON.stringify(nodeDir)}; }`, + "emit_json() { :; }", + "log() { :; }", + `PREFIX=${JSON.stringify(installPrefix)}`, + "SET_NPM_PREFIX=0", + "OPENCLAW_VERSION=1.2.3", + "install_openclaw", + ].join("\n"), + { + HOME: home, + NPM_CONFIG_GLOBALCONFIG: undefined, + NPM_CONFIG_PREFIX: undefined, + npm_config_globalconfig: undefined, + npm_config_prefix: undefined, + NPM_FAKE_CALLS: calls, + NPM_FAKE_GLOBALCONFIG: npmrc, + NPM_FAKE_INSTALL_ARGS: installArgs, + PATH: `${bin}:${process.env.PATH}`, + }, + ); + + expect(result.status).toBe(0); + expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n"); + expect(readFileSync(installArgs, "utf8")).not.toContain("--before="); + expect(readFileSync(calls, "utf8")).not.toContain("config get before"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("does not emit --before when builtin npmrc config contains min-release-age", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-builtin-npmrc-")); + const bin = join(tmp, "bin"); + const home = join(tmp, "home"); + const npmrc = join(tmp, "npmrc"); + const calls = join(tmp, "npm-calls.txt"); + const installArgs = join(tmp, "npm-install-args.txt"); + const installPrefix = join(tmp, "install-prefix"); + const nodeDir = join(tmp, "node"); + mkdirSync(bin, { recursive: true }); + mkdirSync(home, { recursive: true }); + mkdirSync(nodeDir, { recursive: true }); + writeFileSync(npmrc, "min-release-age=7\n"); + const fakeNpm = join(bin, "npm"); + writeFileSync( + fakeNpm, + [ + "#!/usr/bin/env bash", + 'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"', + 'if [[ "$1" == "config" && "$2" == "get" ]]; then', + ' if [[ "$3" == "min-release-age" ]]; then', + " printf 'null\\n'", + " exit 0", + " fi", + ' if [[ "$3" == "globalconfig" ]]; then', + ' printf "%s\\n" "$NPM_FAKE_GLOBALCONFIG"', + " exit 0", + " fi", + ' if [[ "$3" == "before" ]]; then', + " printf '2026-01-01T00:00:00.000Z\\n'", + " exit 0", + " fi", + "fi", + 'printf "%s\\n" "$@" > "$NPM_FAKE_INSTALL_ARGS"', + "exit 0", + "", + ].join("\n"), + ); + chmodSync(fakeNpm, 0o755); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + `npm_bin() { printf '%s\\n' ${JSON.stringify(fakeNpm)}; }`, + `node_dir() { printf '%s\\n' ${JSON.stringify(nodeDir)}; }`, + "emit_json() { :; }", + "log() { :; }", + `PREFIX=${JSON.stringify(installPrefix)}`, + "SET_NPM_PREFIX=0", + "OPENCLAW_VERSION=1.2.3", + "install_openclaw", + ].join("\n"), + { + HOME: home, + NPM_CONFIG_GLOBALCONFIG: undefined, + NPM_CONFIG_PREFIX: undefined, + npm_config_globalconfig: undefined, + npm_config_prefix: undefined, + NPM_FAKE_CALLS: calls, + NPM_FAKE_GLOBALCONFIG: join(tmp, "missing-global-npmrc"), + NPM_FAKE_INSTALL_ARGS: installArgs, + PATH: `${bin}:${process.env.PATH}`, + }, + ); + + expect(result.status).toBe(0); + expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n"); + expect(readFileSync(installArgs, "utf8")).not.toContain("--before="); + expect(readFileSync(calls, "utf8")).not.toContain("config get before"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + it("rejects OpenClaw GitHub source targets for npm installs", () => { const result = runInstallCliShell(` set -euo pipefail diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index c70417ae1826..27aa5c1abee6 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -82,10 +82,212 @@ describe("install.sh", () => { it("clears npm freshness filters for package installs", () => { expect(script).toContain("env -u NPM_CONFIG_BEFORE -u npm_config_before"); expect(script).toContain('freshness_flag="--min-release-age=0"'); + expect(script).toContain('npm_raw_config_has_key "min-release-age"'); expect(script).toContain('freshness_flag="--before=$(date -u'); expect(script).toContain('cmd+=(--no-fund --no-audit "$freshness_flag" install -g "$spec")'); }); + it("does not emit --before when raw user npmrc config contains min-release-age", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-npmrc-")); + const bin = join(tmp, "bin"); + const npmrc = join(tmp, "user.npmrc"); + const calls = join(tmp, "npm-calls.txt"); + const installArgs = join(tmp, "npm-install-args.txt"); + mkdirSync(bin, { recursive: true }); + writeFileSync(npmrc, "min-release-age=7\n"); + const fakeNpm = join(bin, "npm"); + writeFileSync( + fakeNpm, + [ + "#!/usr/bin/env bash", + 'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"', + 'if [[ "$1" == "config" && "$2" == "get" ]]; then', + ' if [[ "$3" == "min-release-age" ]]; then', + " printf 'null\\n'", + " exit 0", + " fi", + ' if [[ "$3" == "before" ]]; then', + " printf '2026-01-01T00:00:00.000Z\\n'", + " exit 0", + " fi", + "fi", + 'printf "%s\\n" "$@" > "$NPM_FAKE_INSTALL_ARGS"', + "exit 0", + "", + ].join("\n"), + ); + chmodSync(fakeNpm, 0o755); + + try { + const result = runInstallShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + `run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`, + 'printf "cmd=%s\\n" "$LAST_NPM_INSTALL_CMD"', + ].join("\n"), + { + NPM_CONFIG_USERCONFIG: npmrc, + NPM_FAKE_CALLS: calls, + NPM_FAKE_INSTALL_ARGS: installArgs, + PATH: `${bin}:${process.env.PATH}`, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("--min-release-age=0"); + expect(result.stdout).not.toContain("--before="); + expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n"); + expect(readFileSync(installArgs, "utf8")).not.toContain("--before="); + expect(readFileSync(calls, "utf8")).not.toContain("config get before"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("does not emit --before when default global npmrc config contains min-release-age", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-global-npmrc-")); + const bin = join(tmp, "bin"); + const home = join(tmp, "home"); + const prefix = join(tmp, "prefix"); + const npmrc = join(prefix, "etc", "npmrc"); + const calls = join(tmp, "npm-calls.txt"); + const installArgs = join(tmp, "npm-install-args.txt"); + mkdirSync(bin, { recursive: true }); + mkdirSync(home, { recursive: true }); + mkdirSync(join(prefix, "etc"), { recursive: true }); + writeFileSync(npmrc, "min-release-age=7\n"); + const fakeNpm = join(bin, "npm"); + writeFileSync( + fakeNpm, + [ + "#!/usr/bin/env bash", + 'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"', + 'if [[ "$1" == "config" && "$2" == "get" ]]; then', + ' if [[ "$3" == "min-release-age" ]]; then', + " printf 'null\\n'", + " exit 0", + " fi", + ' if [[ "$3" == "globalconfig" ]]; then', + ' printf "%s\\n" "$NPM_FAKE_GLOBALCONFIG"', + " exit 0", + " fi", + ' if [[ "$3" == "before" ]]; then', + " printf '2026-01-01T00:00:00.000Z\\n'", + " exit 0", + " fi", + "fi", + 'printf "%s\\n" "$@" > "$NPM_FAKE_INSTALL_ARGS"', + "exit 0", + "", + ].join("\n"), + ); + chmodSync(fakeNpm, 0o755); + + try { + const result = runInstallShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + `run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`, + 'printf "cmd=%s\\n" "$LAST_NPM_INSTALL_CMD"', + ].join("\n"), + { + HOME: home, + NPM_CONFIG_GLOBALCONFIG: undefined, + NPM_CONFIG_PREFIX: undefined, + npm_config_globalconfig: undefined, + npm_config_prefix: undefined, + NPM_FAKE_CALLS: calls, + NPM_FAKE_GLOBALCONFIG: npmrc, + NPM_FAKE_INSTALL_ARGS: installArgs, + PATH: `${bin}:${process.env.PATH}`, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("--min-release-age=0"); + expect(result.stdout).not.toContain("--before="); + expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n"); + expect(readFileSync(installArgs, "utf8")).not.toContain("--before="); + expect(readFileSync(calls, "utf8")).not.toContain("config get before"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("does not emit --before when builtin npmrc config contains min-release-age", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-builtin-npmrc-")); + const bin = join(tmp, "bin"); + const home = join(tmp, "home"); + const npmrc = join(tmp, "npmrc"); + const calls = join(tmp, "npm-calls.txt"); + const installArgs = join(tmp, "npm-install-args.txt"); + mkdirSync(bin, { recursive: true }); + mkdirSync(home, { recursive: true }); + writeFileSync(npmrc, "min-release-age=7\n"); + const fakeNpm = join(bin, "npm"); + writeFileSync( + fakeNpm, + [ + "#!/usr/bin/env bash", + 'printf "%s\\n" "$*" >> "$NPM_FAKE_CALLS"', + 'if [[ "$1" == "config" && "$2" == "get" ]]; then', + ' if [[ "$3" == "min-release-age" ]]; then', + " printf 'null\\n'", + " exit 0", + " fi", + ' if [[ "$3" == "globalconfig" ]]; then', + ' printf "%s\\n" "$NPM_FAKE_GLOBALCONFIG"', + " exit 0", + " fi", + ' if [[ "$3" == "before" ]]; then', + " printf '2026-01-01T00:00:00.000Z\\n'", + " exit 0", + " fi", + "fi", + 'printf "%s\\n" "$@" > "$NPM_FAKE_INSTALL_ARGS"', + "exit 0", + "", + ].join("\n"), + ); + chmodSync(fakeNpm, 0o755); + + try { + const result = runInstallShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + `run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`, + 'printf "cmd=%s\\n" "$LAST_NPM_INSTALL_CMD"', + ].join("\n"), + { + HOME: home, + NPM_CONFIG_GLOBALCONFIG: undefined, + NPM_CONFIG_PREFIX: undefined, + npm_config_globalconfig: undefined, + npm_config_prefix: undefined, + NPM_FAKE_CALLS: calls, + NPM_FAKE_GLOBALCONFIG: join(tmp, "missing-global-npmrc"), + NPM_FAKE_INSTALL_ARGS: installArgs, + PATH: `${bin}:${process.env.PATH}`, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("--min-release-age=0"); + expect(result.stdout).not.toContain("--before="); + expect(readFileSync(installArgs, "utf8")).toContain("--min-release-age=0\n"); + expect(readFileSync(installArgs, "utf8")).not.toContain("--before="); + expect(readFileSync(calls, "utf8")).not.toContain("config get before"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + it("uses OPENCLAW_HOME for git and onboarding defaults", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-home-")); const osHome = join(tmp, "os-home");