diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index 8efa4f4f2bee..cd5496e87c9e 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -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 diff --git a/docs/ci.md b/docs/ci.md index 593e6fb51498..741201284cd6 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -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 diff --git a/docs/cli/uninstall.md b/docs/cli/uninstall.md index 385f42fbd7b8..66e4f37b593d 100644 --- a/docs/cli/uninstall.md +++ b/docs/cli/uninstall.md @@ -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 diff --git a/docs/install/installer.md b/docs/install/installer.md index 93001f2b5e2c..c6086fe6fb0b 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -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 @@ -87,7 +91,7 @@ Recommended for most interactive installs on macOS/Linux/WSL. - 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. @@ -316,7 +320,7 @@ by default, plus git-checkout installs under the same prefix flow. - 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 diff --git a/docs/install/uninstall.md b/docs/install/uninstall.md index 88f5bf1776e8..a619836fdb05 100644 --- a/docs/install/uninstall.md +++ b/docs/install/uninstall.md @@ -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 diff --git a/docs/install/updating.md b/docs/install/updating.md index b2442e65f7ee..17772e265f38 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -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. diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index f98b16083754..bf7b71d8f478 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -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" < "${PREFIX}/bin/openclaw" <$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]?(?\d+)\.(?\d+)\.(?\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%)\\(?[^"]+?)"\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 diff --git a/scripts/install.sh b/scripts/install.sh index 16607bd7b2bf..196d859b5ecd 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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" </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[@]}" { 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 () => { diff --git a/src/cli/update-cli/update-command-git.ts b/src/cli/update-cli/update-command-git.ts index ae688e74491f..4f5ca4c8ec2e 100644 --- a/src/cli/update-cli/update-command-git.ts +++ b/src/cli/update-cli/update-command-git.ts @@ -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(); + + 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, }; diff --git a/src/cli/update-cli/update-command-post-update.test.ts b/src/cli/update-cli/update-command-post-update.test.ts index f8eeb2faceae..a7530aa8952d 100644 --- a/src/cli/update-cli/update-command-post-update.test.ts +++ b/src/cli/update-cli/update-command-post-update.test.ts @@ -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()), + 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()), + 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()), + tryWriteCompletionCache: mocks.tryWriteCompletionCache, +})); vi.mock("./update-command-service.js", async (importOriginal) => ({ ...(await importOriginal()), + maybeRestartService: mocks.restartService, maybeRestartServiceAfterFailedMutableUpdate: mocks.restart, restoreWindowsTaskAutoStartOrExit: mocks.restoreWindowsAutoStart, + tryInstallShellCompletion: mocks.tryInstallCompletion, })); vi.mock("./update-command-post-core.js", async (importOriginal) => ({ ...(await importOriginal()), + markControlPlaneUpdateRestartSentinelFailureBestEffort: mocks.markSentinelFailure, writeControlPlaneUpdateRestartSentinelBestEffort: mocks.writeSentinel, })); +import { retireStandaloneGitWrapper } from "./update-command-git.js"; import { finishUpdate } from "./update-command-post-update.js"; type FinishUpdateParams = Parameters[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 { + 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", diff --git a/src/cli/update-cli/update-command-post-update.ts b/src/cli/update-cli/update-command-post-update.ts index 3d6a0e9d9b3c..eb748ad4ccf1 100644 --- a/src/cli/update-cli/update-command-post-update.ts +++ b/src/cli/update-cli/update-command-post-update.ts @@ -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>; requestedChannel: UpdateChannel | null; @@ -105,7 +107,7 @@ export async function finishUpdate(params: { updateStepTimeoutMs: number; invocationCwd?: string; }): Promise { - 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); } } diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index f66f4acf1a3c..4e7f9c50e3cd 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -663,6 +663,7 @@ async function updateCommandInternal( await finishUpdate({ result, root, + previousInstallRoot: discoveredRoot, installKindChanged: switchToGit || switchToPackage, configSnapshot: finalizationConfigSnapshot, requestedChannel, diff --git a/src/commands/cleanup-command.test-support.ts b/src/commands/cleanup-command.test-support.ts index c184ea4073ea..a83f3b65f8ee 100644 --- a/src/commands/cleanup-command.test-support.ts +++ b/src/commands/cleanup-command.test-support.ts @@ -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])); +} diff --git a/src/commands/cleanup-live-state.test.ts b/src/commands/cleanup-live-state.test.ts index 4fae6ebee344..0cbd40a5904d 100644 --- a/src/commands/cleanup-live-state.test.ts +++ b/src/commands/cleanup-live-state.test.ts @@ -125,6 +125,7 @@ describe("destructive cleanup with a live unmanaged state owner", () => { nixMode: false, preservesWorkspace: false, serviceChecks: 1, + aggregatesFailure: false, run: (runtime: ReturnType) => 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) => 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) => 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"); diff --git a/src/commands/cleanup-plan.ts b/src/commands/cleanup-plan.ts index af84425ef842..8b0d2c0d1738 100644 --- a/src/commands/cleanup-plan.ts +++ b/src/commands/cleanup-plan.ts @@ -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); diff --git a/src/commands/cleanup-utils.test.ts b/src/commands/cleanup-utils.test.ts index b8802ffead92..33b9bdb2c971 100644 --- a/src/commands/cleanup-utils.test.ts +++ b/src/commands/cleanup-utils.test.ts @@ -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-")); diff --git a/src/commands/cleanup-utils.ts b/src/commands/cleanup-utils.ts index 79d1176c521d..f9e107438ed6 100644 --- a/src/commands/cleanup-utils.ts +++ b/src/commands/cleanup-utils.ts @@ -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; }, @@ -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; diff --git a/src/commands/reset.ts b/src/commands/reset.ts index 818d457d1a67..0672c19ba942 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -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 } = diff --git a/src/commands/uninstall.test.ts b/src/commands/uninstall.test.ts index 2ac5624861a8..6909b5df8728 100644 --- a/src/commands/uninstall.test.ts +++ b/src/commands/uninstall.test.ts @@ -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(); + }); }); diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 4fab7a466d7b..6513a8b19e77 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -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 { return stopped; } -async function removeMacApp(runtime: RuntimeEnv, dryRun?: boolean) { +async function removeMacApp(runtime: RuntimeEnv, dryRun?: boolean): Promise { 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); + } } diff --git a/src/docs/install-cloud-secrets.test.ts b/src/docs/install-cloud-secrets.test.ts index 584bfa749101..10d21c652ee0 100644 --- a/src/docs/install-cloud-secrets.test.ts +++ b/src/docs/install-cloud-secrets.test.ts @@ -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"); } } }); diff --git a/src/hooks/gmail-watcher.integration.test.ts b/src/hooks/gmail-watcher.integration.test.ts index 1272d1e62c06..59ec0bb98d2f 100644 --- a/src/hooks/gmail-watcher.integration.test.ts +++ b/src/hooks/gmail-watcher.integration.test.ts @@ -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); }); diff --git a/src/infra/package-update-steps.test.ts b/src/infra/package-update-steps.test.ts index fa6033992d4b..9f62c43b58db 100644 --- a/src/infra/package-update-steps.test.ts +++ b/src/infra/package-update-steps.test.ts @@ -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", ); }); }); diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts index b0811237ee1a..1a93da47fade 100644 --- a/src/infra/package-update-steps.ts +++ b/src/infra/package-update-steps.ts @@ -757,6 +757,27 @@ async function restoreNpmBinShimBackup(backup: NpmBinShimBackup): Promise } } +async function activateStagedNpmPackageRoot(source: string, destination: string): Promise { + 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({ diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 254b61c650f3..37e1f7fcd6ba 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -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}`, { diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index 49142da951db..d8efbcdfa8be 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -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() { :; }", diff --git a/test/scripts/install-npm-fixtures.ts b/test/scripts/install-npm-fixtures.ts index ba53001892d4..62876f473714 100644 --- a/test/scripts/install-npm-fixtures.ts +++ b/test/scripts/install-npm-fixtures.ts @@ -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); +} diff --git a/test/scripts/install-ps1.test.ts b/test/scripts/install-ps1.test.ts index 56b5e107c17e..0b6d07714dd5 100644 --- a/test/scripts/install-ps1.test.ts +++ b/test/scripts/install-ps1.test.ts @@ -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) }", diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index 44e13cf6bba3..541d20dfb0a0 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -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" < "$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; diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 08949245f7af..ef79302485c5 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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", );