fix: handle npm min-release-age in installers

Replays #84749 because the contributor fork branch became conflicted and was no longer maintainer-writable.

Co-authored-by: TeodoroRodrigo <rodrigoteodoro.90@gmail.com>
This commit is contained in:
Peter Steinberger
2026-05-25 08:13:47 +01:00
committed by GitHub
parent 6704d0ab27
commit 316d97c938
8 changed files with 526 additions and 156 deletions
+1
View File
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
- Build: include `ui:build` in the `full` and `ciArtifacts` profiles of `scripts/build-all.mjs` so `pnpm build` always rebuilds `dist/control-ui` after `tsdown` cleans `dist`, removing the second-command requirement and the missing-asset failure mode for source/runtime installs and CI artifact uploads. (#85206)
### Fixes
- Install/update: bypass npm `min-release-age` policies with `--min-release-age=0` instead of `--before` so hosted installers keep working on npm versions that reject the combined config. (#84749) Thanks @TeodoroRodrigo.
- WebChat: keep message-tool replies visible in the chat while still summarizing internal tool results for the model. Fixes #86347. Thanks @shakkernerd.
- Agents/commitments: serialize commitment store load-modify-save writes so concurrent heartbeat and CLI updates no longer lose dismissal, sent, or attempt state. (#81153) Thanks @ai-hpc.
- CLI: suppress benign self-update version-skew warnings during package post-update finalization.
+48 -41
View File
@@ -857,25 +857,27 @@ fix_npm_prefix_if_needed() {
log "Configured npm prefix to ${target}"
}
expand_npm_config_path() {
local path="$1"
if [[ -z "$path" ]]; then
resolve_npm_config_path() {
local raw="$1"
if [[ -z "$raw" || "$raw" == "null" || "$raw" == "undefined" ]]; 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"
if [[ "$raw" == \~/* && -n "${HOME:-}" ]]; then
printf '%s\n' "${HOME}/${raw#"~/"}"
return 0
fi
if [[ "$raw" == "\${HOME}/"* && -n "${HOME:-}" ]]; then
printf '%s\n' "${HOME}/${raw#"\${HOME}/"}"
return 0
fi
printf '%s\n' "$raw"
}
npm_config_file_has_key() {
local file
file="$(expand_npm_config_path "$1")" || return 1
local file="$1"
local key="$2"
[[ -f "$file" ]] || return 1
grep -E "^[[:space:]]*${key}[[:space:]]*=" "$file" >/dev/null 2>&1
grep -Eiq "^[[:space:]]*${key}[[:space:]]*=" "$file"
}
npm_command_path() {
@@ -899,36 +901,39 @@ npm_builtin_config_path() {
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_has_raw_key() {
local npm_cmd="$1"
local key="$2"
local raw=""
local file=""
local -a files=()
npm_config_file_has_key ".npmrc" "$key" && return 0
if [[ -n "$user_config" ]]; then
npm_config_file_has_key "$user_config" "$key" && return 0
raw="${NPM_CONFIG_USERCONFIG:-${npm_config_userconfig:-}}"
if [[ -n "$raw" ]]; then
file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)"
[[ -n "$file" ]] && files+=("$file")
elif [[ -n "${HOME:-}" ]]; then
npm_config_file_has_key "${HOME}/.npmrc" "$key" && return 0
files+=("${HOME}/.npmrc")
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
raw="${NPM_CONFIG_GLOBALCONFIG:-${npm_config_globalconfig:-}}"
if [[ -n "$raw" ]]; then
file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)"
[[ -n "$file" ]] && files+=("$file")
fi
raw="$(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_cmd" config get globalconfig --global 2>/dev/null || true)"
file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)"
[[ -n "$file" ]] && files+=("$file")
file="$(npm_builtin_config_path "$npm_cmd" 2>/dev/null || true)"
[[ -n "$file" ]] && files+=("$file")
for file in "${files[@]}"; do
if npm_config_file_has_key "$file" "$key"; then
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
done
return 1
}
@@ -939,10 +944,12 @@ install_openclaw() {
fi
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 ! npm_raw_config_has_key "min-release-age" "$(npm_bin)" && [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then
min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before "$(npm_bin)" config get min-release-age --global 2>/dev/null || true)"
if npm_config_has_raw_key "$(npm_bin)" "min-release-age"; then
freshness_flag="--min-release-age=0"
elif [[ -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)"
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 --global 2>/dev/null || true)"
if [[ -n "$before_value" && "$before_value" != "null" && "$before_value" != "undefined" ]]; then
freshness_flag="--before=$(date -u '+%Y-%m-%dT%H:%M:%S.000Z')"
fi
+63 -3
View File
@@ -930,6 +930,63 @@ function Test-OpenClawSourcePackageInstallSpec {
)
}
function Resolve-NpmConfigPath {
param([string]$RawPath)
if ([string]::IsNullOrWhiteSpace($RawPath) -or $RawPath -eq "null" -or $RawPath -eq "undefined") {
return $null
}
if (($RawPath.StartsWith("~/") -or $RawPath.StartsWith("~\")) -and -not [string]::IsNullOrWhiteSpace($HOME)) {
return (Join-Path $HOME $RawPath.Substring(2))
}
if (($RawPath.StartsWith('${HOME}/') -or $RawPath.StartsWith('${HOME}\')) -and -not [string]::IsNullOrWhiteSpace($HOME)) {
return (Join-Path $HOME $RawPath.Substring(8))
}
return $RawPath
}
function Test-NpmConfigFileKey {
param(
[string]$Path,
[string]$Key
)
if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) {
return $false
}
$escapedKey = [regex]::Escape($Key)
return [bool](Select-String -LiteralPath $Path -Pattern "^\s*$escapedKey\s*=" -Quiet)
}
function Test-NpmConfigRawKey {
param([string]$Key)
$files = New-Object System.Collections.Generic.List[string]
$userConfig = if ($env:NPM_CONFIG_USERCONFIG) { $env:NPM_CONFIG_USERCONFIG } else { $env:npm_config_userconfig }
if ($userConfig) {
$resolvedUserConfig = Resolve-NpmConfigPath $userConfig
if ($resolvedUserConfig) { $files.Add($resolvedUserConfig) }
} elseif (-not [string]::IsNullOrWhiteSpace($HOME)) {
$files.Add((Join-Path $HOME ".npmrc"))
}
$globalConfig = if ($env:NPM_CONFIG_GLOBALCONFIG) { $env:NPM_CONFIG_GLOBALCONFIG } else { $env:npm_config_globalconfig }
if ($globalConfig) {
$resolvedGlobalConfig = Resolve-NpmConfigPath $globalConfig
if ($resolvedGlobalConfig) { $files.Add($resolvedGlobalConfig) }
}
$detectedGlobalConfig = (Invoke-NpmCommand -Arguments @("config", "get", "globalconfig", "--global") 2>$null)
if ($LASTEXITCODE -eq 0) {
$resolvedDetectedGlobalConfig = Resolve-NpmConfigPath $detectedGlobalConfig
if ($resolvedDetectedGlobalConfig) { $files.Add($resolvedDetectedGlobalConfig) }
}
foreach ($file in ($files | Select-Object -Unique)) {
if (Test-NpmConfigFileKey -Path $file -Key $Key) {
return $true
}
}
return $false
}
function Install-OpenClaw {
if ([string]::IsNullOrWhiteSpace($Tag)) {
$Tag = "latest"
@@ -951,9 +1008,12 @@ function Install-OpenClaw {
$installSpec = Resolve-NpmOpenClawInstallSpec -PackageName $packageName -RequestedTag $Tag
Write-Host "[*] Installing OpenClaw ($installSpec)..." -ForegroundColor Yellow
$freshnessArgs = @("--min-release-age=0")
$minReleaseAge = (Invoke-NpmCommand -Arguments @("config", "get", "min-release-age") 2>$null)
if ($LASTEXITCODE -ne 0 -or -not $minReleaseAge -or $minReleaseAge.Trim() -eq "null" -or $minReleaseAge.Trim() -eq "undefined") {
$beforeValue = (Invoke-NpmCommand -Arguments @("config", "get", "before") 2>$null)
$minReleaseAge = (Invoke-NpmCommand -Arguments @("config", "get", "min-release-age", "--global") 2>$null)
$minReleaseAgeStatus = $LASTEXITCODE
if (Test-NpmConfigRawKey -Key "min-release-age") {
$freshnessArgs = @("--min-release-age=0")
} elseif ($minReleaseAgeStatus -ne 0 -or -not $minReleaseAge -or $minReleaseAge.Trim() -eq "null" -or $minReleaseAge.Trim() -eq "undefined") {
$beforeValue = (Invoke-NpmCommand -Arguments @("config", "get", "before", "--global") 2>$null)
if ($LASTEXITCODE -eq 0 -and $beforeValue -and $beforeValue.Trim() -ne "null" -and $beforeValue.Trim() -ne "undefined") {
$freshnessArgs = @("--before=$((Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ"))")
}
+48 -41
View File
@@ -744,25 +744,27 @@ auto_install_build_tools_for_npm_failure() {
return 0
}
expand_npm_config_path() {
local path="$1"
if [[ -z "$path" ]]; then
resolve_npm_config_path() {
local raw="$1"
if [[ -z "$raw" || "$raw" == "null" || "$raw" == "undefined" ]]; 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"
if [[ "$raw" == \~/* && -n "${HOME:-}" ]]; then
printf '%s\n' "${HOME}/${raw#"~/"}"
return 0
fi
if [[ "$raw" == "\${HOME}/"* && -n "${HOME:-}" ]]; then
printf '%s\n' "${HOME}/${raw#"\${HOME}/"}"
return 0
fi
printf '%s\n' "$raw"
}
npm_config_file_has_key() {
local file
file="$(expand_npm_config_path "$1")" || return 1
local file="$1"
local key="$2"
[[ -f "$file" ]] || return 1
grep -E "^[[:space:]]*${key}[[:space:]]*=" "$file" >/dev/null 2>&1
grep -Eiq "^[[:space:]]*${key}[[:space:]]*=" "$file"
}
npm_command_path() {
@@ -786,36 +788,39 @@ npm_builtin_config_path() {
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_has_raw_key() {
local npm_cmd="$1"
local key="$2"
local raw=""
local file=""
local -a files=()
npm_config_file_has_key ".npmrc" "$key" && return 0
if [[ -n "$user_config" ]]; then
npm_config_file_has_key "$user_config" "$key" && return 0
raw="${NPM_CONFIG_USERCONFIG:-${npm_config_userconfig:-}}"
if [[ -n "$raw" ]]; then
file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)"
[[ -n "$file" ]] && files+=("$file")
elif [[ -n "${HOME:-}" ]]; then
npm_config_file_has_key "${HOME}/.npmrc" "$key" && return 0
files+=("${HOME}/.npmrc")
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
raw="${NPM_CONFIG_GLOBALCONFIG:-${npm_config_globalconfig:-}}"
if [[ -n "$raw" ]]; then
file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)"
[[ -n "$file" ]] && files+=("$file")
fi
raw="$(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_cmd" config get globalconfig --global 2>/dev/null || true)"
file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)"
[[ -n "$file" ]] && files+=("$file")
file="$(npm_builtin_config_path "$npm_cmd" 2>/dev/null || true)"
[[ -n "$file" ]] && files+=("$file")
for file in "${files[@]}"; do
if npm_config_file_has_key "$file" "$key"; then
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
done
return 1
}
@@ -825,10 +830,12 @@ 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 ! npm_raw_config_has_key "min-release-age" "npm" && [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then
min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before npm config get min-release-age --global 2>/dev/null || true)"
if npm_config_has_raw_key npm "min-release-age"; then
freshness_flag="--min-release-age=0"
elif [[ -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)"
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 --global 2>/dev/null || true)"
if [[ -n "$before_value" && "$before_value" != "null" && "$before_value" != "undefined" ]]; then
freshness_flag="--before=$(date -u '+%Y-%m-%dT%H:%M:%S.000Z')"
fi
+31 -4
View File
@@ -41,6 +41,17 @@ function expectUnsetOrZeroNpmJsonConfig(value: unknown): void {
expect(value == null || value === false || value === 0 || value === "0").toBe(true);
}
function createIsolatedNpmConfigEnv(dir: string): NodeJS.ProcessEnv {
const home = path.join(dir, "home");
const globalconfig = path.join(dir, "global-npmrc");
fsSync.mkdirSync(home, { recursive: true });
fsSync.writeFileSync(globalconfig, "", "utf-8");
return {
HOME: home,
NPM_CONFIG_GLOBALCONFIG: globalconfig,
};
}
describe("npm project install env", () => {
it("uses an absolute POSIX script shell for npm lifecycle scripts", () => {
withMockedPlatform("linux", () => {
@@ -163,10 +174,12 @@ describe("npm project install env", () => {
it("uses a current before override for explicit npm before policy", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-"));
try {
const baseEnv = createIsolatedNpmConfigEnv(dir);
const npmrc = path.join(dir, "npmrc");
fsSync.writeFileSync(npmrc, "before=2026-01-01T00:00:00.000Z\n", "utf-8");
const env = createNpmProjectInstallEnv(
{
...baseEnv,
NPM_CONFIG_USERCONFIG: npmrc,
},
{},
@@ -180,6 +193,7 @@ describe("npm project install env", () => {
const envWithParentAge = createNpmProjectInstallEnv(
{
...baseEnv,
NPM_CONFIG_USERCONFIG: npmrc,
NPM_CONFIG_MIN_RELEASE_AGE: "7",
},
@@ -200,12 +214,14 @@ describe("npm project install env", () => {
it("uses before args for stale npm before policies", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-"));
try {
const baseEnv = createIsolatedNpmConfigEnv(dir);
const npmrc = path.join(dir, "npmrc");
fsSync.writeFileSync(npmrc, "before=2026-01-01T00:00:00.000Z\n", "utf-8");
expect(
createNpmFreshnessBypassArgs(
{
...baseEnv,
NPM_CONFIG_USERCONFIG: npmrc,
},
FROZEN_NOW,
@@ -219,11 +235,13 @@ describe("npm project install env", () => {
it("uses before args for expanded npm userconfig paths", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-home-npmrc-"));
try {
const baseEnv = createIsolatedNpmConfigEnv(dir);
fsSync.writeFileSync(path.join(dir, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n", "utf-8");
expect(
createNpmFreshnessBypassArgs(
{
...baseEnv,
HOME: dir,
NPM_CONFIG_USERCONFIG: "~/.npmrc",
},
@@ -233,6 +251,7 @@ describe("npm project install env", () => {
expect(
createNpmFreshnessBypassArgs(
{
...baseEnv,
HOME: dir,
NPM_CONFIG_USERCONFIG: "${HOME}/.npmrc",
},
@@ -247,7 +266,9 @@ describe("npm project install env", () => {
it("uses before args for npm default globalconfig before policies", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npm-prefix-"));
try {
const home = path.join(dir, "home");
const npmrcDir = path.join(dir, "etc");
fsSync.mkdirSync(home, { recursive: true });
fsSync.mkdirSync(npmrcDir, { recursive: true });
fsSync.writeFileSync(
path.join(npmrcDir, "npmrc"),
@@ -258,6 +279,7 @@ describe("npm project install env", () => {
expect(
createNpmFreshnessBypassArgs(
{
HOME: home,
NPM_CONFIG_PREFIX: dir,
},
FROZEN_NOW,
@@ -271,13 +293,14 @@ describe("npm project install env", () => {
it("uses before args for command project npmrc before policies", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-project-npmrc-"));
try {
const baseEnv = createIsolatedNpmConfigEnv(dir);
fsSync.writeFileSync(path.join(dir, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n", "utf-8");
expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW, { npmConfigCwd: dir })).toEqual([
expect(createNpmFreshnessBypassArgs(baseEnv, FROZEN_NOW, { npmConfigCwd: dir })).toEqual([
`--before=${FROZEN_NOW.toISOString()}`,
]);
const env = createNpmProjectInstallEnv({}, { npmConfigCwd: dir }, FROZEN_NOW);
const env = createNpmProjectInstallEnv(baseEnv, { npmConfigCwd: dir }, FROZEN_NOW);
expect(env.npm_config_min_release_age).toBe("");
expect(env.npm_config_before).toBe(FROZEN_NOW.toISOString());
} finally {
@@ -288,10 +311,11 @@ describe("npm project install env", () => {
it("uses before args for the current project npmrc by default", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-current-npmrc-"));
try {
const baseEnv = createIsolatedNpmConfigEnv(dir);
fsSync.writeFileSync(path.join(dir, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n", "utf-8");
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir);
withRestoredMocks([cwdSpy], () => {
expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW)).toEqual([
expect(createNpmFreshnessBypassArgs(baseEnv, FROZEN_NOW)).toEqual([
`--before=${FROZEN_NOW.toISOString()}`,
]);
});
@@ -303,6 +327,7 @@ describe("npm project install env", () => {
it("uses before args for scoped npm prefix before policies", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-prefix-npmrc-"));
try {
const baseEnv = createIsolatedNpmConfigEnv(dir);
const npmrcDir = path.join(dir, "etc");
fsSync.mkdirSync(npmrcDir, { recursive: true });
fsSync.writeFileSync(
@@ -311,7 +336,7 @@ describe("npm project install env", () => {
"utf-8",
);
expect(createNpmFreshnessBypassArgs({}, FROZEN_NOW, { npmConfigPrefix: dir })).toEqual([
expect(createNpmFreshnessBypassArgs(baseEnv, FROZEN_NOW, { npmConfigPrefix: dir })).toEqual([
`--before=${FROZEN_NOW.toISOString()}`,
]);
} finally {
@@ -322,10 +347,12 @@ describe("npm project install env", () => {
it("overrides stale npmrc before config without emitting release-age config", () => {
const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-npmrc-"));
try {
const baseEnv = createIsolatedNpmConfigEnv(dir);
const npmrc = path.join(dir, "npmrc");
fsSync.writeFileSync(npmrc, "before=2026-01-01T00:00:00.000Z\n", "utf-8");
const env = createNpmProjectInstallEnv(
{
...baseEnv,
NPM_CONFIG_USERCONFIG: npmrc,
},
{},
+165 -61
View File
@@ -1,5 +1,15 @@
import { spawnSync } from "node:child_process";
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import {
chmodSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readlinkSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
@@ -23,6 +33,74 @@ function linkRequiredShellTools(bin: string) {
}
}
function writeNpmFreshnessConflictFixture(path: string, argsLog: string) {
writeFileSync(
path,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`printf '%s\\n' "$*" >> ${JSON.stringify(argsLog)}`,
'if [[ "$1" == "config" && "$2" == "get" && "$3" == "min-release-age" ]]; then',
" printf 'null\\n'",
" exit 0",
"fi",
'if [[ "$1" == "config" && "$2" == "get" && "$3" == "before" ]]; then',
" printf 'Wed May 13 2026 21:25:20 GMT-0300 (Brasilia Standard Time)\\n'",
" exit 0",
"fi",
'for arg in "$@"; do',
' if [[ "$arg" == --before=* ]]; then',
" printf '%s\\n' 'Exit prior to config file resolving' >&2",
" printf '%s\\n' 'cause' >&2",
" printf '%s\\n' '--min-release-age cannot be provided when using --before' >&2",
" exit 64",
" fi",
"done",
'for arg in "$@"; do',
' if [[ "$arg" == "--min-release-age=0" ]]; then',
" exit 0",
" fi",
"done",
"exit 65",
"",
].join("\n"),
);
chmodSync(path, 0o755);
}
function writeNpmBeforePolicyFixture(path: string, argsLog: string) {
writeFileSync(
path,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`printf '%s\\n' "$*" >> ${JSON.stringify(argsLog)}`,
'if [[ "$1" == "config" && "$2" == "get" && "$3" == "min-release-age" ]]; then',
" printf 'null\\n'",
" exit 0",
"fi",
'if [[ "$1" == "config" && "$2" == "get" && "$3" == "before" ]]; then',
" printf 'Wed May 13 2026 21:25:20 GMT-0300 (Brasilia Standard Time)\\n'",
" exit 0",
"fi",
'for arg in "$@"; do',
' if [[ "$arg" == "--min-release-age=0" ]]; then',
" printf '%s\\n' 'min-release-age should not be selected for project-only npmrc' >&2",
" exit 64",
" fi",
"done",
'for arg in "$@"; do',
' if [[ "$arg" == --before=* ]]; then',
" exit 0",
" fi",
"done",
"exit 65",
"",
].join("\n"),
);
chmodSync(path, 0o755);
}
describe("install-cli.sh", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
@@ -144,12 +222,7 @@ describe("install-cli.sh", () => {
linkRequiredShellTools(bin);
writeFileSync(
fakeApk,
[
"#!/bin/bash",
'printf "%s\\n" "$*" >> "$APK_LOG"',
"exit 99",
"",
].join("\n"),
["#!/bin/bash", 'printf "%s\\n" "$*" >> "$APK_LOG"', "exit 99", ""].join("\n"),
);
writeFileSync(
fakeNode,
@@ -166,14 +239,7 @@ describe("install-cli.sh", () => {
"",
].join("\n"),
);
writeFileSync(
fakeNpm,
[
"#!/bin/bash",
"exit 0",
"",
].join("\n"),
);
writeFileSync(fakeNpm, ["#!/bin/bash", "exit 0", ""].join("\n"));
chmodSync(fakeApk, 0o755);
chmodSync(fakeNode, 0o755);
chmodSync(fakeNpm, 0o755);
@@ -233,12 +299,7 @@ describe("install-cli.sh", () => {
mkdirSync(nodePrefixBin, { recursive: true });
writeFileSync(
fakeApk,
[
"#!/bin/bash",
'printf "%s\\n" "$*" >> "$APK_LOG"',
"exit 99",
"",
].join("\n"),
["#!/bin/bash", 'printf "%s\\n" "$*" >> "$APK_LOG"', "exit 99", ""].join("\n"),
);
writeFileSync(
staleNode,
@@ -285,22 +346,8 @@ describe("install-cli.sh", () => {
"",
].join("\n"),
);
writeFileSync(
oldNpm,
[
"#!/bin/bash",
"exit 0",
"",
].join("\n"),
);
writeFileSync(
fakeNpm,
[
"#!/bin/bash",
"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);
@@ -384,14 +431,7 @@ describe("install-cli.sh", () => {
"",
].join("\n"),
);
writeFileSync(
fakeNpm,
[
"#!/bin/bash",
"exit 0",
"",
].join("\n"),
);
writeFileSync(fakeNpm, ["#!/bin/bash", "exit 0", ""].join("\n"));
chmodSync(fakeApk, 0o755);
chmodSync(fakeNode, 0o755);
chmodSync(fakeNpm, 0o755);
@@ -446,12 +486,7 @@ describe("install-cli.sh", () => {
linkRequiredShellTools(bin);
writeFileSync(
fakeApk,
[
"#!/bin/bash",
'printf "%s\\n" "$*" >> "$APK_LOG"',
"exit 0",
"",
].join("\n"),
["#!/bin/bash", 'printf "%s\\n" "$*" >> "$APK_LOG"', "exit 0", ""].join("\n"),
);
writeFileSync(
fakeNode,
@@ -468,14 +503,7 @@ describe("install-cli.sh", () => {
"",
].join("\n"),
);
writeFileSync(
fakeNpm,
[
"#!/bin/bash",
"exit 0",
"",
].join("\n"),
);
writeFileSync(fakeNpm, ["#!/bin/bash", "exit 0", ""].join("\n"));
chmodSync(fakeApk, 0o755);
chmodSync(fakeNode, 0o755);
chmodSync(fakeNpm, 0o755);
@@ -504,7 +532,9 @@ describe("install-cli.sh", () => {
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 >= 22.22.0 with node:sqlite");
expect(result.stdout).toContain(
"Alpine Node package must provide Node >= 22.22.0 with node:sqlite",
);
expect(result.stdout).toContain("found v22.18.0");
} finally {
rmSync(tmp, { force: true, recursive: true });
@@ -513,7 +543,7 @@ 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('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");
});
@@ -748,4 +778,78 @@ describe("install-cli.sh", () => {
expect(result.stdout).toContain("npm installs do not support OpenClaw GitHub source targets");
expect(result.stdout).toContain("--install-method git --version main");
});
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");
const home = join(tmp, "home");
const nodeBin = join(prefix, "tools/node-v22.22.0/bin");
const argsLog = join(tmp, "npm-args.log");
mkdirSync(nodeBin, { recursive: true });
mkdirSync(home, { recursive: true });
writeFileSync(join(home, ".npmrc"), "min-release-age=7\n");
writeNpmFreshnessConflictFixture(join(nodeBin, "npm"), argsLog);
let result: ReturnType<typeof runInstallCliShell> | undefined;
let argsOutput = "";
try {
result = runInstallCliShell(
[
"set -euo pipefail",
`HOME=${JSON.stringify(home)}`,
`OPENCLAW_PREFIX=${JSON.stringify(prefix)}`,
"OPENCLAW_VERSION=2026.5.19",
`source ${JSON.stringify(SCRIPT_PATH)}`,
"ensure_git() { return 0; }",
"install_openclaw",
].join("\n"),
);
argsOutput = readFileSync(argsLog, "utf8");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(argsOutput).toContain("--min-release-age=0");
expect(argsOutput).not.toContain("--before=");
});
it("ignores project npmrc when choosing global install freshness args", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-global-freshness-"));
const prefix = join(tmp, "prefix");
const home = join(tmp, "home");
const project = join(tmp, "project");
const nodeBin = join(prefix, "tools/node-v22.22.0/bin");
const argsLog = join(tmp, "npm-args.log");
mkdirSync(nodeBin, { recursive: true });
mkdirSync(home, { recursive: true });
mkdirSync(project, { recursive: true });
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);
let result: ReturnType<typeof runInstallCliShell> | undefined;
let argsOutput = "";
try {
result = runInstallCliShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(project)}`,
`HOME=${JSON.stringify(home)}`,
`OPENCLAW_PREFIX=${JSON.stringify(prefix)}`,
"OPENCLAW_VERSION=2026.5.19",
`source ${JSON.stringify(process.cwd() + "/" + SCRIPT_PATH)}`,
"ensure_git() { return 0; }",
"install_openclaw",
].join("\n"),
);
argsOutput = readFileSync(argsLog, "utf8");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(argsOutput).toContain("--before=");
expect(argsOutput).not.toContain("--min-release-age=0");
});
});
+24
View File
@@ -152,6 +152,30 @@ describe("install.ps1 failure handling", () => {
expect(npmInstallBody).toContain("-InstallMethod git -Tag main");
});
it("does not read project npmrc when choosing global install freshness args", () => {
const rawKeyBody = extractFunctionBody(source, "Test-NpmConfigRawKey");
expect(rawKeyBody).not.toContain("Get-Location");
expect(rawKeyBody).not.toContain('Join-Path (Get-Location) ".npmrc"');
});
it("preserves the min-release-age probe status before raw npmrc detection", () => {
const npmInstallBody = extractFunctionBody(source, "Install-OpenClaw");
const probeStatusCapture = npmInstallBody.indexOf("$minReleaseAgeStatus = $LASTEXITCODE");
const rawKeyProbe = npmInstallBody.indexOf("Test-NpmConfigRawKey -Key");
expect(probeStatusCapture).toBeGreaterThan(-1);
expect(rawKeyProbe).toBeGreaterThan(-1);
expect(probeStatusCapture).toBeLessThan(rawKeyProbe);
expect(npmInstallBody).toContain(
"} elseif ($minReleaseAgeStatus -ne 0 -or -not $minReleaseAge",
);
expect(npmInstallBody).toContain(
'Invoke-NpmCommand -Arguments @("config", "get", "min-release-age", "--global")',
);
expect(npmInstallBody).toContain(
'Invoke-NpmCommand -Arguments @("config", "get", "before", "--global")',
);
});
it("preserves caller-relative local tarball install specs before safe-cwd npm calls", () => {
const resolveSpecBody = extractFunctionBody(source, "Resolve-NpmOpenClawInstallSpec");
const localSpecBody = extractFunctionBody(source, "Resolve-LocalNpmPackageInstallSpec");
+146 -6
View File
@@ -17,6 +17,74 @@ function runInstallShell(script: string, env: NodeJS.ProcessEnv = {}) {
});
}
function writeNpmFreshnessConflictFixture(path: string, argsLog: string) {
writeFileSync(
path,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`printf '%s\\n' "$*" >> ${JSON.stringify(argsLog)}`,
'if [[ "$1" == "config" && "$2" == "get" && "$3" == "min-release-age" ]]; then',
" printf 'null\\n'",
" exit 0",
"fi",
'if [[ "$1" == "config" && "$2" == "get" && "$3" == "before" ]]; then',
" printf 'Wed May 13 2026 21:25:20 GMT-0300 (Brasilia Standard Time)\\n'",
" exit 0",
"fi",
'for arg in "$@"; do',
' if [[ "$arg" == --before=* ]]; then',
" printf '%s\\n' 'Exit prior to config file resolving' >&2",
" printf '%s\\n' 'cause' >&2",
" printf '%s\\n' '--min-release-age cannot be provided when using --before' >&2",
" exit 64",
" fi",
"done",
'for arg in "$@"; do',
' if [[ "$arg" == "--min-release-age=0" ]]; then',
" exit 0",
" fi",
"done",
"exit 65",
"",
].join("\n"),
);
chmodSync(path, 0o755);
}
function writeNpmBeforePolicyFixture(path: string, argsLog: string) {
writeFileSync(
path,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`printf '%s\\n' "$*" >> ${JSON.stringify(argsLog)}`,
'if [[ "$1" == "config" && "$2" == "get" && "$3" == "min-release-age" ]]; then',
" printf 'null\\n'",
" exit 0",
"fi",
'if [[ "$1" == "config" && "$2" == "get" && "$3" == "before" ]]; then',
" printf 'Wed May 13 2026 21:25:20 GMT-0300 (Brasilia Standard Time)\\n'",
" exit 0",
"fi",
'for arg in "$@"; do',
' if [[ "$arg" == "--min-release-age=0" ]]; then',
" printf '%s\\n' 'min-release-age should not be selected for project-only npmrc' >&2",
" exit 64",
" fi",
"done",
'for arg in "$@"; do',
' if [[ "$arg" == --before=* ]]; then',
" exit 0",
" fi",
"done",
"exit 65",
"",
].join("\n"),
);
chmodSync(path, 0o755);
}
describe("install.sh", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
@@ -42,15 +110,13 @@ describe("install.sh", () => {
it("installs Node.js with apk on Alpine before falling back to NodeSource", () => {
expect(script).toContain("finish_linux_node_install()");
expect(script).toContain('ui_info "Installing Node.js via apk (Alpine Linux detected)"');
expect(script).toContain(
'run_quiet_step "Installing Node.js" apk add --no-cache nodejs npm',
);
expect(script).toContain('run_quiet_step "Installing Node.js" apk add --no-cache nodejs npm');
expect(script).toContain(
'run_quiet_step "Installing Node.js" sudo apk add --no-cache nodejs npm',
);
expect(script).toContain('if ! node_is_at_least_required; then');
expect(script).toContain("if ! node_is_at_least_required; then");
const apkIndex = script.indexOf('if command -v apk &> /dev/null; then');
const apkIndex = script.indexOf("if command -v apk &> /dev/null; then");
const nodeSourceIndex = script.indexOf('ui_info "Installing Node.js via NodeSource"');
expect(apkIndex).toBeGreaterThan(-1);
expect(nodeSourceIndex).toBeGreaterThan(apkIndex);
@@ -82,7 +148,7 @@ 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('npm_config_has_raw_key npm "min-release-age"');
expect(script).toContain('freshness_flag="--before=$(date -u');
expect(script).toContain('cmd+=(--no-fund --no-audit "$freshness_flag" install -g "$spec")');
});
@@ -386,6 +452,80 @@ describe("install.sh", () => {
expect(result.stdout).toContain("--install-method git --version main");
});
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");
const home = join(tmp, "home");
const argsLog = join(tmp, "npm-args.log");
mkdirSync(bin, { recursive: true });
mkdirSync(home, { recursive: true });
writeFileSync(join(home, ".npmrc"), "min-release-age=7\n");
writeNpmFreshnessConflictFixture(join(bin, "npm"), argsLog);
let result: ReturnType<typeof runInstallShell> | undefined;
let argsOutput = "";
try {
result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`HOME=${JSON.stringify(home)}`,
`PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`,
"NPM_LOGLEVEL=error",
"NPM_SILENT_FLAG=",
"SHARP_IGNORE_GLOBAL_LIBVIPS=1",
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
].join("\n"),
);
argsOutput = readFileSync(argsLog, "utf8");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(argsOutput).toContain("--min-release-age=0");
expect(argsOutput).not.toContain("--before=");
});
it("ignores project npmrc when choosing global install freshness args", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-global-freshness-"));
const bin = join(tmp, "bin");
const home = join(tmp, "home");
const project = join(tmp, "project");
const argsLog = join(tmp, "npm-args.log");
mkdirSync(bin, { recursive: true });
mkdirSync(home, { recursive: true });
mkdirSync(project, { recursive: true });
writeFileSync(join(home, ".npmrc"), "before=2026-01-01T00:00:00.000Z\n");
writeFileSync(join(project, ".npmrc"), "min-release-age=7\n");
writeNpmBeforePolicyFixture(join(bin, "npm"), argsLog);
let result: ReturnType<typeof runInstallShell> | undefined;
let argsOutput = "";
try {
result = runInstallShell(
[
"set -euo pipefail",
`cd ${JSON.stringify(project)}`,
`source ${JSON.stringify(process.cwd() + "/" + SCRIPT_PATH)}`,
`HOME=${JSON.stringify(home)}`,
`PATH=${JSON.stringify(`${bin}:/usr/bin:/bin`)}`,
"NPM_LOGLEVEL=error",
"NPM_SILENT_FLAG=",
"SHARP_IGNORE_GLOBAL_LIBVIPS=1",
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
].join("\n"),
);
argsOutput = readFileSync(argsLog, "utf8");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
expect(result?.status).toBe(0);
expect(argsOutput).toContain("--before=");
expect(argsOutput).not.toContain("--min-release-age=0");
});
it("exports noninteractive apt env during Linux startup", () => {
expect(script).toMatch(
/detect_os_or_die\s+if \[\[ "\$OS" == "linux" \]\]; then\s+export DEBIAN_FRONTEND="\$\{DEBIAN_FRONTEND:-noninteractive\}"\s+export NEEDRESTART_MODE="\$\{NEEDRESTART_MODE:-a\}"\s+fi/m,