fix(install): avoid success after incomplete lifecycle changes (#125992)

* fix(install): make lifecycle mutations transactional

Standalone installers now apply npm-version-aware lifecycle approval. Updates verify and repair the installation before reporting success and preserve the prior install owner during method switches. Uninstall now exits nonzero when requested cleanup is only partially completed. Plugin update behavior is unchanged.

Closes #125925

* test(uninstall): assert aggregated live-owner failure

* fix(install): satisfy standalone shell checks

* fix(update): scan PATH for prior Git wrapper

* test(hooks): await Gmail watcher descendant exit

* fix(install): verify Windows npm candidate

* fix(ci): normalize package acceptance version

* fix(update): preserve staged local package links

* test(update): fold staged symlink coverage

* fix(update): retire every legacy Git wrapper

* test(docs): align consolidated ownership checks
This commit is contained in:
Peter Steinberger
2026-08-18 20:50:15 -07:00
committed by GitHub
parent a441431896
commit 7bc994aee8
32 changed files with 2119 additions and 333 deletions
+62
View File
@@ -811,6 +811,65 @@ jobs:
}
node scripts/check-openclaw-package-tarball.mjs "$package"
npm_12_install_sh:
name: npm 12 install.sh acceptance
needs: [resolve_package, package_integrity]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
actions: read
contents: read
steps:
- name: Checkout package workflow ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.workflow_ref }}
fetch-depth: 1
persist-credentials: false
- name: Setup Node 24
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
- name: Download package-under-test artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
artifact-ids: ${{ needs.resolve_package.outputs.package_artifact_id }}
path: .artifacts/docker-e2e-package
run-id: ${{ needs.resolve_package.outputs.package_artifact_run_id }}
github-token: ${{ github.token }}
- name: Run install.sh with npm 12
env:
EXPECTED_PACKAGE_SHA256: ${{ needs.resolve_package.outputs.package_sha256 }}
EXPECTED_PACKAGE_VERSION: ${{ needs.resolve_package.outputs.package_version }}
shell: bash
run: |
set -euo pipefail
package="$PWD/.artifacts/docker-e2e-package/openclaw-current.tgz"
[[ "$(sha256sum "$package" | awk '{print $1}')" == "$EXPECTED_PACKAGE_SHA256" ]]
npm_tool="$RUNNER_TEMP/openclaw-npm12-tool"
install_home="$RUNNER_TEMP/openclaw-npm12-home"
install_prefix="$RUNNER_TEMP/openclaw-npm12-prefix"
mkdir -p "$install_home" "$install_prefix"
npm install -g --prefix "$npm_tool" npm@12.0.2
export PATH="$npm_tool/bin:$install_prefix/bin:$PATH"
[[ "$(npm --version)" == "12.0.2" ]]
HOME="$install_home" \
NPM_CONFIG_CACHE="$RUNNER_TEMP/openclaw-npm12-cache" \
NPM_CONFIG_PREFIX="$install_prefix" \
OPENCLAW_VERSION="$package" \
bash scripts/install.sh --install-method npm --no-prompt --no-onboard
source scripts/docker/install-sh-common/version-parse.sh
installed_version="$(extract_openclaw_semver "$("$install_prefix/bin/openclaw" --version)")"
[[ "$installed_version" == "$EXPECTED_PACKAGE_VERSION" ]] || {
echo "Installed OpenClaw version $installed_version differs from expected $EXPECTED_PACKAGE_VERSION." >&2
exit 1
}
guard="$install_prefix/lib/node_modules/openclaw/dist/openclaw-install-guard"
[[ ! -e "$guard" ]]
docker_acceptance:
name: Docker product acceptance (artifact-only)
needs: [resolve_package, package_integrity]
@@ -951,6 +1010,7 @@ jobs:
[
resolve_package,
package_integrity,
npm_12_install_sh,
docker_acceptance,
docker_acceptance_registry,
package_telegram,
@@ -965,6 +1025,7 @@ jobs:
DOCKER_ARTIFACT_RESULT: ${{ needs.docker_acceptance.result }}
DOCKER_REGISTRY_RESULT: ${{ needs.docker_acceptance_registry.result }}
PACKAGE_INTEGRITY_RESULT: ${{ needs.package_integrity.result }}
NPM_12_INSTALL_RESULT: ${{ needs.npm_12_install_sh.result }}
PACKAGE_TELEGRAM_RESULT: ${{ needs.package_telegram.result }}
RESOLVE_RESULT: ${{ needs.resolve_package.result }}
TELEGRAM_ENABLED: ${{ needs.resolve_package.outputs.telegram_enabled }}
@@ -988,6 +1049,7 @@ jobs:
for item in \
"resolve_package=${RESOLVE_RESULT}" \
"package_integrity=${PACKAGE_INTEGRITY_RESULT}" \
"npm_12_install_sh=${NPM_12_INSTALL_RESULT}" \
"docker_acceptance=${docker_result}" \
"package_telegram=${PACKAGE_TELEGRAM_RESULT}"
do
+4 -3
View File
@@ -466,9 +466,10 @@ Use `Package Acceptance` when the question is "does this installable OpenClaw pa
1. `resolve_package` checks out `workflow_ref`, resolves one package candidate, writes `.artifacts/docker-e2e-package/openclaw-current.tgz`, writes `.artifacts/docker-e2e-package/package-candidate.json`, uploads both as the `package-under-test` artifact, and prints the source, workflow ref, package ref, version, SHA-256, and profile in the GitHub step summary.
2. `package_integrity` downloads the `package-under-test` artifact and enforces the public package tarball contract with `scripts/check-openclaw-package-tarball.mjs`.
3. `docker_acceptance` calls `openclaw-live-and-e2e-checks-reusable.yml` with the resolved package source SHA (falling back to `workflow_ref`) and `package_artifact_name=package-under-test`. The reusable workflow downloads that artifact, validates the tarball inventory, prepares package-digest Docker images when needed, and runs the selected Docker lanes against that package instead of packing the workflow checkout. When a profile selects multiple targeted `docker_lanes`, the reusable workflow prepares the package and shared images once, then fans those lanes out as parallel targeted Docker jobs with unique artifacts.
4. `package_telegram` optionally calls `NPM Telegram Beta E2E`. It runs when `telegram_mode` is not `none` and installs the same `package-under-test` artifact when Package Acceptance resolved one; standalone Telegram dispatch can still install a published npm spec.
5. `summary` fails the workflow if package resolution, integrity, Docker acceptance, or the optional Telegram lane failed. The `advisory` input downgrades acceptance failures to warnings for advisory callers.
3. `npm_12_install_sh` installs that exact artifact through the public Linux installer under npm 12 in an isolated home/prefix, then verifies the CLI version and lifecycle-completion guard.
4. `docker_acceptance` calls `openclaw-live-and-e2e-checks-reusable.yml` with the resolved package source SHA (falling back to `workflow_ref`) and `package_artifact_name=package-under-test`. The reusable workflow downloads that artifact, validates the tarball inventory, prepares package-digest Docker images when needed, and runs the selected Docker lanes against that package instead of packing the workflow checkout. When a profile selects multiple targeted `docker_lanes`, the reusable workflow prepares the package and shared images once, then fans those lanes out as parallel targeted Docker jobs with unique artifacts.
5. `package_telegram` optionally calls `NPM Telegram Beta E2E`. It runs when `telegram_mode` is not `none` and installs the same `package-under-test` artifact when Package Acceptance resolved one; standalone Telegram dispatch can still install a published npm spec.
6. `summary` fails the workflow if package resolution, integrity, npm 12 installer acceptance, Docker acceptance, or the optional Telegram lane failed. The `advisory` input downgrades acceptance failures to warnings for advisory callers.
### Candidate sources
+2
View File
@@ -40,6 +40,8 @@ openclaw uninstall --dry-run
## Notes
Uninstall reports each requested scope and exits nonzero if any requested cleanup fails or is blocked. A failed gateway service inspection, stop, or uninstall blocks state and workspace mutation, but independent macOS app cleanup is still attempted. After service teardown is safe, other permitted scopes continue so failures can be reported together. On non-macOS systems, `--app` reports that the scope is not applicable.
- Run `openclaw backup create` first for a restorable snapshot before removing
state or workspaces.
- Before removing state, `--state` requires exclusive state ownership. If an
+6 -2
View File
@@ -17,6 +17,10 @@ OpenClaw ships three installer scripts, served from `openclaw.ai`.
All three support Node **22.22.3+, 24.15+, or 25.9+**. On macOS and Linux, `install.sh` provisions Node 26 when needed, while the rootless `install-cli.sh` downloads Node 24.15.0 (Node 22.22.3 on ARMv7). On Windows, winget/Chocolatey/Scoop install the supported Node LTS line, and the portable fallback downloads Node 26.
Before changing packages, every installer probes the exact npm executable it will use. npm 11.15 and earlier installs normally; npm 11.16 and later, including npm 12, receives `--allow-scripts` for only the npm-resolved OpenClaw candidate identity. An unreadable npm version stops before package mutation, and a remaining `dist/openclaw-install-guard` makes the install fail instead of reporting a lifecycle-skipped package as successful.
Install-method switches verify the replacement before retiring the current owner. Source wrappers use a same-directory atomic replacement; when an npm shim shares that path, the installer moves only an identity-matched source wrapper aside and restores it if npm installation, lifecycle checks, or candidate verification fails. On upgrades, `install.sh` and `install.ps1` run `openclaw doctor --fix`; repair or final verification failure exits nonzero, and the success banner appears only after those steps complete.
## Quick commands
<Tabs>
@@ -87,7 +91,7 @@ Recommended for most interactive installs on macOS/Linux/WSL.
<Step title="Post-install tasks">
- Resolves the just-installed `openclaw` binary for follow-up commands
- For an unconfigured install, starts onboarding before doctor or gateway probes. With `--no-onboard` or no TTY, it prints the command to finish setup later.
- For a configured install, refreshes and restarts a loaded gateway service best-effort and runs doctor. Upgrades update plugins when possible, or print the manual command in a headless prompt-enabled run.
- For a configured install, refreshes and restarts a loaded gateway service best-effort and runs repair Doctor. Upgrade repair failures are fatal; plugin update failures remain warnings.
- When `--verify` runs, it checks the installed version and checks gateway health only after configuration exists.
</Step>
@@ -316,7 +320,7 @@ by default, plus git-checkout installs under the same prefix flow.
<Step title="Post-install tasks">
- Adds needed bin directory to user PATH when possible
- Refreshes a loaded gateway service best-effort (`openclaw gateway install --force`, then restart)
- Runs `openclaw doctor --non-interactive` on upgrades and git installs (best effort)
- Runs `openclaw doctor --fix --non-interactive` on upgrades and git installs; failure prevents an upgrade-success result
</Step>
<Step title="Handle failures">
+2
View File
@@ -13,6 +13,8 @@ Two paths:
## Easy path (CLI still installed)
The command attempts independent requested cleanup scopes and returns a nonzero status if any scope fails or is blocked. Service teardown remains the safety gate for state and workspace deletion; if that gate fails, those data scopes are preserved while app cleanup is still attempted. Partial cleanup is reported explicitly and is never followed by an unconditional completion result.
Recommended: use the built-in uninstaller:
```bash
+2
View File
@@ -74,6 +74,8 @@ See [Release channels](/install/development-channels) for channel semantics.
## Switch between npm and git installs
Installer-driven switches verify the replacement before the working owner is retired. Source wrappers are published atomically; same-path npm shim transitions use an identity-checked backup that is restored on failure, so a failed candidate leaves the previous command runnable. The `openclaw update` command prints its final success result only after post-core convergence and requested restart health checks succeed.
Use channels to change the install type. The updater keeps your state, config,
credentials, and workspace in `~/.openclaw`; it only changes which OpenClaw
code install the CLI and gateway use.
+80 -8
View File
@@ -33,7 +33,13 @@ ensure_home_env
# Register paths in the caller: command substitutions run in a subshell, so
# array mutations inside a helper would not reach this shell.
TMPFILES=()
WRAPPER_BACKUP_TARGET=""
WRAPPER_BACKUP_PATH=""
cleanup_tmpfiles() {
if [[ -n "$WRAPPER_BACKUP_PATH" && ( -e "$WRAPPER_BACKUP_PATH" || -L "$WRAPPER_BACKUP_PATH" ) ]]; then
rm -f "$WRAPPER_BACKUP_TARGET" 2>/dev/null || true
mv "$WRAPPER_BACKUP_PATH" "$WRAPPER_BACKUP_TARGET" 2>/dev/null || true
fi
local f
for f in "${TMPFILES[@]:-}"; do
rm -rf "$f" 2>/dev/null || true
@@ -1273,6 +1279,62 @@ npm_config_has_raw_key() {
return 1
}
npm_lifecycle_allow_arg() {
local npm_cmd="$1" spec="$2" npm_cwd="${3:-$PWD}" version=""
version="$("$npm_cmd" --version 2>/dev/null | awk 'NF { value = $0 } END { print value }')" || true
if [[ ! "$version" =~ ^[vV]?([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then
log "ERROR: unable to determine npm version; no package changes were made"
return 1
fi
local major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}"
if (( major < 12 && (major != 11 || minor < 16) )); then return 0; fi
local identity="$spec" normalized=""
normalized="$(to_lowercase_ascii "$identity")"
if [[ "$normalized" == openclaw@* ]]; then identity="${identity#*@}"; normalized="$(to_lowercase_ascii "$identity")"; fi
if [[ "$normalized" == npm:* ]]; then
local alias_target="${identity#*:}"
if [[ "$alias_target" == @*/*@* ]]; then identity="${alias_target%@*}"
elif [[ "$alias_target" == *@* ]]; then identity="${alias_target%%@*}"
else identity="$alias_target"; fi
elif [[ "$identity" != *"://"* && "$identity" != /* && "$identity" != ./* && "$identity" != ../* && ! "$identity" =~ ^(file|github|git\+|npm): && ! "$identity" =~ \.(tgz|tar\.gz)$ ]]; then
identity="openclaw"
fi
if [[ "$identity" == /* ]]; then
# shellcheck disable=SC2016 # JavaScript source must not expand in the installer shell.
identity="$("$(node_bin)" -e '
const path = require("node:path");
const relative = path.relative(process.argv[1], process.argv[2]) || ".";
process.stdout.write(path.isAbsolute(relative) || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) ? relative : `.${path.sep}${relative}`);
' "$npm_cwd" "$identity")" || return 1
fi
[[ -n "$identity" && "$identity" != *,* ]] || { log "ERROR: npm cannot allow lifecycle scripts for ${spec}"; return 1; }
printf '%s\n' "--allow-scripts=${identity}"
}
publish_executable_wrapper() {
local target="$1" target_dir="" temp="" backup=""
target_dir="${target%/*}"
mkdir -p "$target_dir"
temp="$(mktemp "${target_dir}/.openclaw-wrapper.XXXXXX")" || return 1
TMPFILES+=("$temp")
cat > "$temp"
chmod +x "$temp"
if [[ -z "$WRAPPER_BACKUP_PATH" && ( -e "$target" || -L "$target" ) ]]; then
backup="$(mktemp "${target}.backup.XXXXXX")" || return 1
rm -f "$backup" || return 1
mv "$target" "$backup" || return 1
WRAPPER_BACKUP_TARGET="$target"
WRAPPER_BACKUP_PATH="$backup"
fi
mv -f "$temp" "$target"
}
commit_wrapper_backup() {
[[ -z "$WRAPPER_BACKUP_PATH" ]] || rm -f "$WRAPPER_BACKUP_PATH" || return 1
WRAPPER_BACKUP_TARGET=""
WRAPPER_BACKUP_PATH=""
}
install_openclaw() {
local requested="${OPENCLAW_VERSION:-latest}"
if is_openclaw_source_package_install_spec "$requested"; then
@@ -1304,17 +1366,29 @@ install_openclaw() {
fi
require_openclaw_version_compatible "$resolved_requested"
fi
local install_spec="openclaw@${resolved_requested}"
if [[ "$resolved_requested" == *"://"* || "$resolved_requested" == /* || "$resolved_requested" == ./* || "$resolved_requested" == ../* || "$resolved_requested" =~ ^(file|github|git\+|npm): || "$resolved_requested" =~ \.(tgz|tar\.gz)$ ]]; then
install_spec="$resolved_requested"
fi
local npm_cmd="" lifecycle_arg=""
npm_cmd="$(npm_bin)"
local npm_cwd="$PWD"
lifecycle_arg="$(npm_lifecycle_allow_arg "$npm_cmd" "$install_spec" "$npm_cwd")" || return 1
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"start\",\"version\":\"${requested}\"}"
log "Installing OpenClaw (${requested})..."
if [[ "$SET_NPM_PREFIX" -eq 1 ]]; then
fix_npm_prefix_if_needed
fi
local installed_entry
local installed_entry install_guard
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
install_guard="$(node_dir)/lib/node_modules/openclaw/dist/openclaw-install-guard"
local npm_install_args=(install -g --prefix "$(node_dir)" "${npm_args[@]}")
[[ -z "$lifecycle_arg" ]] || npm_install_args+=("$lifecycle_arg")
npm_install_args+=("$install_spec")
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_cmd" "${npm_install_args[@]}" || [[ ! -f "$installed_entry" || -e "$install_guard" ]]; 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
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_cmd" "${npm_install_args[@]}" || [[ ! -f "$installed_entry" || -e "$install_guard" ]]; 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
@@ -1322,13 +1396,11 @@ install_openclaw() {
fi
mkdir -p "${PREFIX}/bin"
rm -f "${PREFIX}/bin/openclaw"
cat > "${PREFIX}/bin/openclaw" <<EOF
publish_executable_wrapper "${PREFIX}/bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec "${PREFIX}/tools/node/bin/node" "$(node_dir)/lib/node_modules/openclaw/dist/entry.js" "\$@"
EOF
chmod +x "${PREFIX}/bin/openclaw"
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"ok\",\"version\":\"${requested}\"}"
}
@@ -1541,12 +1613,11 @@ install_openclaw_from_git() {
emit_json '{"event":"step","name":"cli-build","status":"ok"}'
mkdir -p "${PREFIX}/bin"
cat > "${PREFIX}/bin/openclaw" <<EOF
publish_executable_wrapper "${PREFIX}/bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec "${PREFIX}/tools/node/bin/node" "${repo_dir}/dist/entry.js" "\$@"
EOF
chmod +x "${PREFIX}/bin/openclaw"
emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"ok\",\"method\":\"git\"}"
}
@@ -1636,6 +1707,7 @@ main() {
[[ -z "$installed_version" ]]; then
fail "Installed OpenClaw CLI did not return a version successfully from ${PREFIX}/bin/openclaw."
fi
commit_wrapper_backup
refresh_gateway_service_if_loaded
emit_json "{\"event\":\"done\",\"ok\":true,\"version\":\"${installed_version//\"/\\\"}\"}"
+308 -28
View File
@@ -988,10 +988,11 @@ function Invoke-CommandFromWindowsSafeDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$CommandPath,
[string[]]$Arguments = @()
[string[]]$Arguments = @(),
[string]$WorkingDirectory
)
$safeDir = Get-WindowsCommandSafeDirectory
$safeDir = if ([string]::IsNullOrWhiteSpace($WorkingDirectory)) { Get-WindowsCommandSafeDirectory } else { $WorkingDirectory }
$pushedLocation = $false
try {
if (-not [string]::IsNullOrWhiteSpace($safeDir)) {
@@ -1007,8 +1008,15 @@ function Invoke-CommandFromWindowsSafeDirectory {
}
function Invoke-NpmCommand {
param([string[]]$Arguments = @())
Invoke-CommandFromWindowsSafeDirectory -CommandPath (Get-NpmCommandPath) -Arguments $Arguments
param(
[string[]]$Arguments = @(),
[string]$CommandPath,
[string]$WorkingDirectory
)
if ([string]::IsNullOrWhiteSpace($CommandPath)) {
$CommandPath = Get-NpmCommandPath
}
Invoke-CommandFromWindowsSafeDirectory -CommandPath $CommandPath -Arguments $Arguments -WorkingDirectory $WorkingDirectory
}
function Invoke-CorepackCommand {
@@ -1416,6 +1424,131 @@ function Write-NpmInstallFailureDetails {
}
}
function Resolve-NpmLifecyclePathIdentity {
param(
[string]$Identity,
[string]$NpmCwd
)
$isAbsolute = (
[System.IO.Path]::IsPathRooted($Identity) -or
$Identity -match '^[A-Za-z]:[\\/]' -or
$Identity -match '^\\\\'
)
if (-not $isAbsolute -or [string]::IsNullOrWhiteSpace($NpmCwd)) {
return $Identity
}
$separator = [System.IO.Path]::DirectorySeparatorChar
$basePath = $NpmCwd.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $separator
$baseUri = New-Object -TypeName System.Uri -ArgumentList $basePath
$targetUri = New-Object -TypeName System.Uri -ArgumentList $Identity
if (-not $baseUri.IsFile -or -not $targetUri.IsFile) {
return $Identity
}
$relative = [System.Uri]::UnescapeDataString($baseUri.MakeRelativeUri($targetUri).ToString())
$relative = $relative.Replace([char]'/', $separator)
if ($relative -ne "." -and $relative -ne ".." -and -not $relative.StartsWith("..$separator")) {
$relative = ".$separator$relative"
}
return $relative
}
function Get-NpmLifecycleAllowArgument {
param(
[string]$NpmCommand,
[string]$InstallSpec,
[string]$NpmCwd
)
$versionOutput = @(Invoke-NpmCommand -CommandPath $NpmCommand -WorkingDirectory $NpmCwd -Arguments @("--version") 2>$null)
if ($LASTEXITCODE -ne 0 -or $versionOutput.Count -eq 0) {
throw "Unable to determine npm version; no package changes were made."
}
$version = $versionOutput[-1].ToString().Trim()
if ($version -notmatch '^[vV]?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)([-+][0-9A-Za-z.-]+)?$') {
throw "Unable to determine npm version; no package changes were made."
}
$major = [int]$Matches["major"]
$minor = [int]$Matches["minor"]
if ($major -lt 12 -and ($major -ne 11 -or $minor -lt 16)) {
return $null
}
$identity = $InstallSpec.Trim()
if ($identity.StartsWith("openclaw@", [System.StringComparison]::OrdinalIgnoreCase)) {
$identity = $identity.Substring("openclaw@".Length)
}
if ($identity.StartsWith("npm:", [System.StringComparison]::OrdinalIgnoreCase)) {
$target = $identity.Substring("npm:".Length)
if ($target.StartsWith("@")) {
$slash = $target.IndexOf("/")
$versionAt = if ($slash -ge 0) { $target.IndexOf("@", $slash + 1) } else { -1 }
$identity = if ($versionAt -ge 0) { $target.Substring(0, $versionAt) } else { $target }
} else {
$versionAt = $target.IndexOf("@")
$identity = if ($versionAt -ge 0) { $target.Substring(0, $versionAt) } else { $target }
}
} elseif (
$identity -notmatch '^(https?|file|git\+|github:)' -and
$identity -notmatch '^[A-Za-z]:[\\/]' -and
$identity -notmatch '^\\\\' -and
$identity -notmatch '^\.\.?[\\/]' -and
$identity -notmatch '\.(tgz|tar\.gz)$'
) {
$identity = "openclaw"
}
if ($identity -match '^/' -or $identity -match '^[A-Za-z]:[\\/]' -or $identity -match '^\\\\') {
$identity = Resolve-NpmLifecyclePathIdentity -Identity $identity -NpmCwd $NpmCwd
}
if ([string]::IsNullOrWhiteSpace($identity) -or $identity.Contains(",")) {
throw "npm cannot allow lifecycle scripts for install target '$InstallSpec'."
}
return "--allow-scripts=$identity"
}
function Test-NpmLifecycleCompleted {
param(
[string]$NpmCommand,
[string]$NpmCwd
)
$rootOutput = @(Invoke-NpmCommand -CommandPath $NpmCommand -WorkingDirectory $NpmCwd -Arguments @("root", "-g") 2>$null)
if ($LASTEXITCODE -ne 0 -or $rootOutput.Count -eq 0) {
return $false
}
$npmRoot = $rootOutput[-1].ToString().Trim()
if ([string]::IsNullOrWhiteSpace($npmRoot)) {
return $false
}
$entryPath = Join-Path $npmRoot "openclaw\dist\entry.js"
$guardPath = Join-Path $npmRoot "openclaw\dist\openclaw-install-guard"
return (Test-Path -LiteralPath $entryPath -PathType Leaf) -and -not (Test-Path -LiteralPath $guardPath)
}
function Format-OpenClawGitWrapper {
param([string]$EntryPath)
return "@echo off`r`nnode `"$EntryPath`" %*`r`n"
}
function Publish-TextFileAtomically {
param(
[string]$Path,
[string]$Contents
)
$directory = Split-Path -Parent $Path
New-Item -ItemType Directory -Force -Path $directory | Out-Null
$temporaryPath = Join-Path $directory (".openclaw-wrapper-" + [guid]::NewGuid().ToString("N") + ".cmd")
$encoding = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($temporaryPath, $Contents, $encoding)
try {
if (Test-Path -LiteralPath $Path) {
[System.IO.File]::Replace($temporaryPath, $Path, $null)
} else {
[System.IO.File]::Move($temporaryPath, $Path)
}
} finally {
if (Test-Path -LiteralPath $temporaryPath) {
Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue
}
}
}
function Install-OpenClaw {
if ([string]::IsNullOrWhiteSpace($Tag)) {
$Tag = "latest"
@@ -1435,14 +1568,17 @@ function Install-OpenClaw {
$packageName = "openclaw"
}
$installSpec = Resolve-NpmOpenClawInstallSpec -PackageName $packageName -RequestedTag $Tag
$npmCommand = Get-NpmCommandPath
$npmCwd = Get-WindowsCommandSafeDirectory
$lifecycleArgument = Get-NpmLifecycleAllowArgument -NpmCommand $npmCommand -InstallSpec $installSpec -NpmCwd $npmCwd
Write-Host "[*] Installing OpenClaw ($installSpec)..." -ForegroundColor Yellow
$freshnessArgs = @("--min-release-age=0")
$minReleaseAge = (Invoke-NpmCommand -Arguments @("config", "get", "min-release-age", "--global") 2>$null)
$minReleaseAge = (Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -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)
$beforeValue = (Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -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"))")
}
@@ -1462,12 +1598,13 @@ function Install-OpenClaw {
try {
# Resolve cache roots before the install so failure reporting cannot create a newer npm log.
$npmDebugLogRoots = @(Get-NpmDebugLogRootCandidates)
$npmInstallArguments = @("install", "-g") + $freshnessArgs + @("$installSpec")
$npmOutput = Invoke-NpmCommand -Arguments $npmInstallArguments 2>&1
$lifecycleArguments = if ($lifecycleArgument) { @($lifecycleArgument) } else { @() }
$npmInstallArguments = @("install", "-g") + $freshnessArgs + $lifecycleArguments + @("$installSpec")
$npmOutput = Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments $npmInstallArguments 2>&1
$npmInstallStatus = $LASTEXITCODE
if ($npmInstallStatus -ne 0) {
Write-Host "[!] npm install failed; retrying once" -ForegroundColor Yellow
$npmOutput = Invoke-NpmCommand -Arguments $npmInstallArguments 2>&1
$npmOutput = Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments $npmInstallArguments 2>&1
$npmInstallStatus = $LASTEXITCODE
}
if ($npmInstallStatus -ne 0) {
@@ -1483,6 +1620,10 @@ function Install-OpenClaw {
Write-NpmInstallFailureDetails -Output $npmOutput -CacheRoots $npmDebugLogRoots
return $false
}
if (-not (Test-NpmLifecycleCompleted -NpmCommand $npmCommand -NpmCwd $npmCwd)) {
Write-Host "[!] npm install did not produce a usable OpenClaw package; lifecycle scripts may not have completed." -ForegroundColor Red
return $false
}
} finally {
$env:NPM_CONFIG_LOGLEVEL = $prevLogLevel
$env:NPM_CONFIG_UPDATE_NOTIFIER = $prevUpdateNotifier
@@ -1713,9 +1854,14 @@ function Install-OpenClawFromGit {
if (-not (Test-Path $binDir)) {
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
}
node $entryPath --version 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host "[!] Git replacement failed CLI verification" -ForegroundColor Red
return $false
}
$cmdPath = Join-Path $binDir "openclaw.cmd"
$cmdContents = "@echo off`r`nnode ""$entryPath"" %*`r`n"
Set-Content -Path $cmdPath -Value $cmdContents -NoNewline
$cmdContents = Format-OpenClawGitWrapper -EntryPath $entryPath
Publish-TextFileAtomically -Path $cmdPath -Contents $cmdContents
if (Add-ToUserPath $binDir) {
Write-Host "[!] Added $binDir to user PATH (restart terminal if command not found)" -ForegroundColor Yellow
@@ -1730,10 +1876,12 @@ function Install-OpenClawFromGit {
function Run-Doctor {
Write-Host "[*] Running doctor to migrate settings..." -ForegroundColor Yellow
try {
Invoke-OpenClawCommand doctor --non-interactive
Invoke-OpenClawCommand doctor --fix --non-interactive
Write-Host "[OK] Migration complete" -ForegroundColor Green
return $true
} catch {
Write-Host "[!] Migration failed; continuing. Run: openclaw doctor --non-interactive" -ForegroundColor Yellow
Write-Host "[!] Migration failed: $($_.Exception.Message)" -ForegroundColor Red
return $false
}
}
@@ -1800,6 +1948,86 @@ function Remove-LegacySubmodule {
}
}
function Test-PreviousGitWrapper {
$wrapper = Join-Path (Join-Path $env:USERPROFILE ".local\bin") "openclaw.cmd"
if (-not (Test-Path -LiteralPath $wrapper -PathType Leaf)) { return $false }
return ([System.IO.File]::ReadAllText($wrapper) -match '^@echo off\r?\nnode ".+[\\/]dist[\\/]entry\.js" %\*\r?\n?$')
}
function Remove-PreviousGitWrapper {
if (Test-PreviousGitWrapper) {
$wrapper = Join-Path (Join-Path $env:USERPROFILE ".local\bin") "openclaw.cmd"
Remove-Item -LiteralPath $wrapper -Force
Write-Host "[OK] Previous git wrapper retired" -ForegroundColor Green
}
}
function Remove-PreviousNpmOwner {
param([string]$GitWrapper)
$npmCommand = Get-NpmCommandPath
$rootOutput = @(Invoke-NpmCommand -CommandPath $npmCommand -Arguments @("root", "-g") 2>$null)
if ($LASTEXITCODE -ne 0 -or $rootOutput.Count -eq 0) { throw "Could not resolve the previous npm owner." }
$packageRoot = Join-Path $rootOutput[-1].ToString().Trim() "openclaw"
$packageJson = Join-Path $packageRoot "package.json"
if (-not (Test-Path -LiteralPath $packageJson)) { return }
$package = Get-Content -LiteralPath $packageJson -Raw | ConvertFrom-Json
if ($package.name -ne "openclaw") { throw "Refusing to retire a package whose identity is not openclaw." }
$prefixOutput = @(Invoke-NpmCommand -CommandPath $npmCommand -Arguments @("config", "get", "prefix") 2>$null)
$npmShim = if ($prefixOutput.Count -gt 0) { Join-Path $prefixOutput[-1].ToString().Trim() "openclaw.cmd" } else { $null }
if ($npmShim -and [System.IO.Path]::GetFullPath($npmShim) -eq [System.IO.Path]::GetFullPath($GitWrapper)) {
Remove-Item -LiteralPath $packageRoot -Recurse -Force
} else {
Invoke-NpmCommand -CommandPath $npmCommand -Arguments @("uninstall", "-g", "openclaw") | Out-Null
if ($LASTEXITCODE -ne 0) { throw "npm could not retire the previous OpenClaw package." }
}
Write-Host "[OK] Previous npm install retired" -ForegroundColor Green
}
function Start-NpmShimBackup {
param(
[string]$Path,
[string]$ExpectedLauncher
)
$backupPath = Join-Path (Split-Path -Parent $Path) (".openclaw-shim-backup-" + [guid]::NewGuid().ToString("N"))
[System.IO.File]::Move($Path, $backupPath)
return [pscustomobject]@{ Path = $Path; BackupPath = $backupPath; ExpectedLauncher = $ExpectedLauncher }
}
function Test-NpmOpenClawCmdShim {
param(
[string]$Path,
[string]$ExpectedLauncher
)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false }
$item = Get-Item -LiteralPath $Path -Force
if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or $item.Length -gt 16384) { return $false }
$contents = [System.IO.File]::ReadAllText($Path)
if (-not $contents.StartsWith("@ECHO off`r`nGOTO start`r`n", [System.StringComparison]::Ordinal)) { return $false }
$targetMatch = [regex]::Match($contents, '"%(?:~dp0|dp0%)\\(?<target>[^"]+?)"\s+%\*')
if (-not $targetMatch.Success) { return $false }
$resolvedTarget = [System.IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $Path) $targetMatch.Groups["target"].Value))
return [string]::Equals($resolvedTarget, [System.IO.Path]::GetFullPath($ExpectedLauncher), [System.StringComparison]::OrdinalIgnoreCase)
}
function Restore-NpmShimBackup {
param([object]$Backup)
if (-not $Backup -or -not (Test-Path -LiteralPath $Backup.BackupPath -PathType Leaf)) { return }
if (Test-Path -LiteralPath $Backup.Path) {
if (-not (Test-NpmOpenClawCmdShim -Path $Backup.Path -ExpectedLauncher $Backup.ExpectedLauncher)) {
throw "Refusing to replace an unrelated file while restoring $($Backup.Path)."
}
Remove-Item -LiteralPath $Backup.Path -Force
}
[System.IO.File]::Move($Backup.BackupPath, $Backup.Path)
}
function Complete-NpmShimBackup {
param([object]$Backup)
if ($Backup -and (Test-Path -LiteralPath $Backup.BackupPath -PathType Leaf)) {
Remove-Item -LiteralPath $Backup.BackupPath -Force
}
}
# Main installation flow
function Main {
if ($InstallMethod -ne "npm" -and $InstallMethod -ne "git") {
@@ -1849,11 +2077,12 @@ function Main {
# Step 2: OpenClaw
if ($InstallMethod -eq "git") {
$hadNpmOwner = $false
try {
$npmCommand = Get-NpmCommandPath
if ($npmCommand) {
Invoke-NpmCommand -Arguments @("uninstall", "-g", "openclaw") 2>$null | Out-Null
Write-Host "[OK] Removed npm global install if present" -ForegroundColor Green
Invoke-NpmCommand -CommandPath $npmCommand -Arguments @("list", "-g", "openclaw") 2>$null | Out-Null
$hadNpmOwner = ($LASTEXITCODE -eq 0)
}
} catch { }
$finalGitDir = $GitDir
@@ -1862,16 +2091,65 @@ function Main {
Fail-Install
return
}
} else {
$gitWrapper = Join-Path (Join-Path $env:USERPROFILE ".local\\bin") "openclaw.cmd"
if (Test-Path $gitWrapper) {
Remove-Item -Force $gitWrapper
Write-Host "[OK] Removed git wrapper (switching to npm)" -ForegroundColor Green
if ($hadNpmOwner) {
Remove-PreviousNpmOwner -GitWrapper (Join-Path (Join-Path $env:USERPROFILE ".local\bin") "openclaw.cmd")
}
$npmInstallResults = @(Install-OpenClaw)
if (-not (Test-BooleanSuccessResult -Results $npmInstallResults)) {
Fail-Install
return
} else {
$hadGitWrapper = Test-PreviousGitWrapper
$npmShimBackup = $null
try {
$npmCommand = Get-NpmCommandPath
$npmCwd = Get-WindowsCommandSafeDirectory
$prefixOutput = @(Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments @("config", "get", "prefix") 2>$null)
$npmPrefix = if ($prefixOutput.Count -gt 0) { $prefixOutput[-1].ToString().Trim() } else { $null }
$previousGitWrapper = Join-Path (Join-Path $env:USERPROFILE ".local\bin") "openclaw.cmd"
if ($hadGitWrapper) {
foreach ($npmBin in (Get-NpmGlobalBinCandidates -NpmPrefix $npmPrefix)) {
$candidate = Join-Path $npmBin "openclaw.cmd"
if ([string]::Equals([System.IO.Path]::GetFullPath($candidate), [System.IO.Path]::GetFullPath($previousGitWrapper), [System.StringComparison]::OrdinalIgnoreCase)) {
$rootOutput = @(Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments @("root", "-g") 2>$null)
if ($LASTEXITCODE -ne 0 -or $rootOutput.Count -eq 0) {
Fail-Install
return
}
$expectedNpmLauncher = Join-Path $rootOutput[-1].ToString().Trim() "openclaw\openclaw.mjs"
$npmShimBackup = Start-NpmShimBackup -Path $previousGitWrapper -ExpectedLauncher $expectedNpmLauncher
break
}
}
}
$npmInstallResults = @(Install-OpenClaw)
if (-not (Test-BooleanSuccessResult -Results $npmInstallResults)) {
Fail-Install
return
}
if ($hadGitWrapper) {
$npmCandidate = if ($npmShimBackup) { $npmShimBackup.Path } else {
$candidatePath = $null
foreach ($npmBin in (Get-NpmGlobalBinCandidates -NpmPrefix $npmPrefix)) {
$candidate = Join-Path $npmBin "openclaw.cmd"
if (Test-Path -LiteralPath $candidate -PathType Leaf) { $candidatePath = $candidate; break }
}
$candidatePath
}
if (-not $npmCandidate -or -not (Test-Path -LiteralPath $npmCandidate -PathType Leaf)) {
Fail-Install
return
}
& $npmCandidate --version 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Fail-Install
return
}
Complete-NpmShimBackup -Backup $npmShimBackup
$npmShimBackup = $null
Remove-PreviousGitWrapper
}
} finally {
if ($npmShimBackup) {
Restore-NpmShimBackup -Backup $npmShimBackup
}
}
}
@@ -1885,7 +2163,11 @@ function Main {
# Step 3: Run doctor for migrations if upgrading or git install
if ($isUpgrade -or $InstallMethod -eq "git") {
Run-Doctor
$doctorResults = @(Run-Doctor)
if (-not (Test-BooleanSuccessResult -Results $doctorResults)) {
Fail-Install
return
}
}
$installedVersion = $null
@@ -1961,9 +2243,7 @@ function Main {
}
if ($isUpgrade) {
Write-Host "Upgrade complete. Run " -NoNewline
Write-Host "openclaw doctor" -ForegroundColor Cyan -NoNewline
Write-Host " to check for additional migrations."
Write-Host "Upgrade complete." -ForegroundColor Green
} else {
if ($NoOnboard) {
Write-Host "Skipping onboard (requested). Run " -NoNewline
+289 -126
View File
@@ -32,7 +32,14 @@ NODE_SUPPORTED_VERSION_LABEL="22.22.3+, 24.15.0+, or 25.9.0+"
ORIGINAL_PATH="${PATH:-}"
TMPFILES=()
OPENCLAW_BIN_BACKUP_TARGET=""
OPENCLAW_BIN_BACKUP_PATH=""
OPENCLAW_BIN_BACKUP_CANDIDATE=""
OPENCLAW_BIN_BACKUP_DISCARD=0
cleanup_tmpfiles() {
if [[ "$(type -t restore_openclaw_bin_backup 2>/dev/null || true)" == "function" ]]; then
restore_openclaw_bin_backup || true
fi
local f
for f in "${TMPFILES[@]:-}"; do
rm -rf "$f" 2>/dev/null || true
@@ -697,13 +704,62 @@ cleanup_legacy_submodules() {
fi
}
cleanup_npm_openclaw_paths() {
local npm_root=""
npm_root="$(npm root -g 2>/dev/null || true)"
if [[ -z "$npm_root" || "$npm_root" != *node_modules* ]]; then
begin_openclaw_bin_backup() {
local target="$1" candidate="$2" discard="${3:-0}" backup=""
[[ -z "$OPENCLAW_BIN_BACKUP_PATH" ]] || return 0
[[ -e "$target" || -L "$target" ]] || return 0
backup="$(mktemp "${target}.openclaw-backup.XXXXXX")" || return 1
rm -f "$backup" || return 1
OPENCLAW_BIN_BACKUP_TARGET="$target"
OPENCLAW_BIN_BACKUP_PATH="$backup"
OPENCLAW_BIN_BACKUP_CANDIDATE="$candidate"
OPENCLAW_BIN_BACKUP_DISCARD="$discard"
if ! mv "$target" "$backup"; then
OPENCLAW_BIN_BACKUP_TARGET=""
OPENCLAW_BIN_BACKUP_PATH=""
OPENCLAW_BIN_BACKUP_CANDIDATE=""
OPENCLAW_BIN_BACKUP_DISCARD=0
return 1
fi
rm -rf "$npm_root"/.openclaw-* "$npm_root"/openclaw 2>/dev/null || true
}
is_npm_openclaw_shim() {
local target="$1" launcher="$2"
if [[ -L "$target" ]]; then
local link_target=""
link_target="$(readlink "$target" 2>/dev/null || true)"
[[ "$link_target" == "$launcher" || "$link_target" == *"/node_modules/openclaw/openclaw.mjs" ]]
return
fi
[[ -f "$target" ]] && grep -Fq "/node_modules/openclaw/openclaw.mjs" "$target"
}
restore_openclaw_bin_backup() {
local target="$OPENCLAW_BIN_BACKUP_TARGET" backup="$OPENCLAW_BIN_BACKUP_PATH"
[[ -n "$backup" && ( -e "$backup" || -L "$backup" ) ]] || return 0
if [[ -e "$target" || -L "$target" ]]; then
is_npm_openclaw_shim "$target" "$OPENCLAW_BIN_BACKUP_CANDIDATE" || return 1
rm -f "$target" || return 1
fi
mv "$backup" "$target" || return 1
OPENCLAW_BIN_BACKUP_TARGET=""
OPENCLAW_BIN_BACKUP_PATH=""
OPENCLAW_BIN_BACKUP_CANDIDATE=""
OPENCLAW_BIN_BACKUP_DISCARD=0
}
commit_openclaw_bin_backup() {
local backup="$OPENCLAW_BIN_BACKUP_PATH"
[[ -n "$backup" ]] || return 0
if [[ "$OPENCLAW_BIN_BACKUP_DISCARD" == "1" ]]; then
rm -f "$backup" || return 1
else
ui_info "Preserved previous openclaw command at ${backup}"
fi
OPENCLAW_BIN_BACKUP_TARGET=""
OPENCLAW_BIN_BACKUP_PATH=""
OPENCLAW_BIN_BACKUP_CANDIDATE=""
OPENCLAW_BIN_BACKUP_DISCARD=0
}
extract_openclaw_conflict_path() {
@@ -736,23 +792,23 @@ cleanup_openclaw_bin_conflict() {
;;
esac
fi
if [[ -L "$bin_path" ]]; then
local target=""
target="$(readlink "$bin_path" 2>/dev/null || true)"
if [[ "$target" == *"/node_modules/openclaw/"* ]]; then
rm -f "$bin_path"
ui_info "Removed stale openclaw symlink at ${bin_path}"
return 0
fi
return 1
fi
local backup=""
backup="${bin_path}.bak-$(date +%Y%m%d-%H%M%S)"
if mv "$bin_path" "$backup"; then
ui_info "Moved existing openclaw binary to ${backup}"
return 0
fi
return 1
local npm_root=""
npm_root="$(npm root -g 2>/dev/null || true)"
[[ -n "$npm_root" ]] || return 1
begin_openclaw_bin_backup "$bin_path" "${npm_root%/}/openclaw/openclaw.mjs" 0 || return 1
ui_info "Moved existing openclaw command aside for npm retry"
}
cleanup_npm_stale_rename_dirs() {
local npm_root="" stale="" found=0
npm_root="$(npm root -g 2>/dev/null || true)"
[[ -n "$npm_root" && "$npm_root" == *node_modules* ]] || return 1
for stale in "$npm_root"/.openclaw-*; do
[[ -d "$stale" && ! -L "$stale" ]] || continue
found=1
rm -rf "$stale" || return 1
done
(( found == 0 )) || ui_info "Removed interrupted npm rename directories"
}
npm_log_indicates_missing_build_tools() {
@@ -982,49 +1038,109 @@ npm_config_has_raw_key() {
return 1
}
npm_lifecycle_allow_arg() {
local npm_cmd="$1" spec="$2" npm_cwd="${3:-$PWD}" version=""
version="$("$npm_cmd" --version 2>/dev/null | awk 'NF { value = $0 } END { print value }')" || true
if [[ ! "$version" =~ ^[vV]?([0-9]+)\.([0-9]+)\.([0-9]+)([-+][0-9A-Za-z.-]+)?$ ]]; then
echo "Unable to determine npm version from ${npm_cmd}; no package changes were made." >&2
return 1
fi
local major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}"
if (( major < 12 && (major != 11 || minor < 16) )); then
return 0
fi
local identity="$spec" normalized=""
normalized="$(to_lowercase_ascii "$identity")"
if [[ "$normalized" == openclaw@* ]]; then
identity="${identity#*@}"
normalized="$(to_lowercase_ascii "$identity")"
fi
if [[ "$normalized" == npm:* ]]; then
local alias_target="${identity#*:}"
if [[ "$alias_target" == @*/*@* ]]; then identity="${alias_target%@*}"
elif [[ "$alias_target" == *@* ]]; then identity="${alias_target%%@*}"
else identity="$alias_target"; fi
elif ! is_explicit_package_install_spec "$identity" && [[ "$identity" != /* && "$identity" != ./* && "$identity" != ../* && ! "$identity" =~ \.(tgz|tar\.gz)$ ]]; then
identity="openclaw"
fi
if [[ "$identity" == /* ]]; then
# shellcheck disable=SC2016 # JavaScript source must not expand in the installer shell.
identity="$(node -e '
const path = require("node:path");
const relative = path.relative(process.argv[1], process.argv[2]) || ".";
process.stdout.write(path.isAbsolute(relative) || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) ? relative : `.${path.sep}${relative}`);
' "$npm_cwd" "$identity")" || return 1
fi
if [[ -z "$identity" || "$identity" == *,* ]]; then
echo "npm cannot allow lifecycle scripts for install target: ${spec}" >&2
return 1
fi
printf '%s\n' "--allow-scripts=${identity}"
}
verify_npm_lifecycle_completed() {
local npm_cmd="$1" npm_root=""
npm_root="$("$npm_cmd" root -g 2>/dev/null | awk 'NF { value = $0 } END { print value }')" || true
[[ -n "$npm_root" ]] || { echo "Unable to resolve npm global root after install." >&2; return 1; }
[[ ! -e "${npm_root%/}/openclaw/dist/openclaw-install-guard" ]] || {
echo "OpenClaw lifecycle scripts did not complete; refusing installer success." >&2
return 1
}
}
run_npm_global_install() {
local spec="$1"
local log="$2"
local npm_cmd="" lifecycle_arg=""
npm_cmd="$(npm_command_path npm)" || { echo "npm not found on PATH; no package changes were made." >&2; return 1; }
local npm_cwd="$PWD"
lifecycle_arg="$(npm_lifecycle_allow_arg "$npm_cmd" "$spec" "$npm_cwd")" || return 1
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 --global 2>/dev/null || true)"
if npm_config_has_raw_key npm "min-release-age"; then
min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before "$npm_cmd" config get min-release-age --global 2>/dev/null || true)"
if npm_config_has_raw_key "$npm_cmd" "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 --global 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_cmd" 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
fi
local -a cmd
cmd=(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 --loglevel "$NPM_LOGLEVEL")
cmd=(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" --loglevel "$NPM_LOGLEVEL")
if [[ -n "$NPM_SILENT_FLAG" ]]; then
cmd+=("$NPM_SILENT_FLAG")
fi
cmd+=(--no-fund --no-audit "$freshness_flag" install -g "$spec")
cmd+=(--no-fund --no-audit "$freshness_flag" install -g)
[[ -z "$lifecycle_arg" ]] || cmd+=("$lifecycle_arg")
cmd+=("$spec")
local cmd_display=""
printf -v cmd_display '%q ' "${cmd[@]}"
LAST_NPM_INSTALL_CMD="${cmd_display% }"
local install_status=0
if [[ "$VERBOSE" == "1" ]]; then
"${cmd[@]}" < /dev/null 2>&1 | tee "$log"
return $?
fi
if [[ -n "$GUM" ]] && gum_is_tty; then
"${cmd[@]}" < /dev/null 2>&1 | tee "$log" || install_status=$?
elif [[ -n "$GUM" ]] && gum_is_tty; then
local cmd_quoted=""
local log_quoted=""
printf -v cmd_quoted '%q ' "${cmd[@]}"
printf -v log_quoted '%q' "$log"
run_with_spinner "Installing OpenClaw package" bash -c "${cmd_quoted}>${log_quoted} 2>&1"
return $?
run_with_spinner "Installing OpenClaw package" bash -c "${cmd_quoted}>${log_quoted} 2>&1" || install_status=$?
else
ui_info "Installing OpenClaw package"
"${cmd[@]}" < /dev/null >"$log" 2>&1 || install_status=$?
fi
(( install_status == 0 )) || return "$install_status"
}
ui_info "Installing OpenClaw package"
"${cmd[@]}" < /dev/null >"$log" 2>&1
run_verified_npm_global_install() {
local npm_cmd=""
npm_cmd="$(npm_command_path npm)" || return 1
run_npm_global_install "$1" "$2" && verify_npm_lifecycle_completed "$npm_cmd"
}
extract_npm_debug_log_path() {
@@ -1110,12 +1226,12 @@ install_openclaw_npm() {
local spec="$1"
local log
mktempfile log
if ! run_npm_global_install "$spec" "$log"; then
if ! run_verified_npm_global_install "$spec" "$log"; then
local attempted_build_tool_fix=false
if auto_install_build_tools_for_npm_failure "$log"; then
attempted_build_tool_fix=true
ui_info "Retrying npm install after build tools setup"
if run_npm_global_install "$spec" "$log"; then
if run_verified_npm_global_install "$spec" "$log"; then
ui_success "OpenClaw npm package installed"
return 0
fi
@@ -1134,8 +1250,8 @@ install_openclaw_npm() {
if grep -q "ENOTEMPTY: directory not empty, rename .*openclaw" "$log"; then
ui_warn "npm left stale directory; cleaning and retrying"
cleanup_npm_openclaw_paths
if run_npm_global_install "$spec" "$log"; then
cleanup_npm_stale_rename_dirs || return 1
if run_verified_npm_global_install "$spec" "$log"; then
ui_success "OpenClaw npm package installed"
return 0
fi
@@ -1145,7 +1261,7 @@ install_openclaw_npm() {
local conflict=""
conflict="$(extract_openclaw_conflict_path "$log" || true)"
if [[ -n "$conflict" ]] && cleanup_openclaw_bin_conflict "$conflict"; then
if run_npm_global_install "$spec" "$log"; then
if run_verified_npm_global_install "$spec" "$log"; then
ui_success "OpenClaw npm package installed"
return 0
fi
@@ -2323,7 +2439,7 @@ ensure_openclaw_bin_link() {
local npm_root=""
npm_root="$(npm root -g 2>/dev/null || true)"
local launcher="${npm_root}/openclaw/openclaw.mjs"
if [[ -z "$npm_root" || ! -x "$launcher" ]]; then
if [[ -z "$npm_root" || ! -x "$launcher" ]] || ! "$launcher" --version >/dev/null 2>&1; then
return 1
fi
local npm_bin=""
@@ -2331,12 +2447,18 @@ ensure_openclaw_bin_link() {
if [[ -z "$npm_bin" ]]; then
return 1
fi
mkdir -p "$npm_bin"
if [[ ! -x "${npm_bin}/openclaw" ]]; then
ln -sf "$launcher" "${npm_bin}/openclaw"
ui_info "Created openclaw bin link at ${npm_bin}/openclaw"
mkdir -p "$npm_bin" || return 1
local target="${npm_bin}/openclaw" temp=""
if [[ -e "$target" || -L "$target" ]]; then
is_npm_openclaw_shim "$target" "$launcher" || return 1
fi
"${npm_bin}/openclaw" --version >/dev/null 2>&1
temp="$(mktemp "${npm_bin}/.openclaw-link.XXXXXX")" || return 1
TMPFILES+=("$temp")
rm -f "$temp" || return 1
ln -s "$launcher" "$temp" || return 1
mv -f "$temp" "$target" || return 1
ui_info "Published openclaw bin link at ${target}"
"$target" --version >/dev/null 2>&1
}
# Check for existing OpenClaw installation
@@ -2711,8 +2833,8 @@ ensure_user_local_bin_on_path() {
}
npm_global_bin_dir() {
local prefix=""
prefix="$(bounded_probe_output "npm prefix -g" npm prefix -g || true)"
local npm_cmd="${1:-npm}" prefix=""
prefix="$(bounded_probe_output "npm prefix -g" "$npm_cmd" prefix -g || true)"
if [[ -n "$prefix" ]]; then
if [[ "$prefix" == /* ]]; then
echo "${prefix%/}/bin"
@@ -2720,7 +2842,7 @@ npm_global_bin_dir() {
fi
fi
prefix="$(bounded_probe_output "npm config get prefix" npm config get prefix || true)"
prefix="$(bounded_probe_output "npm config get prefix" "$npm_cmd" config get prefix || true)"
if [[ -n "$prefix" && "$prefix" != "undefined" && "$prefix" != "null" ]]; then
if [[ "$prefix" == /* ]]; then
echo "${prefix%/}/bin"
@@ -3093,6 +3215,17 @@ resolve_installed_openclaw_bin() {
resolve_openclaw_bin
}
publish_executable_wrapper() {
local target="$1" target_dir="" temp=""
target_dir="${target%/*}"
mkdir -p "$target_dir"
temp="$(mktemp "${target_dir}/.openclaw-wrapper.XXXXXX")" || return 1
TMPFILES+=("$temp")
cat > "$temp"
chmod +x "$temp"
mv -f "$temp" "$target"
}
install_openclaw_from_git() {
local repo_dir="$1"
local repo_url="https://github.com/openclaw/openclaw.git"
@@ -3162,15 +3295,18 @@ install_openclaw_from_git() {
ui_error "Node.js runtime not found after build"
return 1
fi
if ! "$node_bin" "${repo_dir}/dist/entry.js" --version >/dev/null 2>&1; then
ui_error "Git replacement failed CLI verification"
return 1
fi
printf -v node_bin_quoted "%q" "$node_bin"
printf -v entry_path_quoted "%q" "${repo_dir}/dist/entry.js"
cat > "$HOME/.local/bin/openclaw" <<EOF
publish_executable_wrapper "$HOME/.local/bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec ${node_bin_quoted} ${entry_path_quoted} "\$@"
EOF
chmod +x "$HOME/.local/bin/openclaw"
ui_success "OpenClaw wrapper installed to \$HOME/.local/bin/openclaw"
ui_info "This checkout uses pnpm — run pnpm install (or corepack pnpm install) for deps"
}
@@ -3192,7 +3328,7 @@ to_lowercase_ascii() {
is_explicit_package_install_spec() {
local value="${1:-}"
[[ "$value" == *"://"* || "$value" == *"#"* || "$value" =~ ^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm): ]]
[[ "$value" == *"://"* || "$value" == *"#"* || "$value" == /* || "$value" == ./* || "$value" == ../* || "$value" =~ \.(tgz|tar\.gz)$ || "$value" =~ ^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm): ]]
}
is_openclaw_source_package_install_spec() {
@@ -3287,13 +3423,18 @@ install_openclaw() {
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
if ! install_openclaw_npm "${install_spec}" || ! ensure_openclaw_bin_link; then
ui_error "npm install did not produce a usable OpenClaw package"
restore_openclaw_bin_backup || ui_error "Could not restore the previous openclaw command"
return 1
fi
fi
if ! commit_openclaw_bin_backup; then
restore_openclaw_bin_backup || true
return 1
fi
ui_success "OpenClaw installed"
}
@@ -3310,7 +3451,7 @@ run_doctor() {
return 0
fi
local doctor_exit=0
run_quiet_step "Running doctor" "$claw" doctor --non-interactive || doctor_exit=$?
run_quiet_step "Running doctor" "$claw" doctor --fix --non-interactive || doctor_exit=$?
if (( doctor_exit == 130 )); then
abort_install_int
fi
@@ -3515,6 +3656,51 @@ verify_installation() {
ui_success "Install verify complete"
}
retire_npm_owner_after_git_install() {
local wrapper="$HOME/.local/bin/openclaw" npm_cmd="" npm_root="" npm_bin="" package_root="" package_name=""
npm_cmd="$(npm_command_path npm)" || return 1
npm_root="$("$npm_cmd" root -g 2>/dev/null | awk 'NF { value = $0 } END { print value }')" || true
package_root="${npm_root%/}/openclaw"
[[ -n "$npm_root" && -f "$package_root/package.json" ]] || return 0
package_name="$(node -e 'const p=require(process.argv[1]); process.stdout.write(String(p.name || ""))' "$package_root/package.json" 2>/dev/null || true)"
[[ "$package_name" == "openclaw" ]] || return 1
npm_bin="$(npm_global_bin_dir "$npm_cmd" || true)"
if [[ "${npm_bin%/}/openclaw" == "$wrapper" ]]; then
rm -rf "$package_root" || return 1
else
"$npm_cmd" uninstall -g openclaw >/dev/null 2>&1 || return 1
fi
ui_success "Previous npm install retired"
}
is_installer_git_wrapper() {
local wrapper="${1:-$HOME/.local/bin/openclaw}" first="" second="" third="" fourth=""
[[ -f "$wrapper" && ! -L "$wrapper" ]] || return 1
IFS= read -r first < "$wrapper" || return 1
second="$(sed -n '2p' "$wrapper")"; third="$(sed -n '3p' "$wrapper")"; fourth="$(sed -n '4p' "$wrapper")"
[[ "$first" == "#!/usr/bin/env bash" && "$second" == "set -euo pipefail" && -z "$fourth" ]] || return 1
case "$third" in "exec "*"/dist/entry.js \"\$@\"") return 0 ;; *) return 1 ;; esac
}
prepare_git_wrapper_backup_for_npm() {
local npm_cmd="" npm_root="" npm_bin="" target="" launcher=""
npm_cmd="$(npm_command_path npm)" || return 1
npm_root="$("$npm_cmd" root -g 2>/dev/null || true)"
npm_bin="$(npm_global_bin_dir "$npm_cmd" || true)"
[[ -n "$npm_root" && -n "$npm_bin" ]] || return 1
target="${npm_bin%/}/openclaw"
is_installer_git_wrapper "$target" || return 0
launcher="${npm_root%/}/openclaw/openclaw.mjs"
begin_openclaw_bin_backup "$target" "$launcher" 1
}
retire_git_wrapper_after_npm_install() {
local wrapper="$HOME/.local/bin/openclaw"
is_installer_git_wrapper "$wrapper" || return 0
rm -f "$wrapper" || return 1
ui_success "Previous git wrapper retired"
}
# Main installation flow
main() {
if [[ "$HELP" == "1" ]]; then
@@ -3599,11 +3785,9 @@ main() {
local final_git_dir=""
if [[ "$INSTALL_METHOD" == "git" ]]; then
# Clean up npm global install if switching to git
local had_npm_owner=false
if npm list -g openclaw &>/dev/null; then
ui_info "Removing npm global install (switching to git)"
npm uninstall -g openclaw 2>/dev/null || true
ui_success "npm global install removed"
had_npm_owner=true
fi
local repo_dir="$GIT_DIR"
@@ -3612,14 +3796,10 @@ main() {
fi
final_git_dir="$repo_dir"
install_openclaw_from_git "$repo_dir"
else
# Clean up git wrapper if switching to npm
if [[ -x "$HOME/.local/bin/openclaw" ]]; then
ui_info "Removing git wrapper (switching to npm)"
rm -f "$HOME/.local/bin/openclaw"
ui_success "git wrapper removed"
if [[ "$had_npm_owner" == "true" ]]; then
retire_npm_owner_after_git_install || return $?
fi
else
# Step 3: Git (required for npm installs that may fetch from git or apply patches)
if ! check_git; then
install_git
@@ -3629,7 +3809,15 @@ main() {
fix_npm_permissions
# Step 5: OpenClaw
prepare_git_wrapper_backup_for_npm || return $?
install_openclaw
local npm_candidate=""
npm_candidate="$(resolve_installed_openclaw_bin || true)"
if [[ -z "$npm_candidate" ]] || ! "$npm_candidate" --version >/dev/null 2>&1; then
ui_error "npm replacement failed verification"
return 1
fi
retire_git_wrapper_after_npm_install || return $?
fi
ui_stage "Finalizing setup"
@@ -3655,42 +3843,15 @@ main() {
refresh_gateway_service_if_loaded
fi
local installed_version
installed_version=$(resolve_openclaw_version)
echo ""
if [[ -n "$installed_version" ]]; then
ui_celebrate "🦞 OpenClaw installed successfully (${installed_version})!"
else
ui_celebrate "🦞 OpenClaw installed successfully!"
fi
if [[ "$is_upgrade" == "true" ]]; then
local update_messages=(
"Leveled up! New skills unlocked. You're welcome."
"Fresh code, same lobster. Miss me?"
"Back and better. Did you even notice I was gone?"
"Update complete. I learned some new tricks while I was out."
"Upgraded! Now with 23% more sass."
"I've evolved. Try to keep up. 🦞"
"New version, who dis? Oh right, still me but shinier."
"Patched, polished, and ready to pinch. Let's go."
"The lobster has molted. Harder shell, sharper claws."
"Update done! Check the changelog or just trust me, it's good."
"Reborn from the boiling waters of npm. Stronger now."
"I went away and came back smarter. You should try it sometime."
"Update complete. The bugs feared me, so they left."
"New version installed. Old version sends its regards."
"Firmware fresh. Brain wrinkles: increased."
"I've seen things you wouldn't believe. Anyway, I'm updated."
"Back online. The changelog is long but our friendship is longer."
"Upgraded! Peter fixed stuff. Blame him if it breaks."
"Molting complete. Please don't look at my soft shell phase."
"Version bump! Same chaos energy, fewer crashes (probably)."
)
local update_message
update_message="${update_messages[RANDOM % ${#update_messages[@]}]}"
echo -e "${MUTED}${update_message}${NC}"
else
local installed_version=""
if [[ "$is_upgrade" != "true" ]]; then
installed_version="$(resolve_openclaw_version)"
echo ""
if [[ -n "$installed_version" ]]; then
ui_celebrate "🦞 OpenClaw installed successfully (${installed_version})!"
else
ui_celebrate "🦞 OpenClaw installed successfully!"
fi
local completion_messages=(
"Ahh nice, I like it here. Got any snacks? "
"Home sweet home. Don't worry, I won't rearrange the furniture."
@@ -3706,8 +3867,8 @@ main() {
local completion_message
completion_message="${completion_messages[RANDOM % ${#completion_messages[@]}]}"
echo -e "${MUTED}${completion_message}${NC}"
echo ""
fi
echo ""
if [[ "$INSTALL_METHOD" == "git" && -n "$final_git_dir" ]]; then
local user_claw
@@ -3745,7 +3906,6 @@ main() {
ui_info "No TTY; run ${user_claw} onboard to finish setup"
fi
elif [[ "$is_upgrade" == "true" ]]; then
ui_info "Upgrade complete"
if has_controlling_tty || [[ "$NO_ONBOARD" == "1" || "$NO_PROMPT" == "1" ]]; then
local claw="${OPENCLAW_BIN:-}"
if [[ -z "$claw" ]]; then
@@ -3756,49 +3916,38 @@ main() {
warn_openclaw_not_found
return 0
fi
local -a doctor_args=()
local -a doctor_args=("--fix")
if [[ "$NO_ONBOARD" == "1" || "$NO_PROMPT" == "1" ]]; then
doctor_args+=("--non-interactive")
fi
ui_info "Running openclaw doctor"
local doctor_ok=0
local doctor_exit=0
if (( ${#doctor_args[@]} )); then
if [[ "$NO_ONBOARD" == "1" || "$NO_PROMPT" == "1" ]]; then
OPENCLAW_UPDATE_IN_PROGRESS=1 "$claw" doctor "${doctor_args[@]}" </dev/null || doctor_exit=$?
else
OPENCLAW_UPDATE_IN_PROGRESS=1 "$claw" doctor </dev/tty || doctor_exit=$?
OPENCLAW_UPDATE_IN_PROGRESS=1 "$claw" doctor "${doctor_args[@]}" </dev/tty || doctor_exit=$?
fi
if (( doctor_exit == 130 )); then
abort_install_int
fi
# Clear dashboard flag if the doctor was cancelled or failed,
# since the upgrade did not complete successfully.
if (( doctor_exit != 0 )); then
should_open_dashboard=false
fi
if (( doctor_exit == 0 )); then
doctor_ok=1
fi
if (( doctor_ok )); then
should_open_dashboard=true
ui_info "Updating plugins"
OPENCLAW_UPDATE_IN_PROGRESS=1 run_with_safe_stdin "$claw" plugins update --all || true
else
ui_warn "Doctor failed; skipping plugin updates"
return "$doctor_exit"
fi
should_open_dashboard=true
ui_info "Updating plugins"
OPENCLAW_UPDATE_IN_PROGRESS=1 run_with_safe_stdin "$claw" plugins update --all || true
else
if run_doctor; then
should_open_dashboard=true
fi
run_doctor || return $?
should_open_dashboard=true
local user_claw
user_claw="$(openclaw_command_for_user "${OPENCLAW_BIN:-}")"
ui_info "No TTY; run ${user_claw} plugins update --all manually"
fi
else
ui_info "Config already present; running doctor"
if run_doctor; then
should_open_dashboard=true
fi
run_doctor || return $?
should_open_dashboard=true
ui_info "Config already present; skipping onboarding"
fi
@@ -3823,10 +3972,24 @@ main() {
fi
fi
if [[ "$is_upgrade" == "true" ]]; then
VERIFY_INSTALL=1
fi
if ! verify_installation "$config_present"; then
exit 1
fi
if [[ "$is_upgrade" == "true" ]]; then
installed_version="$(resolve_openclaw_version)"
echo ""
if [[ -n "$installed_version" ]]; then
ui_celebrate "🦞 OpenClaw installed successfully (${installed_version})!"
else
ui_celebrate "🦞 OpenClaw installed successfully!"
fi
ui_info "Upgrade complete"
fi
if [[ "$should_open_dashboard" == "true" ]]; then
maybe_open_dashboard
fi
+67 -6
View File
@@ -1211,6 +1211,7 @@ describe("update-cli", () => {
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(logs).toContain(message);
expect(logs).not.toContain("Gateway: restarted and verified.");
expect(logs).not.toContain("Update Result: OK");
};
const mockGatewayProbe = (version: string, connId: string) => {
@@ -2038,6 +2039,7 @@ describe("update-cli", () => {
expect(serviceStop).toHaveBeenCalled();
expectNoSideEffects(serviceRestart, runDaemonRestart);
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(getLogOutput()).not.toContain("Update Result: OK");
expect(
requireValue(spawn.mock.invocationCallOrder[0], "post-core update process order"),
).toBeLessThan(
@@ -5311,6 +5313,7 @@ describe("update-cli", () => {
"utf-8",
);
await fs.writeFile(serviceEntrypoint, "export {};\n", "utf-8");
const canonicalGitRoot = await fs.realpath(gitRoot);
mockPackageInstallStatus(packageRoot);
pathExists.mockImplementation(async (candidate: string) => candidate === gitRoot);
mockRunningManagedGateway(["node", serviceEntrypoint, "gateway", "run"]);
@@ -5328,7 +5331,7 @@ describe("update-cli", () => {
expect(serviceStop).toHaveBeenCalledTimes(1);
expect(runGatewayUpdate).toHaveBeenCalledTimes(1);
const updateCall = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(updateCall?.cwd).toBe(gitRoot);
expect(updateCall?.cwd).toBe(canonicalGitRoot);
expect(updateCall?.beforeGitMutation).toEqual(expect.any(Function));
});
@@ -5349,6 +5352,7 @@ describe("update-cli", () => {
"utf-8",
);
await fs.writeFile(packageEntrypoint, "export {};\n", "utf-8");
const canonicalGitRoot = await fs.realpath(gitRoot);
mockPackageInstallStatus(packageRoot);
pathExists.mockImplementation(async (candidate: string) => candidate === gitRoot);
mockRunningManagedGateway(["node", packageEntrypoint, "gateway", "run"]);
@@ -5366,7 +5370,7 @@ describe("update-cli", () => {
expect(serviceStop).toHaveBeenCalledTimes(1);
expect(runGatewayUpdate).toHaveBeenCalledTimes(1);
const updateCall = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(updateCall?.cwd).toBe(gitRoot);
expect(updateCall?.cwd).toBe(canonicalGitRoot);
expect(updateCall?.beforeGitMutation).toEqual(expect.any(Function));
});
@@ -5381,12 +5385,13 @@ describe("update-cli", () => {
const checkoutAlias = path.join(root, "checkout-alias");
await Promise.all([fs.mkdir(targetRoot), fs.mkdir(replacementRoot)]);
await fs.symlink(targetRoot, checkoutAlias, "dir");
const publishedRoot = await fs.realpath(checkoutAlias);
mockPackageInstallStatus(packageRoot);
mockFileBackedPathExists();
mockNoopPostUpdatePluginConvergence();
vi.mocked(runGatewayUpdate).mockImplementationOnce(async (options) => {
expect(options?.cwd).toBe(targetRoot);
return makeOkUpdateResult({ mode: "git", root: targetRoot });
expect(options?.cwd).toBe(publishedRoot);
return makeOkUpdateResult({ mode: "git", root: publishedRoot });
});
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (argv[1] === "--version") {
@@ -5414,13 +5419,67 @@ describe("update-cli", () => {
});
const installCall = packageInstallCommandCall();
expect(installCall?.[0]).toContain(targetRoot);
expect(installCall?.[0]).toContain(publishedRoot);
expect(installCall?.[0]).not.toContain(checkoutAlias);
expect(installCall?.[1].cwd).toBe(targetRoot);
expect(installCall?.[1].cwd).toBe(publishedRoot);
await expect(fs.readdir(replacementRoot)).resolves.toEqual([]);
},
);
it("preserves the package and shim when package-to-Git staged activation fails", async () => {
const root = await createTrackedTempDir("openclaw-update-package-to-git-fail-");
const prefix = path.join(root, "prefix");
const nodeModules = path.join(prefix, "lib", "node_modules");
const packageRoot = path.join(nodeModules, "openclaw");
const shim = path.join(prefix, "bin", "openclaw");
const gitRoot = path.join(root, "git-root");
await writeOpenClawPackageFixture(packageRoot, "2026.4.20", {
entrySource: "export {};\n",
inventory: true,
});
await fs.mkdir(path.dirname(shim), { recursive: true });
await fs.writeFile(shim, "old package shim\n", { mode: 0o755 });
await fs.mkdir(path.join(gitRoot, ".git"), { recursive: true });
await fs.writeFile(
path.join(gitRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.8.18" }),
"utf8",
);
const packageBefore = await fs.readFile(path.join(packageRoot, "package.json"), "utf8");
const shimBefore = await fs.readFile(shim, "utf8");
mockPackageInstallStatus(packageRoot);
mockFileBackedPathExists();
mockGitUpdateAfterMutation(makeOkUpdateResult({ mode: "git", root: gitRoot }));
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (argv[1] === "--version") {
return commandResult({ stdout: "12.0.0\n" });
}
if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: `${nodeModules}\n` });
}
if (argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g") {
return commandResult({ code: 1, stderr: "candidate verification fixture failure" });
}
return commandResult();
});
await withEnvAsync({ OPENCLAW_GIT_DIR: gitRoot }, async () => {
await updateCommand({ channel: "dev", yes: true, restart: false });
});
const installCalls = commandCalls().filter(
([argv]) => argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g",
);
expect(installCalls).toHaveLength(2);
expect(installCalls.every(([argv]) => argv.includes("--prefix"))).toBe(true);
await expect(fs.readFile(path.join(packageRoot, "package.json"), "utf8")).resolves.toBe(
packageBefore,
);
await expect(fs.readFile(shim, "utf8")).resolves.toBe(shimBefore);
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("does not stop or restart a managed gateway owned by another git checkout", async () => {
const otherRoot = await createTrackedTempDir("openclaw-update-other-service-root-");
const otherEntrypoint = path.join(otherRoot, "dist", "index.js");
@@ -5464,6 +5523,8 @@ describe("update-cli", () => {
expect(serviceStop).toHaveBeenCalledTimes(1);
expectNoSideEffects(serviceRestart, runDaemonRestart);
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(getLogOutput()).toContain("Update Result: ERROR");
expect(getErrorOutput()).not.toContain("Update failed during plugin post-update sync.");
});
it("restarts a stopped git service when the fresh plugin doctor cannot run", async () => {
+97 -22
View File
@@ -1,11 +1,13 @@
import fs from "node:fs/promises";
import path from "node:path";
import { runGlobalPackageUpdateSteps } from "../../infra/package-update-steps.js";
import { hasNodeErrorCode } from "../../infra/path-guards.js";
import type { UpdateChannel } from "../../infra/update-channels.js";
import type { DevUpdateTarget } from "../../infra/update-dev-target.js";
import {
createGlobalInstallEnv,
globalInstallArgs,
resolveGlobalInstallTarget,
resolveNpmLifecyclePolicyGate,
resolvePnpmGlobalDirFromGlobalRoot,
} from "../../infra/update-global.js";
import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js";
import { defaultRuntime } from "../../runtime.js";
@@ -17,10 +19,13 @@ import {
type OpenClawDatabaseSchemaPreflight,
} from "../../state/openclaw-database-preflight.js";
import type { OpenClawSchemaVersions } from "../../state/openclaw-schema-versions.js";
import { splitShellArgs } from "../../utils/shell-argv.js";
import { createUpdateProgress, printResult } from "./progress.js";
import {
createGlobalCommandRunner,
DEFAULT_PACKAGE_NAME,
ensureGitCheckout,
readPackageName,
resolveGitInstallDir,
resolveGlobalManager,
runUpdateStep,
@@ -30,6 +35,82 @@ import { UpdateCommandAbort, type PreManagedServiceStop } from "./update-command
const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000;
export async function retireStandaloneGitWrapper(params: {
previousRoot: string;
platform?: NodeJS.Platform;
searchDirs?: readonly string[];
}): Promise<{ error?: string }> {
const platform = params.platform ?? process.platform;
const wrapperName = platform === "win32" ? "openclaw.cmd" : "openclaw";
const searchDirs = params.searchDirs ?? (process.env.PATH ?? "").split(path.delimiter);
const expectedEntry =
platform === "win32"
? path.win32.join(params.previousRoot, "dist", "entry.js")
: path.join(params.previousRoot, "dist", "entry.js");
const seen = new Set<string>();
for (const directory of searchDirs) {
if (!directory) {
continue;
}
const wrapperPath = path.resolve(directory, wrapperName);
if (seen.has(wrapperPath)) {
continue;
}
seen.add(wrapperPath);
let stat;
try {
stat = await fs.lstat(wrapperPath);
} catch (error) {
if (hasNodeErrorCode(error, "ENOENT")) {
continue;
}
return { error: `Could not inspect ${wrapperPath}: ${String(error)}` };
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size > 4096 ||
(platform !== "win32" && (stat.mode & 0o111) === 0)
) {
continue;
}
let contents: string;
try {
contents = await fs.readFile(wrapperPath, "utf8");
} catch (error) {
return { error: `Could not inspect ${wrapperPath}: ${String(error)}` };
}
const lines = contents.trimEnd().split(/\r?\n/u);
const matchesWindows =
platform === "win32" &&
lines.length === 2 &&
lines[0] === "@echo off" &&
lines[1] === `node "${expectedEntry}" %*`;
const execArgs =
platform === "win32" || lines.length !== 3 ? null : splitShellArgs(lines[2] ?? "");
const matchesPosix =
platform !== "win32" &&
lines[0] === "#!/usr/bin/env bash" &&
lines[1] === "set -euo pipefail" &&
execArgs?.length === 4 &&
execArgs[0] === "exec" &&
execArgs[2] === expectedEntry &&
execArgs[3] === "$@";
if (!matchesWindows && !matchesPosix) {
continue;
}
try {
await fs.unlink(wrapperPath);
} catch (error) {
return { error: `Could not retire ${wrapperPath}: ${String(error)}` };
}
}
return {};
}
type BeforeGitMutation = (target: {
schemaVersions?: OpenClawSchemaVersions;
metadataUnreadable?: string;
@@ -216,31 +297,25 @@ export async function updateGitInstall(params: {
if (!installTarget) {
throw new Error("global install target missing after package-to-Git preflight");
}
const installLocation =
installTarget.manager === "pnpm"
? resolvePnpmGlobalDirFromGlobalRoot(installTarget.globalRoot)
: null;
const installStep = await runUpdateStep({
name: "global install",
argv: globalInstallArgs(
installTarget,
updateRoot,
undefined,
installLocation,
updateRoot,
npmLifecycleGate.policy ?? undefined,
),
cwd: updateRoot,
env: installEnv,
const packageName =
(await readPackageName(installTarget.packageRoot ?? params.root)) ?? DEFAULT_PACKAGE_NAME;
const packageUpdate = await runGlobalPackageUpdateSteps({
installTarget,
installSpec: updateRoot,
packageName,
packageRoot: installTarget.packageRoot,
runCommand,
runStep: (stepParams) => runUpdateStep({ ...stepParams, progress: params.progress }),
timeoutMs: effectiveTimeout,
progress: params.progress,
env: installEnv,
installCwd: updateRoot,
});
steps.push(installStep);
steps.push(...packageUpdate.steps);
const failedStep = installStep.exitCode !== 0 ? installStep : null;
return {
...updateResult,
status: updateResult.status === "ok" && !failedStep ? "ok" : "error",
status: packageUpdate.failedStep ? "error" : "ok",
reason: packageUpdate.failedStep?.name,
steps,
durationMs: Date.now() - params.startedAt,
};
@@ -1,28 +1,295 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { defaultRuntime } from "../../runtime.js";
const mocks = vi.hoisted(() => ({
completePluginUpdate: vi.fn(),
markSentinelFailure: vi.fn(async () => undefined),
printResult: vi.fn(),
readConfig: vi.fn(),
restart: vi.fn(async () => undefined),
restartService: vi.fn(async () => true),
restoreWindowsAutoStart: vi.fn(async () => true),
tryInstallCompletion: vi.fn(async () => undefined),
tryWriteCompletionCache: vi.fn(async () => undefined),
updatePlugins: vi.fn(),
writeSentinel: vi.fn(async () => undefined),
}));
vi.mock("./progress.js", () => ({ printResult: vi.fn() }));
vi.mock("./progress.js", () => ({ printResult: mocks.printResult }));
vi.mock("../../config/config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../config/config.js")>()),
readConfigFileSnapshot: mocks.readConfig,
}));
vi.mock("../../plugins/plugin-lifecycle-lease.js", () => ({
withPluginLifecycleLease: async (_params: unknown, callback: () => unknown) => callback(),
}));
vi.mock("./update-command-config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./update-command-config.js")>()),
persistRequestedUpdateChannel: async (params: { configSnapshot: unknown }) =>
params.configSnapshot,
restoreDroppedPreUpdateChannels: (snapshot: unknown) => ({
snapshot,
changed: false,
authoredChannels: [],
}),
}));
vi.mock("./update-command-fresh-doctor.js", () => ({
completePostCorePluginUpdate: mocks.completePluginUpdate,
}));
vi.mock("./update-command-plugins.js", () => ({
updatePluginsAfterCoreUpdate: mocks.updatePlugins,
}));
vi.mock("./shared.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./shared.js")>()),
tryWriteCompletionCache: mocks.tryWriteCompletionCache,
}));
vi.mock("./update-command-service.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./update-command-service.js")>()),
maybeRestartService: mocks.restartService,
maybeRestartServiceAfterFailedMutableUpdate: mocks.restart,
restoreWindowsTaskAutoStartOrExit: mocks.restoreWindowsAutoStart,
tryInstallShellCompletion: mocks.tryInstallCompletion,
}));
vi.mock("./update-command-post-core.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./update-command-post-core.js")>()),
markControlPlaneUpdateRestartSentinelFailureBestEffort: mocks.markSentinelFailure,
writeControlPlaneUpdateRestartSentinelBestEffort: mocks.writeSentinel,
}));
import { retireStandaloneGitWrapper } from "./update-command-git.js";
import { finishUpdate } from "./update-command-post-update.js";
type FinishUpdateParams = Parameters<typeof finishUpdate>[0];
const validConfigSnapshot = {
valid: true,
parsed: {},
config: {},
runtimeConfig: {},
sourceConfig: {},
warnings: [],
issues: [],
legacyIssues: [],
};
const successfulPluginUpdate = {
status: "ok",
changed: false,
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: { changed: false, outcomes: [] },
integrityDrifts: [],
warnings: [],
};
async function finishSuccessfulPackageSwitch(params: {
previousRoot: string;
packageRoot: string;
}): Promise<void> {
await finishUpdate({
result: {
status: "ok",
mode: "npm",
root: params.packageRoot,
steps: [],
durationMs: 1,
},
root: params.packageRoot,
previousInstallRoot: params.previousRoot,
installKindChanged: true,
configSnapshot: validConfigSnapshot,
requestedChannel: null,
storedChannel: null,
channel: "stable",
downgradeRisk: true,
shouldRestart: false,
opts: {},
showProgress: false,
controlPlaneUpdateSentinelMeta: {},
preUpdatePluginInstallRecords: {},
startedAt: Date.now(),
updateStepTimeoutMs: 1_000,
} as unknown as FinishUpdateParams);
}
describe("retireStandaloneGitWrapper", () => {
it("removes only the installer wrapper for the previous checkout", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-wrapper-retire-"));
const oldRoot = path.join(home, "old checkout");
const unrelatedWrapper = path.join(home, "earlier", "openclaw");
const wrapper = path.join(home, ".local", "bin", "openclaw");
const secondWrapper = path.join(home, "legacy", "bin", "openclaw");
const oldWrapperContents = `#!/usr/bin/env bash\nset -euo pipefail\nexec /usr/bin/node ${oldRoot.replaceAll(" ", "\\ ")}/dist/entry.js "$@"\n`;
await Promise.all([
fs.mkdir(path.dirname(unrelatedWrapper), { recursive: true }),
fs.mkdir(path.dirname(wrapper), { recursive: true }),
fs.mkdir(path.dirname(secondWrapper), { recursive: true }),
]);
await fs.writeFile(unrelatedWrapper, "#!/usr/bin/env bash\necho unrelated\n", { mode: 0o755 });
await Promise.all([
fs.writeFile(wrapper, oldWrapperContents, { mode: 0o755 }),
fs.writeFile(secondWrapper, oldWrapperContents, { mode: 0o755 }),
]);
try {
await expect(
retireStandaloneGitWrapper({
previousRoot: oldRoot,
platform: "linux",
searchDirs: [
path.dirname(unrelatedWrapper),
path.dirname(wrapper),
path.dirname(secondWrapper),
],
}),
).resolves.toEqual({});
await expect(fs.readFile(unrelatedWrapper, "utf8")).resolves.toContain("unrelated");
await expect(fs.stat(wrapper)).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.stat(secondWrapper)).rejects.toMatchObject({ code: "ENOENT" });
await fs.writeFile(
wrapper,
"#!/usr/bin/env node\nimport '../lib/node_modules/openclaw/openclaw.mjs';\n",
{ mode: 0o755 },
);
await expect(
retireStandaloneGitWrapper({
previousRoot: oldRoot,
platform: "linux",
searchDirs: [path.dirname(wrapper)],
}),
).resolves.toEqual({});
await expect(fs.stat(wrapper)).resolves.toBeDefined();
} finally {
await fs.rm(home, { recursive: true, force: true });
}
});
it("removes only the exact PowerShell installer wrapper on Windows", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-wrapper-retire-win-"));
const oldRoot = "C:\\Users\\operator\\openclaw";
const wrapper = path.join(home, ".local", "bin", "openclaw.cmd");
await fs.mkdir(path.dirname(wrapper), { recursive: true });
await fs.writeFile(
wrapper,
`@echo off\r\nnode "${path.win32.join(oldRoot, "dist", "entry.js")}" %*\r\n`,
);
try {
await expect(
retireStandaloneGitWrapper({
previousRoot: oldRoot,
platform: "win32",
searchDirs: [path.dirname(wrapper)],
}),
).resolves.toEqual({});
await expect(fs.stat(wrapper)).rejects.toMatchObject({ code: "ENOENT" });
await fs.writeFile(wrapper, "@echo off\r\necho unrelated\r\n");
await expect(
retireStandaloneGitWrapper({
previousRoot: oldRoot,
platform: "win32",
searchDirs: [path.dirname(wrapper)],
}),
).resolves.toEqual({});
await expect(fs.readFile(wrapper, "utf8")).resolves.toContain("unrelated");
} finally {
await fs.rm(home, { recursive: true, force: true });
}
});
});
describe("successful update finalization ordering", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.readConfig.mockResolvedValue(validConfigSnapshot);
mocks.updatePlugins.mockResolvedValue(successfulPluginUpdate);
mocks.completePluginUpdate.mockResolvedValue({
pluginUpdate: successfulPluginUpdate,
configSnapshot: validConfigSnapshot,
});
vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined as never);
vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
vi.spyOn(defaultRuntime, "log").mockImplementation(() => undefined);
});
it("retires the wrapper before persisting and printing success", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-finalize-order-"));
const previousRoot = path.join(home, "old-root");
const wrapper = path.join(home, ".local", "bin", "openclaw");
await fs.mkdir(path.dirname(wrapper), { recursive: true });
await fs.writeFile(
wrapper,
`#!/usr/bin/env bash\nset -euo pipefail\nexec /usr/bin/node ${previousRoot}/dist/entry.js "$@"\n`,
{ mode: 0o755 },
);
const previousPath = process.env.PATH;
process.env.PATH = path.dirname(wrapper);
const unlink = vi.spyOn(fs, "unlink");
try {
await finishSuccessfulPackageSwitch({
previousRoot,
packageRoot: path.join(home, "package"),
});
expect(mocks.writeSentinel).toHaveBeenCalledTimes(2);
expect(unlink.mock.invocationCallOrder[0]).toBeLessThan(
mocks.writeSentinel.mock.invocationCallOrder[1] ?? Number.POSITIVE_INFINITY,
);
expect(mocks.writeSentinel.mock.invocationCallOrder[1]).toBeLessThan(
mocks.printResult.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
} finally {
unlink.mockRestore();
process.env.PATH = previousPath;
await fs.rm(home, { recursive: true, force: true });
}
});
it("marks and prints an error without persisting success when retirement fails", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-finalize-failure-"));
const previousRoot = path.join(home, "old-root");
const wrapper = path.join(home, ".local", "bin", "openclaw");
await fs.mkdir(path.dirname(wrapper), { recursive: true });
await fs.writeFile(
wrapper,
`#!/usr/bin/env bash\nset -euo pipefail\nexec /usr/bin/node ${previousRoot}/dist/entry.js "$@"\n`,
{ mode: 0o755 },
);
const previousPath = process.env.PATH;
process.env.PATH = path.dirname(wrapper);
const unlink = vi.spyOn(fs, "unlink").mockRejectedValueOnce(new Error("unlink denied"));
try {
await finishSuccessfulPackageSwitch({
previousRoot,
packageRoot: path.join(home, "package"),
});
expect(mocks.writeSentinel).toHaveBeenCalledTimes(1);
expect(mocks.markSentinelFailure).toHaveBeenCalledWith(
expect.objectContaining({ reason: "wrapper-retirement-failed" }),
);
expect(mocks.printResult).toHaveBeenCalledWith(
expect.objectContaining({ status: "error", reason: "wrapper-retirement-failed" }),
expect.any(Object),
);
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
} finally {
unlink.mockRestore();
process.env.PATH = previousPath;
await fs.rm(home, { recursive: true, force: true });
}
});
});
function failedResult(recovery: UpdateRunResult["recovery"]): UpdateRunResult {
return {
status: "error",
@@ -29,6 +29,7 @@ import {
restoreDroppedPreUpdateChannels,
} from "./update-command-config.js";
import { completePostCorePluginUpdate } from "./update-command-fresh-doctor.js";
import { retireStandaloneGitWrapper } from "./update-command-git.js";
import { withOwnedManagedUpdateEnv } from "./update-command-managed-context.js";
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
import {
@@ -87,6 +88,7 @@ function pickUpdateQuip(): string {
export async function finishUpdate(params: {
result: UpdateRunResult;
root: string;
previousInstallRoot?: string;
installKindChanged: boolean;
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
requestedChannel: UpdateChannel | null;
@@ -105,7 +107,7 @@ export async function finishUpdate(params: {
updateStepTimeoutMs: number;
invocationCwd?: string;
}): Promise<void> {
if (!params.opts.json || params.result.status !== "ok") {
if (params.result.status !== "ok") {
printResult(params.result, { ...params.opts, hideSteps: params.showProgress });
}
@@ -343,11 +345,7 @@ export async function finishUpdate(params: {
jsonMode: Boolean(params.opts.json),
});
}
if (params.opts.json) {
defaultRuntime.writeJson(resultWithPostUpdate);
} else {
defaultRuntime.error(theme.error("Update failed during plugin post-update sync."));
}
printResult(resultWithPostUpdate, { ...params.opts, hideSteps: params.showProgress });
defaultRuntime.exit(1);
return;
}
@@ -490,15 +488,36 @@ export async function finishUpdate(params: {
return;
}
if (params.installKindChanged && resultWithPostUpdate.mode !== "git") {
const retirement = await retireStandaloneGitWrapper({
previousRoot: params.previousInstallRoot ?? params.root,
});
if (retirement.error) {
defaultRuntime.error(retirement.error);
await markControlPlaneUpdateRestartSentinelFailureBestEffort({
meta: params.controlPlaneUpdateSentinelMeta,
reason: "wrapper-retirement-failed",
jsonMode: Boolean(params.opts.json),
});
const failedResult: UpdateRunResult = {
...resultWithPostUpdate,
status: "error",
reason: "wrapper-retirement-failed",
};
printResult(failedResult, { ...params.opts, hideSteps: params.showProgress });
defaultRuntime.exit(1);
return;
}
}
await writeControlPlaneUpdateRestartSentinelBestEffort({
meta: params.controlPlaneUpdateSentinelMeta,
result: resultWithPostUpdate,
jsonMode: Boolean(params.opts.json),
});
printResult(resultWithPostUpdate, { ...params.opts, hideSteps: params.showProgress });
if (!params.opts.json) {
defaultRuntime.log(theme.muted(pickUpdateQuip()));
} else {
defaultRuntime.writeJson(resultWithPostUpdate);
}
}
+1
View File
@@ -663,6 +663,7 @@ async function updateCommandInternal(
await finishUpdate({
result,
root,
previousInstallRoot: discoveredRoot,
installKindChanged: switchToGit || switchToPackage,
configSnapshot: finalizationConfigSnapshot,
requestedChannel,
+7 -11
View File
@@ -5,10 +5,8 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js";
const resolveCleanupPlanForDryRun = vi.fn();
export const resolveCleanupPlanForRemoval = vi.fn();
const removePath = vi.fn();
export const removePath = vi.fn();
export const listAgentSessionDirs = vi.fn();
export const prepareLegacyWorkspaceStateReset = vi.fn();
export const removeLegacyWorkspaceStateForReset = vi.fn();
export const removeStateAndLinkedPaths = vi.fn();
export const removeWorkspaceDirs = vi.fn();
const gatewayServiceState = vi.hoisted(() => ({
@@ -20,11 +18,6 @@ const gatewayServiceState = vi.hoisted(() => ({
export const gatewayService = gatewayServiceState;
const cleanupConfigState = vi.hoisted(() => ({ isNixMode: false }));
vi.mock("../agents/workspace-legacy-state.js", () => ({
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
}));
vi.mock("../config/config.js", () => ({
get isNixMode() {
return cleanupConfigState.isNixMode;
@@ -66,10 +59,8 @@ export function resetCleanupCommandMocks() {
resolveCleanupPlanForRemoval.mockResolvedValue(cleanupPlan);
removePath.mockResolvedValue({ ok: true });
listAgentSessionDirs.mockResolvedValue(["/tmp/.openclaw/agents/main/sessions"]);
prepareLegacyWorkspaceStateReset.mockImplementation((workspaceDir: string) => ({ workspaceDir }));
removeLegacyWorkspaceStateForReset.mockResolvedValue({ removedPaths: [], warnings: [] });
removeStateAndLinkedPaths.mockResolvedValue(true);
removeWorkspaceDirs.mockResolvedValue(undefined);
removeWorkspaceDirs.mockResolvedValue([]);
gatewayService.isLoaded.mockReset().mockResolvedValue(true);
gatewayService.stop.mockReset().mockResolvedValue(undefined);
gatewayService.uninstall.mockReset().mockResolvedValue(undefined);
@@ -89,3 +80,8 @@ export function cleanupCommandLogMessages(runtime: RuntimeEnv): string[] {
const calls = (runtime.log as MockFn<(...args: unknown[]) => void>).mock.calls;
return calls.map((call) => String(call[0]));
}
export function cleanupCommandErrorMessages(runtime: RuntimeEnv): string[] {
const calls = (runtime.error as MockFn<(...args: unknown[]) => void>).mock.calls;
return calls.map((call) => String(call[0]));
}
+12 -2
View File
@@ -125,6 +125,7 @@ describe("destructive cleanup with a live unmanaged state owner", () => {
nixMode: false,
preservesWorkspace: false,
serviceChecks: 1,
aggregatesFailure: false,
run: (runtime: ReturnType<typeof createNonExitingRuntime>) =>
resetCommand(runtime, { scope: "full", yes: true, nonInteractive: true }),
},
@@ -133,6 +134,7 @@ describe("destructive cleanup with a live unmanaged state owner", () => {
nixMode: true,
preservesWorkspace: false,
serviceChecks: 0,
aggregatesFailure: false,
run: (runtime: ReturnType<typeof createNonExitingRuntime>) =>
resetCommand(runtime, { scope: "full", yes: true, nonInteractive: true }),
},
@@ -141,12 +143,13 @@ describe("destructive cleanup with a live unmanaged state owner", () => {
nixMode: false,
preservesWorkspace: true,
serviceChecks: 0,
aggregatesFailure: true,
run: (runtime: ReturnType<typeof createNonExitingRuntime>) =>
uninstallCommand(runtime, { state: true, yes: true, nonInteractive: true }),
},
])(
"refuses $command until the SQLite owner exits",
async ({ nixMode, preservesWorkspace, run, serviceChecks }) => {
async ({ aggregatesFailure, nixMode, preservesWorkspace, run, serviceChecks }) => {
const state = await createOpenClawTestState({
prefix: "openclaw-cleanup-live-state-",
layout: "split",
@@ -173,7 +176,14 @@ describe("destructive cleanup with a live unmanaged state owner", () => {
const blockedRuntime = createNonExitingRuntime();
vi.spyOn(blockedRuntime, "log").mockImplementation(() => {});
vi.spyOn(blockedRuntime, "error").mockImplementation(() => {});
await expect(run(blockedRuntime)).rejects.toThrow(/Gateway|state directory/i);
if (aggregatesFailure) {
await expect(run(blockedRuntime)).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(blockedRuntime.error).toHaveBeenCalledWith(
expect.stringMatching(/Gateway|state directory/i),
);
} else {
await expect(run(blockedRuntime)).rejects.toThrow(/Gateway|state directory/i);
}
expect(gatewayService.isLoaded).toHaveBeenCalledTimes(serviceChecks);
expect(owner.exitCode).toBeNull();
await expect(fs.readFile(markerPath, "utf8")).resolves.toBe("preserved");
-1
View File
@@ -43,7 +43,6 @@ export async function resolveCleanupPlanForRemoval(runtime: RuntimeEnv) {
runtime.error(
`Cannot safely remove OpenClaw state because workspace configuration could not be resolved: ${issueSummary}. Fix the configuration and retry.`,
);
runtime.exit(1);
return undefined;
}
return buildCleanupPlanForConfig(snapshot.runtimeConfig);
+17
View File
@@ -297,6 +297,23 @@ describe("cleanup path removals", () => {
expect(stateRemoved).toBe(true);
});
it("returns failure when any linked dry-run target is unsafe", async () => {
const runtime = createRuntimeMock();
await expect(
removeStateAndLinkedPaths(
{
stateDir: "/tmp/openclaw-cleanup/state",
configPath: path.parse(process.cwd()).root,
oauthDir: "/tmp/openclaw-cleanup/oauth",
configInsideState: false,
oauthInsideState: false,
},
runtime,
{ dryRun: true },
),
).resolves.toBe(false);
});
it("keeps the canonical state lock visible until state removal completes", async () => {
const runtime = createRuntimeMock();
const tmpRoot = await fs.realpath(tempDirs.make("openclaw-cleanup-lock-visible-"));
+13 -10
View File
@@ -410,13 +410,13 @@ export async function removeStateAndLinkedPaths(
dryRun: true,
label: cleanup.stateDir,
});
if (!cleanup.configInsideState) {
await removePath(cleanup.configPath, runtime, { dryRun: true, label: cleanup.configPath });
}
if (!cleanup.oauthInsideState) {
await removePath(cleanup.oauthDir, runtime, { dryRun: true, label: cleanup.oauthDir });
}
return stateRemoval.ok;
const configRemoval = cleanup.configInsideState
? { ok: true }
: await removePath(cleanup.configPath, runtime, { dryRun: true, label: cleanup.configPath });
const oauthRemoval = cleanup.oauthInsideState
? { ok: true }
: await removePath(cleanup.oauthDir, runtime, { dryRun: true, label: cleanup.oauthDir });
return stateRemoval.ok && configRemoval.ok && oauthRemoval.ok;
}
if (isUnsafeRemovalTarget(requestedStateDir)) {
runtime.error(`Refusing to remove unsafe path: ${shortenHomeInString(cleanup.stateDir)}`);
@@ -516,6 +516,7 @@ export async function removeWorkspaceDirs(
runtime: RuntimeEnv,
opts?: {
dryRun?: boolean;
preserveWorkspace?: boolean;
removeStateRows?: boolean;
removeWorkspace?: (workspace: string) => Promise<boolean>;
},
@@ -539,9 +540,11 @@ export async function removeWorkspaceDirs(
const statePlan = opts?.removeStateRows
? await attempt(stateLabel, () => prepareWorkspaceStateDeletion(workspace))
: undefined;
const result = opts?.removeWorkspace
? { ok: (await attempt(workspace, () => opts.removeWorkspace!(workspace))) === true }
: await removePath(workspace, runtime, { dryRun: opts?.dryRun, label: workspace });
const result = opts?.preserveWorkspace
? { ok: true }
: opts?.removeWorkspace
? { ok: (await attempt(workspace, () => opts.removeWorkspace!(workspace))) === true }
: await removePath(workspace, runtime, { dryRun: opts?.dryRun, label: workspace });
if (!result.ok) {
failures.add(workspace);
continue;
+1
View File
@@ -143,6 +143,7 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) {
? await resolveCleanupPlanForDryRun()
: await resolveCleanupPlanForRemoval(runtime);
if (!cleanupPlan) {
runtime.exit(1);
return;
}
const { stateDir, configPath, oauthDir, configInsideState, oauthInsideState, workspaceDirs } =
+119 -24
View File
@@ -1,11 +1,11 @@
// Uninstall command tests cover cleanup flow, prompts, and runtime messages.
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupCommandLogMessages,
cleanupCommandErrorMessages,
createCleanupCommandRuntime,
gatewayService,
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
removePath,
removeStateAndLinkedPaths,
removeWorkspaceDirs,
resetCleanupCommandMocks,
@@ -49,7 +49,6 @@ describe("uninstallCommand", () => {
expect(removeStateAndLinkedPaths).not.toHaveBeenCalled();
expect(removeWorkspaceDirs).not.toHaveBeenCalled();
expect(prepareLegacyWorkspaceStateReset).not.toHaveBeenCalled();
expect(cleanupCommandLogMessages(runtime)).not.toContain(
"CLI still installed. Remove via npm/pnpm if desired.",
);
@@ -100,6 +99,26 @@ describe("uninstallCommand", () => {
expect(removeWorkspaceDirs).toHaveBeenCalledOnce();
});
it("attempts app cleanup when service teardown blocks local data", async () => {
const platform = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
gatewayService.stop.mockRejectedValue(new Error("stop failed"));
try {
await expect(
uninstallCommand(runtime, { all: true, yes: true, nonInteractive: true }),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(removePath).toHaveBeenCalledWith(
"/Applications/OpenClaw.app",
runtime,
expect.any(Object),
);
expect(cleanupCommandErrorMessages(runtime)).toContain(
"State and workspace cleanup blocked because gateway service teardown failed.",
);
} finally {
platform.mockRestore();
}
});
it("removes an unloaded service definition before deleting user data", async () => {
gatewayService.isLoaded.mockResolvedValue(false);
@@ -163,12 +182,7 @@ describe("uninstallCommand", () => {
);
});
it("previews retired workspace files during state-only uninstall", async () => {
removeLegacyWorkspaceStateForReset.mockResolvedValueOnce({
removedPaths: ["/tmp/.openclaw/workspace/openclaw-workspace-state.json"],
warnings: [],
});
it("cleans retired workspace state without removing state-only workspaces", async () => {
await uninstallCommand(runtime, {
state: true,
yes: true,
@@ -176,14 +190,10 @@ describe("uninstallCommand", () => {
dryRun: true,
});
expect(prepareLegacyWorkspaceStateReset).toHaveBeenCalledWith("/tmp/.openclaw/workspace");
expect(removeLegacyWorkspaceStateForReset).toHaveBeenCalledWith(
{ workspaceDir: "/tmp/.openclaw/workspace" },
{ dryRun: true },
);
expect(cleanupCommandLogMessages(runtime)).toContain(
"[dry-run] remove /tmp/.openclaw/workspace/openclaw-workspace-state.json",
);
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
dryRun: true,
preserveWorkspace: true,
});
});
it("does not preserve workspace dirs when workspace removal is selected", async () => {
@@ -237,16 +247,101 @@ describe("uninstallCommand", () => {
it("removes workspace rows when combined state removal fails", async () => {
removeStateAndLinkedPaths.mockResolvedValueOnce(false);
await uninstallCommand(runtime, {
state: true,
workspace: true,
yes: true,
nonInteractive: true,
});
await expect(
uninstallCommand(runtime, {
state: true,
workspace: true,
yes: true,
nonInteractive: true,
}),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(removeWorkspaceDirs).toHaveBeenCalledWith(["/tmp/.openclaw/workspace"], runtime, {
dryRun: false,
removeStateRows: true,
});
});
it.each([
{
failure: "returns failures",
arrange: () => removeWorkspaceDirs.mockResolvedValueOnce(["retired state failed"]),
},
{
failure: "throws",
arrange: () => removeWorkspaceDirs.mockRejectedValueOnce(new Error("retired state failed")),
},
])(
"continues state and app cleanup when retired workspace cleanup $failure",
async ({ arrange }) => {
const platform = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
arrange();
try {
await expect(
uninstallCommand(runtime, {
state: true,
app: true,
yes: true,
nonInteractive: true,
}),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(removeStateAndLinkedPaths).toHaveBeenCalledOnce();
expect(removePath).toHaveBeenCalledWith(
"/Applications/OpenClaw.app",
runtime,
expect.any(Object),
);
expect(cleanupCommandErrorMessages(runtime).join("\n")).toContain("retired state");
} finally {
platform.mockRestore();
}
},
);
it("fails when workspace cleanup returns failures", async () => {
removeWorkspaceDirs.mockResolvedValueOnce(["/tmp/.openclaw/workspace"]);
await expect(
uninstallCommand(runtime, { workspace: true, yes: true, nonInteractive: true }),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(cleanupCommandErrorMessages(runtime)).toContain(
"Workspace cleanup incomplete: /tmp/.openclaw/workspace",
);
});
it("blocks workspace cleanup after a thrown state ownership failure", async () => {
removeStateAndLinkedPaths.mockRejectedValueOnce(new Error("state is live"));
await expect(
uninstallCommand(runtime, {
state: true,
workspace: true,
yes: true,
nonInteractive: true,
}),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(removeWorkspaceDirs).not.toHaveBeenCalled();
expect(cleanupCommandErrorMessages(runtime)).toContain(
"Workspace cleanup blocked because state cleanup could not safely complete.",
);
});
it("reports app cleanup failure and non-macOS inapplicability", async () => {
const platform = vi.spyOn(process, "platform", "get");
platform.mockReturnValue("darwin");
removePath.mockResolvedValueOnce({ ok: false });
await expect(
uninstallCommand(runtime, { app: true, yes: true, nonInteractive: true }),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
resetCleanupCommandMocks();
silenceCleanupCommandRuntime(runtime);
platform.mockReturnValue("linux");
await uninstallCommand(runtime, { app: true, yes: true, nonInteractive: true });
expect(cleanupCommandLogMessages(runtime)).toContain(
"macOS app cleanup is not applicable on this platform.",
);
platform.mockRestore();
});
});
+78 -39
View File
@@ -8,16 +8,12 @@ import {
stylePromptMessage,
stylePromptTitle,
} from "../../packages/terminal-core/src/prompt-style.js";
import {
prepareLegacyWorkspaceStateReset,
removeLegacyWorkspaceStateForReset,
} from "../agents/workspace-legacy-state.js";
import { formatCliCommand } from "../cli/command-format.js";
import { isNixMode } from "../config/config.js";
import { resolveGatewayService } from "../daemon/service.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveHomeDir, shortenHomeInString } from "../utils.js";
import { resolveHomeDir } from "../utils.js";
import { resolveCleanupPlanForDryRun, resolveCleanupPlanForRemoval } from "./cleanup-plan.js";
import { removePath, removeStateAndLinkedPaths, removeWorkspaceDirs } from "./cleanup-utils.js";
@@ -101,14 +97,16 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
return stopped;
}
async function removeMacApp(runtime: RuntimeEnv, dryRun?: boolean) {
async function removeMacApp(runtime: RuntimeEnv, dryRun?: boolean): Promise<boolean> {
if (process.platform !== "darwin") {
return;
runtime.log("macOS app cleanup is not applicable on this platform.");
return true;
}
await removePath("/Applications/OpenClaw.app", runtime, {
const result = await removePath("/Applications/OpenClaw.app", runtime, {
dryRun,
label: "/Applications/OpenClaw.app",
});
return result.ok;
}
function logBackupRecommendation(runtime: RuntimeEnv) {
@@ -181,6 +179,9 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
const dryRun = Boolean(opts.dryRun);
let stateRemoved = false;
let workspaceBlocked = false;
let failed = false;
let serviceSafe = true;
const removesLocalData = scopes.has("state") || scopes.has("workspace");
if (removesLocalData) {
@@ -193,57 +194,92 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
} else if (!(await stopAndUninstallService(runtime))) {
// Service removal may prevent relaunch even when runtime termination is
// uncertain; preserve mutable user data until teardown can be verified.
runtime.exit(1);
return;
serviceSafe = false;
failed = true;
}
}
const cleanupPlan = removesLocalData
? dryRun
? await resolveCleanupPlanForDryRun()
: await resolveCleanupPlanForRemoval(runtime)
: undefined;
if (removesLocalData && !cleanupPlan) {
return;
let cleanupPlan;
if (removesLocalData && serviceSafe) {
try {
cleanupPlan = dryRun
? await resolveCleanupPlanForDryRun()
: await resolveCleanupPlanForRemoval(runtime);
} catch (error) {
runtime.error(`Failed to prepare local data cleanup: ${formatErrorMessage(error)}`);
}
if (!cleanupPlan) {
failed = true;
}
} else if (removesLocalData) {
runtime.error("State and workspace cleanup blocked because gateway service teardown failed.");
}
if (scopes.has("state") && cleanupPlan) {
const { stateDir, configPath, oauthDir, configInsideState, oauthInsideState, workspaceDirs } =
cleanupPlan;
if (!scopes.has("workspace")) {
for (const workspaceDir of workspaceDirs) {
const legacyPlan = prepareLegacyWorkspaceStateReset(workspaceDir);
const legacyCleanup = await removeLegacyWorkspaceStateForReset(legacyPlan, { dryRun });
for (const removedPath of legacyCleanup.removedPaths) {
if (dryRun) {
runtime.log(`[dry-run] remove ${shortenHomeInString(removedPath)}`);
}
}
for (const warning of legacyCleanup.warnings) {
runtime.error(warning);
try {
const legacyFailures = await removeWorkspaceDirs(workspaceDirs, runtime, {
dryRun,
preserveWorkspace: true,
});
if (legacyFailures.length > 0) {
runtime.error(`Retired workspace state cleanup incomplete: ${legacyFailures.join(", ")}`);
failed = true;
}
} catch (error) {
runtime.error(`Retired workspace state cleanup failed: ${formatErrorMessage(error)}`);
failed = true;
}
}
// Preserve workspaces when state-only uninstall is requested; workspace scope removes them explicitly.
stateRemoved = await removeStateAndLinkedPaths(
{ stateDir, configPath, oauthDir, configInsideState, oauthInsideState },
runtime,
{ dryRun, preservePaths: scopes.has("workspace") ? [] : workspaceDirs },
);
try {
stateRemoved = await removeStateAndLinkedPaths(
{ stateDir, configPath, oauthDir, configInsideState, oauthInsideState },
runtime,
{ dryRun, preservePaths: scopes.has("workspace") ? [] : workspaceDirs },
);
} catch (error) {
runtime.error(`State cleanup failed: ${formatErrorMessage(error)}`);
workspaceBlocked = true;
}
failed ||= !stateRemoved;
}
if (scopes.has("workspace") && cleanupPlan) {
await removeWorkspaceDirs(cleanupPlan.workspaceDirs, runtime, {
dryRun,
removeStateRows: !scopes.has("state") || !stateRemoved,
});
if (scopes.has("workspace") && cleanupPlan && workspaceBlocked) {
runtime.error("Workspace cleanup blocked because state cleanup could not safely complete.");
} else if (scopes.has("workspace") && cleanupPlan) {
try {
const workspaceFailures = await removeWorkspaceDirs(cleanupPlan.workspaceDirs, runtime, {
dryRun,
removeStateRows: !scopes.has("state") || !stateRemoved,
});
if (workspaceFailures.length > 0) {
runtime.error(`Workspace cleanup incomplete: ${workspaceFailures.join(", ")}`);
failed = true;
}
} catch (error) {
runtime.error(`Workspace cleanup failed: ${formatErrorMessage(error)}`);
failed = true;
}
}
if (scopes.has("app")) {
await removeMacApp(runtime, dryRun);
try {
const appRemoved = await removeMacApp(runtime, dryRun);
if (!appRemoved) {
failed = true;
}
} catch (error) {
runtime.error(`App cleanup failed: ${formatErrorMessage(error)}`);
failed = true;
}
}
runtime.log("CLI still installed. Remove via npm/pnpm if desired.");
if (!failed) {
runtime.log("CLI still installed. Remove via npm/pnpm if desired.");
}
if (scopes.has("state") && !scopes.has("workspace") && cleanupPlan) {
const home = resolveHomeDir();
@@ -251,4 +287,7 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio
runtime.log("Tip: workspaces were preserved. Re-run with --workspace to remove them.");
}
}
if (failed) {
runtime.exit(1);
}
}
+7 -5
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
const INSTALL_DOCS_DIR = path.join(process.cwd(), "docs", "install");
const CLOUD_DOCKER_VM_INSTALL_DOCS = new Set(["gcp.md", "hetzner.md"]);
const SHARED_DOCKER_RUNTIME_DELEGATES = new Set(["gcp.md", "hetzner.md"]);
const KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS = [
"change-me-to-a-long-random-token",
"change-me-now",
@@ -34,10 +34,12 @@ describe("cloud install docs", () => {
expect(markdown, docName).not.toContain(`OPENCLAW_GATEWAY_PASSWORD=${password}`);
}
expect(markdown, docName).not.toMatch(/^ {4}GOG_KEYRING_PASSWORD=change-me-now$/m);
if (CLOUD_DOCKER_VM_INSTALL_DOCS.has(docName)) {
expect(markdown, docName).toMatch(/^ {4}OPENCLAW_GATEWAY_TOKEN=[ \t]*\r?$/m);
expect(markdown, docName).toMatch(/^ {4}GOG_KEYRING_PASSWORD=[ \t]*\r?$/m);
expect(markdown, docName).toContain("openssl rand -hex 32");
if (SHARED_DOCKER_RUNTIME_DELEGATES.has(docName)) {
expect(markdown, docName).toContain("[Docker VM runtime](/install/docker-vm-runtime)");
}
if (docName === "docker-vm-runtime.md") {
expect(markdown).toContain("./scripts/docker/setup.sh");
expect(markdown).toContain("generates a Gateway token");
}
}
});
+3 -3
View File
@@ -123,10 +123,10 @@ describePosix("gmail-watcher process-tree shutdown (integration)", () => {
console.log("calling stopGmailWatcher...");
await stopGmailWatcher();
await expect.poll(() => alive(gogPid!), { interval: 25, timeout: 1_500 }).toBe(false);
await expect.poll(() => alive(helperPid!), { interval: 25, timeout: 1_500 }).toBe(false);
console.log(`gog alive after stop: ${alive(gogPid)}`);
console.log(`credential-helper alive after stop: ${alive(helperPid)}`);
expect(alive(gogPid)).toBe(false);
expect(alive(helperPid)).toBe(false); // descendant must also be gone
}, 15_000);
});
+20 -9
View File
@@ -194,7 +194,12 @@ describe("runGlobalPackageUpdateSteps", () => {
const prefix = path.join(base, "prefix");
const globalRoot = path.join(prefix, "lib", "node_modules");
const packageRoot = path.join(globalRoot, "openclaw");
const checkoutRoot = path.join(base, "checkout");
await writePackageRoot(packageRoot, "1.0.0");
await writePackageRoot(checkoutRoot, "2.0.0");
await fs.writeFile(path.join(checkoutRoot, "openclaw.mjs"), "#!/usr/bin/env node\n", {
mode: 0o755,
});
await fs.mkdir(path.join(packageRoot, "dist", "extensions", "qa-channel"), {
recursive: true,
});
@@ -217,14 +222,19 @@ describe("runGlobalPackageUpdateSteps", () => {
throw new Error("missing staged prefix");
}
expect(path.dirname(stagePrefix)).toBe(globalRoot);
await writePackageRoot(
path.join(stagePrefix, "lib", "node_modules", "openclaw"),
"2.0.0",
);
await fs.mkdir(path.join(stagePrefix, "bin"), { recursive: true });
const stageLayout = resolveNpmGlobalPrefixLayoutFromPrefix(stagePrefix);
await fs.mkdir(stageLayout.globalRoot, { recursive: true });
await fs.symlink(
"../lib/node_modules/openclaw/dist/index.js",
path.join(stagePrefix, "bin", "openclaw"),
process.platform === "win32"
? checkoutRoot
: path.relative(stageLayout.globalRoot, checkoutRoot),
path.join(stageLayout.globalRoot, "openclaw"),
process.platform === "win32" ? "junction" : undefined,
);
await fs.mkdir(stageLayout.binDir, { recursive: true });
await fs.symlink(
"../lib/node_modules/openclaw/openclaw.mjs",
path.join(stageLayout.binDir, "openclaw"),
);
return {
name,
@@ -238,7 +248,7 @@ describe("runGlobalPackageUpdateSteps", () => {
const result = await runGlobalPackageUpdateSteps({
installTarget: createNpmTarget(globalRoot),
installSpec: "openclaw@2.0.0",
installSpec: checkoutRoot,
packageName: "openclaw",
packageRoot,
runCommand: createRootRunner(globalRoot),
@@ -259,8 +269,9 @@ describe("runGlobalPackageUpdateSteps", () => {
await expectPathMissing(
path.join(packageRoot, "dist", "extensions", "qa-channel", "runtime-api.js"),
);
await expect(fs.realpath(packageRoot)).resolves.toBe(await fs.realpath(checkoutRoot));
await expect(fs.readlink(path.join(prefix, "bin", "openclaw"))).resolves.toBe(
"../lib/node_modules/openclaw/dist/index.js",
"../lib/node_modules/openclaw/openclaw.mjs",
);
});
});
+22 -5
View File
@@ -757,6 +757,27 @@ async function restoreNpmBinShimBackup(backup: NpmBinShimBackup): Promise<void>
}
}
async function activateStagedNpmPackageRoot(source: string, destination: string): Promise<void> {
const stat = await fs.lstat(source);
if (!stat.isSymbolicLink()) {
await movePathWithCopyFallback({
from: source,
sourceHardlinks: PACKAGE_MANAGER_SWAP_SOURCE_HARDLINKS,
to: destination,
});
return;
}
// npm represents global local-directory installs as relative symlinks. Moving
// one changes its meaning, so activate the same canonical source explicitly.
const canonicalSource = await fs.realpath(source);
await fs.symlink(
canonicalSource,
destination,
process.platform === "win32" ? "junction" : undefined,
);
}
async function swapStagedNpmInstall(params: {
stage: StagedNpmInstall;
installTarget: ResolvedGlobalInstallTarget;
@@ -793,11 +814,7 @@ async function swapStagedNpmInstall(params: {
});
movedExisting = true;
}
await movePathWithCopyFallback({
from: params.stage.packageRoot,
sourceHardlinks: PACKAGE_MANAGER_SWAP_SOURCE_HARDLINKS,
to: targetPackageRoot,
});
await activateStagedNpmPackageRoot(params.stage.packageRoot, targetPackageRoot);
movedStaged = true;
if (params.installTarget.directNodeModulesRoot !== true) {
await replaceNpmBinShims({
@@ -496,7 +496,7 @@ describe("plugin-sdk subpath exports", () => {
expect(docs).toContain("scripts/lib/plugin-sdk-entrypoints.json");
expect(docs).toContain("scripts/lib/plugin-sdk-private-local-only-subpaths.json");
expect(docs).toContain("scripts/lib/plugin-sdk-deprecated-public-subpaths.json");
expect(docs).toContain("private-local entries explicitly");
expect(docs).toContain("are labeled private-local below");
for (const subpath of pluginSdkSubpaths) {
expect(packageExports).toHaveProperty(`./plugin-sdk/${subpath}`, {
+103 -2
View File
@@ -23,6 +23,7 @@ import {
writeNpmBeforePolicyFixture,
writeNpmFreshnessConflictFixture,
writeNpmInstallRetryFixture,
writeNpmLifecycleFixture,
} from "./install-npm-fixtures.js";
const SCRIPT_PATH = "scripts/install-cli.sh";
@@ -604,7 +605,10 @@ describe("install-cli.sh", () => {
const dependencyInstallIndex = script.indexOf(
'CI="${CI:-true}" run_pnpm -C "$repo_dir" install "$install_lockfile_flag"',
);
const wrapperIndex = script.indexOf('cat > "${PREFIX}/bin/openclaw"', compatibilityIndex);
const wrapperIndex = script.indexOf(
'publish_executable_wrapper "${PREFIX}/bin/openclaw"',
compatibilityIndex,
);
expect(checkoutIndex).toBeGreaterThan(-1);
expect(compatibilityIndex).toBeGreaterThan(checkoutIndex);
@@ -660,6 +664,7 @@ describe("install-cli.sh", () => {
"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"; }',
@@ -773,7 +778,7 @@ describe("install-cli.sh", () => {
symlinkSync("node-v24.15.0", join(prefix, "tools", "node"));
writeFileSync(
join(nodeDir, "bin", "npm"),
'#!/bin/bash\nif [[ "$1" == "config" ]]; then printf "null\\n"; fi\n',
'#!/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 [
@@ -1599,6 +1604,100 @@ describe("install-cli.sh", () => {
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)}`,
`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);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("rejects an invalid npm version before mutation", () => {
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)}`,
`npm_lifecycle_allow_arg ${JSON.stringify(npm)} openclaw@latest`,
].join("\n"),
{ NPM_FAKE_ARGS: args, NPM_FAKE_VERSION: "invalid" },
);
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"],
["file:/tmp/openclaw.tgz", "--allow-scripts=file:/tmp/openclaw.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)}`,
`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 });
}
});
it("relativizes absolute npm path identities against the command cwd", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-identity-comma,"));
const npm = join(tmp, "npm");
const commandCwd = join(tmp, "safe");
const candidate = join(tmp, "candidate.tgz");
mkdirSync(commandCwd);
writeNpmLifecycleFixture(npm);
try {
const result = runInstallCliShell(
[
`source ${JSON.stringify(SCRIPT_PATH)}`,
`node_bin() { printf '%s\\n' ${JSON.stringify(process.execPath)}; }`,
`cd ${JSON.stringify(commandCwd)}`,
`npm_lifecycle_allow_arg ${JSON.stringify(npm)} ${JSON.stringify(candidate)} "$PWD"`,
].join("\n"),
{ NPM_FAKE_VERSION: "12.0.0" },
);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("--allow-scripts=../candidate.tgz");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
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");
@@ -1638,6 +1737,7 @@ describe("install-cli.sh", () => {
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"npm_lifecycle_allow_arg() { :; }",
`npm_bin() { printf '%s\\n' ${JSON.stringify(fakeNpm)}; }`,
`node_dir() { printf '%s\\n' ${JSON.stringify(nodeDir)}; }`,
"emit_json() { :; }",
@@ -1722,6 +1822,7 @@ describe("install-cli.sh", () => {
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"npm_lifecycle_allow_arg() { :; }",
`npm_bin() { printf '%s\\n' ${JSON.stringify(fakeNpm)}; }`,
`node_dir() { printf '%s\\n' ${JSON.stringify(nodeDir)}; }`,
"emit_json() { :; }",
+28 -1
View File
@@ -7,6 +7,7 @@ export function writeNpmInstallRetryFixture(path: string) {
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [[ "${1:-}" == "--version" ]]; then printf "11.15.0\\n"; exit 0; fi',
'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',
@@ -15,7 +16,7 @@ export function writeNpmInstallRetryFixture(path: string) {
'if [[ "$is_install" -eq 0 ]]; then exit 0; fi',
'spec="${!#}"',
'printf "%s\\n" "$spec" >> "$NPM_FAKE_CALLS"',
'attempt="$(wc -l < "$NPM_FAKE_CALLS")"',
'attempt="$(awk \'END { print NR }\' "$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"',
@@ -39,6 +40,7 @@ export function writeNpmFreshnessConflictFixture(path: string, argsLog: string)
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [[ "${1:-}" == "--version" ]]; then printf "11.15.0\\n"; exit 0; fi',
`printf '%s\\n' "$*" >> ${JSON.stringify(argsLog)}`,
'if [[ "$1" == "config" && "$2" == "get" && "$3" == "min-release-age" ]]; then',
" printf 'null\\n'",
@@ -74,6 +76,7 @@ export function writeNpmBeforePolicyFixture(path: string, argsLog: string) {
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [[ "${1:-}" == "--version" ]]; then printf "11.15.0\\n"; exit 0; fi',
`printf '%s\\n' "$*" >> ${JSON.stringify(argsLog)}`,
'if [[ "$1" == "config" && "$2" == "get" && "$3" == "min-release-age" ]]; then',
" printf 'null\\n'",
@@ -100,3 +103,27 @@ export function writeNpmBeforePolicyFixture(path: string, argsLog: string) {
);
chmodSync(path, 0o755);
}
export function writeNpmLifecycleFixture(path: string) {
writeFileSync(
path,
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [[ "${1:-}" == "--version" ]]; then',
' [[ "${NPM_FAKE_VERSION_STATUS:-0}" == "0" ]] || exit "$NPM_FAKE_VERSION_STATUS"',
' printf "%s\\n" "$NPM_FAKE_VERSION"',
" exit 0",
"fi",
'if [[ "${1:-}" == "root" ]]; then printf "%s\\n" "$NPM_FAKE_ROOT"; exit 0; fi',
'if [[ "${1:-}" == "config" ]]; then printf "null\\n"; exit 0; fi',
'printf "%s\\n" "$*" >> "$NPM_FAKE_ARGS"',
'mkdir -p "$NPM_FAKE_PACKAGE_DIR/dist"',
'printf "#!/usr/bin/env node\\n" > "$NPM_FAKE_PACKAGE_DIR/dist/entry.js"',
'if [[ "${NPM_FAKE_KEEP_GUARD:-0}" == "1" ]]; then : > "$NPM_FAKE_PACKAGE_DIR/dist/openclaw-install-guard"; else rm -f "$NPM_FAKE_PACKAGE_DIR/dist/openclaw-install-guard"; fi',
"exit 0",
"",
].join("\n"),
);
chmodSync(path, 0o755);
}
+167 -8
View File
@@ -171,7 +171,103 @@ describe("install.ps1 failure handling", () => {
"$output = @(Run-Doctor *>&1 | ForEach-Object { $_.ToString() })",
'$text = $output -join "`n"',
"if ($text -match 'Migration complete') { throw 'doctor failure reported success' }",
"if ($text -notmatch 'Migration failed') { throw \"missing warning: $text\" }",
"if ($text -notmatch 'Migration failed') { throw \"missing error: $text\" }",
"if ($output[-1] -ne $false) { throw 'doctor failure did not propagate' }",
"",
].join("\n"),
},
{
name: "npm-lifecycle-policy",
source: [
scriptWithoutEntryPoint,
"",
"$script:NpmVersion = ''",
"function Invoke-NpmCommand {",
" param([string[]]$Arguments = @(), [string]$CommandPath, [string]$WorkingDirectory)",
" if ($Arguments[0] -eq '--version') { Write-Output $script:NpmVersion; $global:LASTEXITCODE = 0; return }",
" throw 'unexpected npm mutation'",
"}",
"$cases = @{ '11.15.0' = $null; '11.16.0' = '--allow-scripts=openclaw'; '12.0.0' = '--allow-scripts=openclaw' }",
"foreach ($entry in $cases.GetEnumerator()) {",
" $script:NpmVersion = $entry.Key",
" $actual = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'openclaw@latest'",
' if ($actual -ne $entry.Value) { throw "version=$($entry.Key) actual=$actual" }',
"}",
"$script:NpmVersion = '12.0.0'",
"$alias = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'openclaw@npm:@scope/candidate@1.0.0'",
"if ($alias -ne '--allow-scripts=@scope/candidate') { throw \"alias=$alias\" }",
"$tarball = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'https://example.invalid/openclaw.tgz'",
"if ($tarball -ne '--allow-scripts=https://example.invalid/openclaw.tgz') { throw \"tarball=$tarball\" }",
'$commaRoot = Join-Path ([System.IO.Path]::GetTempPath()) "openclaw,identity"',
'$safeCwd = Join-Path $commaRoot "safe"',
'$candidate = Join-Path $commaRoot "candidate.tgz"',
"$relative = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec $candidate -NpmCwd $safeCwd",
"if ($relative -match ',' -or $relative -notmatch '^--allow-scripts=\.\.[\\/]candidate\.tgz$') { throw \"relative=$relative\" }",
"$script:NpmVersion = 'invalid'",
"$caught = $false",
"try { Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'openclaw@latest' } catch { $caught = $true }",
"if (-not $caught) { throw 'invalid npm version was accepted' }",
"",
].join("\n"),
},
{
name: "npm-candidate-validation",
source: [
scriptWithoutEntryPoint,
"",
'$root = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-missing-candidate-" + [guid]::NewGuid().ToString("N"))',
"New-Item -ItemType Directory -Path $root | Out-Null",
"function Check-ExistingOpenClaw { return $true }",
"function Check-Node { return $true }",
"function Ensure-Git { return $true }",
"function Test-PreviousGitWrapper { return $false }",
"function Get-NpmCommandPath { return 'npm.cmd' }",
"function Get-WindowsCommandSafeDirectory { return $root }",
"function Resolve-NpmOpenClawInstallSpec { return 'openclaw@latest' }",
"function Test-NpmConfigRawKey { return $true }",
"function Get-NpmDebugLogRootCandidates { return @() }",
"function Invoke-NpmCommand {",
" param([string[]]$Arguments = @(), [string]$CommandPath, [string]$WorkingDirectory)",
" $global:LASTEXITCODE = 0",
" if ($Arguments[0] -eq '--version') { return '12.0.0' }",
" if ($Arguments[0] -eq 'root') { return $root }",
" if ($Arguments[0] -eq 'config') { return $root }",
" if ($Arguments[0] -eq 'install') { return }",
" throw \"unexpected npm command: $($Arguments -join ' ')\"",
"}",
"function Ensure-OpenClawOnPath { throw 'old PATH command was accepted after missing candidate' }",
"$InstallMethod = 'npm'",
"$NoOnboard = $true",
"$Tag = 'latest'",
"try {",
" $null = Main",
' if ($script:InstallExitCode -ne 1) { throw "InstallExitCode=$script:InstallExitCode" }',
"} finally {",
" Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue",
"}",
"",
].join("\n"),
},
{
name: "method-switch-preservation",
source: [
scriptWithoutEntryPoint,
"",
"$script:OldOwnerRemoved = $false",
"function Check-ExistingOpenClaw { return $true }",
"function Check-Node { return $true }",
"function Get-NpmCommandPath { return 'npm.cmd' }",
"function Invoke-NpmCommand {",
" param([string[]]$Arguments = @(), [string]$CommandPath, [string]$WorkingDirectory)",
" if ($Arguments[0] -eq 'list') { $global:LASTEXITCODE = 0; return }",
" if ($Arguments[0] -eq 'uninstall') { $script:OldOwnerRemoved = $true; $global:LASTEXITCODE = 0; return }",
" throw 'unexpected npm command'",
"}",
"function Install-OpenClawFromGit { return $false }",
"$InstallMethod = 'git'",
"$NoOnboard = $true",
"$null = Main",
"if ($script:OldOwnerRemoved) { throw 'failed candidate retired the working npm owner' }",
"",
].join("\n"),
},
@@ -193,6 +289,39 @@ describe("install.ps1 failure handling", () => {
"",
].join("\n"),
},
{
name: "same-prefix-shim-transaction",
source: [
scriptWithoutEntryPoint,
"",
'$root = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-shim-transaction-" + [guid]::NewGuid().ToString("N"))',
'$target = Join-Path $root "openclaw.cmd"',
"try {",
" New-Item -ItemType Directory -Force -Path $root | Out-Null",
' $old = "@echo off`r`nnode `"C:\\old\\dist\\entry.js`" %*`r`n"',
' $launcher = Join-Path $root "node_modules\\openclaw\\openclaw.mjs"',
' $candidate = "@ECHO off`r`nGOTO start`r`n:find_dp0`r`nSET dp0=%~dp0`r`nEXIT /b`r`n:start`r`nSETLOCAL`r`nCALL :find_dp0`r`nnode `"%dp0%\\node_modules\\openclaw\\openclaw.mjs`" %*`r`n"',
" [System.IO.File]::WriteAllText($target, $old)",
" $backup = Start-NpmShimBackup -Path $target -ExpectedLauncher $launcher",
" [System.IO.File]::WriteAllText($target, $candidate)",
" Restore-NpmShimBackup -Backup $backup",
" if ([System.IO.File]::ReadAllText($target) -ne $old) { throw 'failure did not restore old wrapper' }",
" $backup = Start-NpmShimBackup -Path $target -ExpectedLauncher $launcher",
" [System.IO.File]::WriteAllText($target, $candidate)",
" Complete-NpmShimBackup -Backup $backup",
" if ([System.IO.File]::ReadAllText($target) -ne $candidate) { throw 'success did not retain npm shim' }",
" if (Test-Path -LiteralPath $backup.BackupPath) { throw 'committed backup remains' }",
" [System.IO.File]::WriteAllText($target, $old)",
" $backup = Start-NpmShimBackup -Path $target -ExpectedLauncher $launcher",
' [System.IO.File]::WriteAllText($target, "@echo off`r`necho unrelated`r`n")',
" $refused = $false",
" try { Restore-NpmShimBackup -Backup $backup } catch { $refused = $true }",
" if (-not $refused) { throw 'unrelated replacement was deleted' }",
" if ([System.IO.File]::ReadAllText($target) -notmatch 'unrelated') { throw 'unrelated replacement changed' }",
"} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }",
"",
].join("\n"),
},
{
name: "canonical-temp-root",
source: [
@@ -803,6 +932,22 @@ describe("install.ps1 failure handling", () => {
expectBatchedPowerShellCase("canonical-temp-root");
});
runIfPowerShell("applies the canonical npm lifecycle version policy", () => {
expectBatchedPowerShellCase("npm-lifecycle-policy");
});
runIfPowerShell("rejects npm success without a usable candidate package", () => {
expectBatchedPowerShellCase("npm-candidate-validation");
});
runIfPowerShell("preserves the npm owner when a git replacement fails", () => {
expectBatchedPowerShellCase("method-switch-preservation");
});
runIfPowerShell("restores or commits the same-prefix npm shim transaction", () => {
expectBatchedPowerShellCase("same-prefix-shim-transaction");
});
runIfPowerShell("installs portable Git from multiple archive roots without collisions", () => {
expectBatchedPowerShellCase("portable-git-layout");
});
@@ -826,12 +971,16 @@ describe("install.ps1 failure handling", () => {
it("runs npm install through the resolved command with quiet CI defaults", () => {
const npmInstallBody = extractFunctionBody(source, "Install-OpenClaw");
expect(npmInstallBody).toContain("$npmOutput = Invoke-NpmCommand -Arguments");
expect(npmInstallBody).toContain(
"$npmOutput = Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments",
);
expect(npmInstallBody).toContain("$npmDebugLogRoots = @(Get-NpmDebugLogRootCandidates)");
expect(npmInstallBody).toContain('$npmInstallArguments = @("install", "-g")');
expect(npmInstallBody).toContain('Write-Host "[!] npm install failed; retrying once"');
expect(
npmInstallBody.match(/Invoke-NpmCommand -Arguments \$npmInstallArguments/g),
npmInstallBody.match(
/Invoke-NpmCommand -CommandPath \$npmCommand -WorkingDirectory \$npmCwd -Arguments \$npmInstallArguments/g,
),
).toHaveLength(2);
expect(npmInstallBody).toContain('$env:NPM_CONFIG_LOGLEVEL = "error"');
expect(npmInstallBody).toContain('$env:NPM_CONFIG_UPDATE_NOTIFIER = "false"');
@@ -886,6 +1035,7 @@ describe("install.ps1 failure handling", () => {
const mainBody = extractFunctionBody(source, "Main");
expect(commandSafeBody).toContain("Get-WindowsCommandSafeDirectory");
expect(commandSafeBody).toContain("$WorkingDirectory");
expect(commandSafeBody).toContain("Push-Location -LiteralPath $safeDir");
expect(commandSafeBody).toContain("& $CommandPath @Arguments");
expect(commandSafeBody).toContain("Pop-Location");
@@ -896,7 +1046,11 @@ describe("install.ps1 failure handling", () => {
'Invoke-CorepackCommand -Arguments @("prepare", $pnpmSpec, "--activate")',
);
expect(ensurePnpmBody).toContain('Invoke-NpmCommand -Arguments @("install", "-g", $pnpmSpec)');
expect(mainBody).toContain('Invoke-NpmCommand -Arguments @("uninstall", "-g", "openclaw")');
expect(mainBody).toContain("Remove-PreviousNpmOwner");
expect(mainBody).toContain("Remove-PreviousGitWrapper");
expect(mainBody).toContain("Start-NpmShimBackup");
expect(mainBody).toContain("Restore-NpmShimBackup");
expect(mainBody).toContain("Complete-NpmShimBackup");
expect(mainBody).toContain(
'Invoke-NpmCommand -Arguments @("list", "-g", "--depth", "0", "--json")',
);
@@ -953,10 +1107,10 @@ describe("install.ps1 failure handling", () => {
"} elseif ($minReleaseAgeStatus -ne 0 -or -not $minReleaseAge",
);
expect(npmInstallBody).toContain(
'Invoke-NpmCommand -Arguments @("config", "get", "min-release-age", "--global")',
'Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments @("config", "get", "min-release-age", "--global")',
);
expect(npmInstallBody).toContain(
'Invoke-NpmCommand -Arguments @("config", "get", "before", "--global")',
'Invoke-NpmCommand -CommandPath $npmCommand -WorkingDirectory $npmCwd -Arguments @("config", "get", "before", "--global")',
);
});
@@ -1174,9 +1328,9 @@ describe("install.ps1 failure handling", () => {
expect(gitInstallBody).toContain('$entryPath = Join-Path $RepoDir "dist\\\\entry.js"');
expect(gitInstallBody).toContain("Test-Path $entryPath");
expect(gitInstallBody).toContain('Write-Host "[!] OpenClaw build did not produce $entryPath"');
expect(gitInstallBody).toContain('node ""$entryPath"" %*');
expect(gitInstallBody).toContain("node $entryPath --version");
expect(gitInstallBody).toContain("Format-OpenClawGitWrapper -EntryPath $entryPath");
expect(gitInstallBody).not.toContain("& $pnpmCommand -C $RepoDir install");
expect(gitInstallBody).not.toContain('node ""$RepoDir\\\\dist\\\\entry.js"" %*');
});
it("cleans legacy git submodules only from the selected git checkout", () => {
@@ -1216,6 +1370,11 @@ describe("install.ps1 failure handling", () => {
"function Check-Node { return $true }",
"function Check-ExistingOpenClaw { return $false }",
"function Get-NpmCommandPath { return 'npm.cmd' }",
"function Invoke-NpmCommand {",
" param([string[]]$Arguments = @(), [string]$CommandPath, [string]$WorkingDirectory)",
" if ($Arguments[0] -eq 'config' -and $Arguments[2] -eq 'prefix') { Write-Output $env:USERPROFILE; $global:LASTEXITCODE = 0; return }",
" throw 'unexpected npm command'",
"}",
"function Install-OpenClaw { return $true }",
"function Ensure-OpenClawOnPath { return $true }",
"function Add-ToUserPath { param([string]$Path) }",
+281 -8
View File
@@ -23,6 +23,7 @@ import {
writeNpmBeforePolicyFixture,
writeNpmFreshnessConflictFixture,
writeNpmInstallRetryFixture,
writeNpmLifecycleFixture,
} from "./install-npm-fixtures.js";
const SCRIPT_PATH = "scripts/install.sh";
@@ -305,7 +306,8 @@ describe("install.sh", () => {
node_dir="node-bin"
cd "$tmp"
mkdir -p "$repo/.git" "$repo/dist" "$node_dir"
touch "$repo/dist/entry.js"
repo="$(cd "$repo" && pwd -P)"
printf 'process.stdout.write("fixture-version\\n");\n' > "$repo/dist/entry.js"
cat > "$node_dir/node" <<'NODE'
#!/usr/bin/env bash
printf 'fake-node:%s\\n' "$*"
@@ -348,7 +350,7 @@ NODE
PATH="/usr/bin:/bin" "$wrapper" --version
`);
expect(result.status).toBe(0);
expect(result.status, JSON.stringify(result)).toBe(0);
expect(result.stdout).toContain("exec ");
expect(result.stdout).toContain("/node-bin/node");
expect(result.stdout).toContain("fake-node:");
@@ -470,6 +472,8 @@ NODE
replacement="$root/replacement"
alias_path="$root/alias"
mkdir -p "$target" "$replacement"
target="$(cd "$target" && pwd -P)"
replacement="$(cd "$replacement" && pwd -P)"
ln -s "$target" "$alias_path"
check_git() { return 0; }
@@ -483,7 +487,13 @@ NODE
[[ "$1" == "$target" ]]
printf '%s\\n' '--frozen-lockfile'
}
run_pnpm() { [[ "$1" == "-C" && "$2" == "$target" ]]; }
run_pnpm() {
[[ "$1" == "-C" && "$2" == "$target" ]]
if [[ "\${3:-}" == "build" ]]; then
mkdir -p "$target/dist"
printf '%s\n' 'process.stdout.write("fixture-version\\n");' > "$target/dist/entry.js"
fi
}
run_quiet_step() {
shift
"$@"
@@ -910,9 +920,222 @@ NODE
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_config_has_raw_key npm "min-release-age"');
expect(script).toContain('npm_config_has_raw_key "$npm_cmd" "min-release-age"');
expect(script).toContain('freshness_flag="--before=$(date -u');
expect(script).toContain('cmd+=(--no-fund --no-audit "$freshness_flag" install -g "$spec")');
expect(script).toContain('cmd+=(--no-fund --no-audit "$freshness_flag" install -g)');
});
it.each([
{ expected: false, version: "11.15.0" },
{ expected: true, version: "11.16.0" },
{ expected: true, version: "12.0.0" },
])("applies canonical npm lifecycle policy for npm $version", ({ expected, version }) => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-lifecycle-"));
const npm = join(tmp, "npm");
const args = join(tmp, "args");
const npmRoot = join(tmp, "lib", "node_modules");
const packageDir = join(npmRoot, "openclaw");
writeNpmLifecycleFixture(npm);
try {
const result = runInstallShell(
[
"set -euo pipefail",
`source ${JSON.stringify(SCRIPT_PATH)}`,
`npm_command_path() { printf '%s\\n' ${JSON.stringify(npm)}; }`,
`run_verified_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "log"))}`,
].join("\n"),
{
NPM_FAKE_ARGS: args,
NPM_FAKE_PACKAGE_DIR: packageDir,
NPM_FAKE_ROOT: npmRoot,
NPM_FAKE_VERSION: version,
},
);
expect(result.status).toBe(0);
expect(readFileSync(args, "utf8").includes("--allow-scripts=openclaw")).toBe(expected);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("fails before npm mutation on invalid versions and rejects a remaining guard", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-lifecycle-fail-"));
const npm = join(tmp, "npm");
const args = join(tmp, "args");
const npmRoot = join(tmp, "lib", "node_modules");
writeNpmLifecycleFixture(npm);
try {
const run = (version: string, keepGuard: string) =>
runInstallShell(
[
`source ${JSON.stringify(SCRIPT_PATH)}`,
`npm_command_path() { printf '%s\\n' ${JSON.stringify(npm)}; }`,
`run_verified_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "log"))}`,
].join("\n"),
{
NPM_FAKE_ARGS: args,
NPM_FAKE_KEEP_GUARD: keepGuard,
NPM_FAKE_PACKAGE_DIR: join(npmRoot, "openclaw"),
NPM_FAKE_ROOT: npmRoot,
NPM_FAKE_VERSION: version,
},
);
expect(run("invalid", "0").status).not.toBe(0);
expect(existsSync(args)).toBe(false);
expect(run("12.0.0", "1").status).not.toBe(0);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("relativizes absolute npm path identities against the command cwd", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-lifecycle-comma,"));
const npm = join(tmp, "npm");
const commandCwd = join(tmp, "work");
const candidate = join(tmp, "candidate.tgz");
mkdirSync(commandCwd);
writeNpmLifecycleFixture(npm);
try {
const result = runInstallShell(
[
`source ${JSON.stringify(SCRIPT_PATH)}`,
`cd ${JSON.stringify(commandCwd)}`,
`npm_lifecycle_allow_arg ${JSON.stringify(npm)} ${JSON.stringify(candidate)} "$PWD"`,
].join("\n"),
{ NPM_FAKE_VERSION: "12.0.0" },
);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("--allow-scripts=../candidate.tgz");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it.each(["success", "guard-failure"])(
"keeps same-bin git-to-npm switching rollback-safe on $mode",
(mode) => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
root="$(mktemp -d)"
repo="$root/repo"
npm_root="$root/lib/node_modules"
bin="$HOME/.local/bin"
mkdir -p "$repo/dist" "$npm_root/openclaw/dist" "$bin"
printf '%s\n' 'process.stdout.write("git-version\\n")' > "$repo/dist/entry.js"
cat > "$bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec ${process.execPath} $repo/dist/entry.js "\\$@"
EOF
chmod +x "$bin/openclaw"
fake_npm="$root/npm"
cat > "$fake_npm" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
case "\${1:-}" in
--version) printf '12.0.0\n'; exit 0 ;;
root) printf '%s\n' "$NPM_FAKE_ROOT"; exit 0 ;;
prefix) printf '%s\n' "$NPM_FAKE_PREFIX"; exit 0 ;;
config) printf 'null\n'; exit 0 ;;
esac
mkdir -p "$NPM_FAKE_ROOT/openclaw/dist"
printf '%s\n' '#!/usr/bin/env node' 'process.stdout.write("npm-version\\n")' > "$NPM_FAKE_ROOT/openclaw/openclaw.mjs"
chmod +x "$NPM_FAKE_ROOT/openclaw/openclaw.mjs"
if [[ "$NPM_FAKE_MODE" == guard-failure ]]; then
: > "$NPM_FAKE_ROOT/openclaw/dist/openclaw-install-guard"
else
rm -f "$NPM_FAKE_ROOT/openclaw/dist/openclaw-install-guard"
fi
EOF
chmod +x "$fake_npm"
npm() { "$fake_npm" "$@"; }
npm_command_path() { printf '%s\n' "$fake_npm"; }
npm_global_bin_dir() { printf '%s\n' "$bin"; }
GIT_DIR="$repo"
OPENCLAW_VERSION="$root/candidate.tgz"
export NPM_FAKE_ROOT="$npm_root" NPM_FAKE_PREFIX="$HOME/.local" NPM_FAKE_MODE=${mode}
prepare_git_wrapper_backup_for_npm "$GIT_DIR"
set +e
install_openclaw
status=$?
set -e
cleanup_tmpfiles
printf 'status=%s version=%s link=%s\n' "$status" "$("$bin/openclaw" --version)" "$([[ -L "$bin/openclaw" ]] && echo yes || echo no)"
`);
expect(result.status).toBe(0);
if (mode === "success") {
expect(result.stdout, result.stderr).toContain("status=0 version=npm-version link=yes");
} else {
expect(result.stdout, result.stderr).toContain("status=1 version=git-version link=no");
}
},
);
it("restores an active shim backup when installation is interrupted", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-shim-signal-"));
const target = join(tmp, "openclaw");
writeFileSync(target, "original-wrapper\n", { mode: 0o755 });
try {
const result = runInstallShell(
[
`source ${JSON.stringify(SCRIPT_PATH)}`,
'begin_openclaw_bin_backup "$BACKUP_TARGET" "$BACKUP_CANDIDATE" 1',
'kill -TERM "$$"',
].join("\n"),
{ BACKUP_CANDIDATE: join(tmp, "openclaw.mjs"), BACKUP_TARGET: target },
);
expect(result.status).toBe(143);
expect(readFileSync(target, "utf8")).toBe("original-wrapper\n");
expect(readdirSync(tmp)).toEqual(["openclaw"]);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("removes only stale npm rename directories before ENOTEMPTY retry", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
root="$(mktemp -d)/node_modules"
mkdir -p "$root/openclaw" "$root/.openclaw-stale"
printf 'live\n' > "$root/openclaw/marker"
npm() { [[ "$1" == root ]] && printf '%s\n' "$root"; }
run_npm_global_install() {
attempts=$((attempts + 1))
if (( attempts == 1 )); then printf 'ENOTEMPTY: directory not empty, rename openclaw\n' > "$2"; return 1; fi
return 0
}
auto_install_build_tools_for_npm_failure() { return 1; }
attempts=0
install_openclaw_npm openclaw@latest
[[ -f "$root/openclaw/marker" && ! -e "$root/.openclaw-stale" ]]
`);
expect(result.status).toBe(0);
});
it("does not report npm owner retirement when uninstall fails", () => {
const result = runInstallShell(`
source "${SCRIPT_PATH}"
root="$(mktemp -d)/node_modules"
mkdir -p "$root/openclaw"
printf '{"name":"openclaw"}\n' > "$root/openclaw/package.json"
fake_npm="$root/npm"
printf '#!/bin/sh\nif [ "$1" = root ]; then echo "$NPM_ROOT"; exit 0; fi\nexit 9\n' > "$fake_npm"
chmod +x "$fake_npm"
export NPM_ROOT="$root"
npm_command_path() { printf '%s\n' "$fake_npm"; }
npm_global_bin_dir() { printf '/different/bin\n'; }
set +e
retire_npm_owner_after_git_install
status=$?
set -e
printf 'status=%s\n' "$status"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("status=1");
expect(result.stdout).not.toContain("Previous npm install retired");
});
it("does not emit --before when raw user npmrc config contains min-release-age", () => {
@@ -954,6 +1177,7 @@ NODE
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"npm_lifecycle_allow_arg() { :; }",
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
'printf "cmd=%s\\n" "$LAST_NPM_INSTALL_CMD"',
].join("\n"),
@@ -1022,6 +1246,7 @@ NODE
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"npm_lifecycle_allow_arg() { :; }",
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
'printf "cmd=%s\\n" "$LAST_NPM_INSTALL_CMD"',
].join("\n"),
@@ -1092,6 +1317,7 @@ NODE
"set -euo pipefail",
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"npm_lifecycle_allow_arg() { :; }",
`run_npm_global_install openclaw@latest ${JSON.stringify(join(tmp, "install.log"))}`,
'printf "cmd=%s\\n" "$LAST_NPM_INSTALL_CMD"',
].join("\n"),
@@ -1156,6 +1382,7 @@ NODE
source "${SCRIPT_PATH}"
repo="$HOME/openclaw"
mkdir -p "$repo"
repo="$(cd "$repo" && pwd -P)"
check_git() { return 0; }
ensure_pnpm() { :; }
ensure_pnpm_binary_for_scripts() { :; }
@@ -1170,6 +1397,9 @@ NODE
target="\${*: -1}"
mkdir -p "$target/.git"
printf 'complete\\n' > "$target/checkout.marker"
elif [[ "$1" == "Building OpenClaw" ]]; then
mkdir -p "$repo/dist"
printf '%s\\n' 'process.stdout.write("fixture-version\\n");' > "$repo/dist/entry.js"
fi
return 0
}
@@ -1182,7 +1412,7 @@ NODE
install_openclaw_from_git "$repo"
`);
expect(result.status).toBe(0);
expect(result.status, JSON.stringify(result)).toBe(0);
expect(result.stdout).toContain(
"step:Cloning OpenClaw|git clone --filter=blob:none https://github.com/openclaw/openclaw.git",
);
@@ -1427,6 +1657,7 @@ NODE
ensure_default_node_active_shell() { return 0; }
check_git() { return 0; }
fix_npm_permissions() { :; }
prepare_git_wrapper_backup_for_npm() { :; }
install_openclaw() {
mkdir -p "$HOME/.local/bin"
printf '#!/bin/sh\\nexit 0\\n' > "$HOME/.local/bin/openclaw"
@@ -1456,6 +1687,48 @@ NODE
expect(result.stdout).toContain("dashboard-called");
});
it("fails a configured upgrade without printing success when doctor fails", () => {
const result = runInstallShell(`
source "${SCRIPT_PATH}"
INSTALL_METHOD=npm; NO_ONBOARD=1; NO_PROMPT=1; OS=linux
bootstrap_gum_temp() { :; }; print_installer_banner() { :; }; print_gum_status() { :; }
detect_os_or_die() { OS=linux; }; detect_openclaw_checkout() { return 1; }; show_install_plan() { :; }
check_existing_openclaw() { return 0; }; load_nvm_for_node_detection() { :; }; check_node() { return 0; }
activate_supported_node_on_path() { :; }; ensure_default_node_active_shell() { return 0; }
check_git() { return 0; }; fix_npm_permissions() { :; }
prepare_git_wrapper_backup_for_npm() { :; }
install_openclaw() { mkdir -p "$HOME/.local/bin"; printf '#!/bin/sh\nif [ "$1" = doctor ]; then exit 9; fi\nexit 0\n' > "$HOME/.local/bin/openclaw"; chmod +x "$HOME/.local/bin/openclaw"; }
resolve_installed_openclaw_bin() { printf '%s\n' "$HOME/.local/bin/openclaw"; }
warn_duplicate_openclaw_global_installs() { :; }; npm_global_bin_dir() { :; }; warn_shell_path_missing_dir() { :; }
has_openclaw_config() { return 0; }; refresh_gateway_service_if_loaded() { :; }
run_doctor() { return 9; }; resolve_openclaw_version() { printf 'test-version\n'; }
retire_git_wrapper_after_npm_install() { :; }; show_footer_links() { :; }
main
`);
expect(result.status).toBe(9);
expect(result.stdout).not.toContain("installed successfully");
expect(result.stdout).not.toContain("Upgrade complete");
});
it("keeps the npm owner runnable when a npm-to-git candidate fails", () => {
const result = runInstallShell(`
source "${SCRIPT_PATH}"
INSTALL_METHOD=git; GIT_DIR="$HOME/openclaw"; OS=linux
mkdir -p "$HOME/npm-owner"; printf 'working\n' > "$HOME/npm-owner/status"
bootstrap_gum_temp() { :; }; print_installer_banner() { :; }; print_gum_status() { :; }
detect_os_or_die() { OS=linux; }; detect_openclaw_checkout() { return 1; }; show_install_plan() { :; }
check_existing_openclaw() { return 0; }; load_nvm_for_node_detection() { :; }; check_node() { return 0; }
activate_supported_node_on_path() { :; }; ensure_default_node_active_shell() { return 0; }
npm() { if [[ "$1" == list ]]; then return 0; fi; if [[ "$1" == uninstall ]]; then printf 'old-owner-removed\n'; rm -f "$HOME/npm-owner/status"; fi; }
install_openclaw_from_git() { return 7; }
main
`);
expect(result.status).toBe(7);
expect(result.stdout).not.toContain("old-owner-removed");
});
it("rejects OpenClaw GitHub source targets for npm installs", () => {
const result = runInstallShell(`
set -euo pipefail
@@ -1553,7 +1826,7 @@ NODE
].join("\n"),
);
expect(result.status).toBe(7);
expect(result.status).not.toBe(0);
} finally {
rmSync(tmp, { force: true, recursive: true });
}
@@ -3435,7 +3708,7 @@ describe("install.sh doctor cancellation and dashboard guard", () => {
it("guards every run_doctor caller against failure", () => {
// A failed or cancelled doctor must not launch the dashboard.
expect(script).toContain("if run_doctor; then");
expect(script).toContain("run_doctor || return $?");
// Ensure there is no bare "run_doctor" call followed by
// "should_open_dashboard=true" without an if-guard
const bareDoctor = /^\s+run_doctor\s*$/m;
@@ -646,6 +646,7 @@ function runPackageAcceptanceSummary(params: {
advisory?: boolean;
dockerArtifactResult?: string;
dockerRegistryResult?: string;
npm12InstallResult?: string;
telegramAdvisory?: boolean;
telegramEnabled: boolean;
telegramResult: string;
@@ -662,6 +663,7 @@ function runPackageAcceptanceSummary(params: {
DOCKER_ARTIFACT_RESULT: params.dockerArtifactResult ?? "success",
DOCKER_REGISTRY_RESULT: params.dockerRegistryResult ?? "skipped",
PACKAGE_INTEGRITY_RESULT: "success",
NPM_12_INSTALL_RESULT: params.npm12InstallResult ?? "success",
PACKAGE_TELEGRAM_RESULT: params.telegramResult,
PATH: process.env.PATH,
RESOLVE_RESULT: "success",
@@ -1908,6 +1910,16 @@ describe("package acceptance workflow", () => {
expect(workflow).toContain('[[ "$actual_sha256" == "$EXPECTED_PACKAGE_SHA256" ]]');
expect(workflow).toContain("needs: [resolve_package, package_integrity]");
expect(workflow).toContain("package_integrity=${PACKAGE_INTEGRITY_RESULT}");
const npm12Job = workflowJob(PACKAGE_ACCEPTANCE_WORKFLOW, "npm_12_install_sh");
expect(jobNeeds(npm12Job)).toEqual(["resolve_package", "package_integrity"]);
expect(npm12Job.permissions).toEqual({ actions: "read", contents: "read" });
const npm12Step = workflowStep(npm12Job, "Run install.sh with npm 12");
expect(npm12Step.run).toContain("npm@12.0.2");
expect(npm12Step.run).toContain("bash scripts/install.sh");
expect(npm12Step.run).toContain("scripts/docker/install-sh-common/version-parse.sh");
expect(npm12Step.run).toContain("extract_openclaw_semver");
expect(npm12Step.run).toContain("openclaw-install-guard");
expect(JSON.stringify(npm12Job)).not.toContain("secrets.");
});
it("keeps ref packaging independent of workflow-checkout dependencies", () => {
@@ -4720,6 +4732,16 @@ describe("package artifact reuse", () => {
telegramResult: "skipped",
},
},
{
expectedOutput: "::error::npm_12_install_sh ended with failure",
expectedStatus: 1,
name: "rejects a failed npm 12 installer acceptance lane",
params: {
npm12InstallResult: "failure",
telegramEnabled: false,
telegramResult: "skipped",
},
},
{
expectedOutput:
"::warning::package_telegram ended with skipped; package acceptance is advisory for this caller.",
@@ -6576,6 +6598,9 @@ wait_for_run plugin-clawhub-new.yml 123 "${expectedSha}" || status=$?
expect(jobNeeds(workflowJob(PACKAGE_ACCEPTANCE_WORKFLOW, "summary"))).toContain(
"docker_acceptance",
);
expect(jobNeeds(workflowJob(PACKAGE_ACCEPTANCE_WORKFLOW, "summary"))).toContain(
"npm_12_install_sh",
);
expect(jobNeeds(workflowJob(RELEASE_CHECKS_WORKFLOW, "summary"))).toContain(
"package_acceptance_release_checks",
);