diff --git a/CHANGELOG.md b/CHANGELOG.md index d5496b79e0f..170fd937d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai - CLI/model runs: keep `openclaw infer model run` on explicit OpenRouter models from loading the full provider catalog or inheriting chat-agent silent-reply policy, restoring non-empty one-shot probe output. Fixes #68791. Thanks @limpredator. - Installer/macOS: rerun Homebrew install steps without the gum spinner when raw-mode ioctl failures occur, and avoid claiming `node@24` was installed when the Homebrew keg binary is missing. Fixes #70411. Thanks @1fanwang and @dad-io. - Installer: load nvm before Node.js detection so `curl | bash` installs respect nvm-managed Node instead of stale system Node. Fixes #49556. Thanks @heavenlxj. +- Installer/Windows: route PowerShell install failures through a top-level handler so `iwr ... | iex` returns control to the current shell while direct script-file runs still exit non-zero. Fixes #38054. Thanks @PwrSrg. - CLI/Volta: respawn raw `openclaw` CLI runs through the named `node` shim when the current Node executable resolves to `volta-shim`, avoiding direct shim execution failures in non-interactive shells. Fixes #68672. Thanks @sanchezm86. - Installer: warn when multiple npm global roots contain OpenClaw installs, showing active Node/npm/openclaw plus each install path and version so stale version-manager installs are visible. Fixes #40839. Thanks @zhixianio. - Cron/tasks: recover completed cron task ledger records from durable run logs and job state before marking them `lost`, reducing false `backing session missing` audit errors for isolated cron runs and keeping offline CLI audit from treating its empty local cron active-job set as authoritative. Fixes #71963. diff --git a/docs/install/installer.md b/docs/install/installer.md index 83721f7cd76..195ba9bcc65 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -292,6 +292,9 @@ by default, plus git-checkout installs under the same prefix flow. - 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) + + `iwr ... | iex` and scriptblock installs report a terminating error without closing the current PowerShell session. Direct `powershell -File` / `pwsh -File` installs still exit non-zero for automation. + ### Examples (install.ps1) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7078c95dc61..4b08fbdb6fe 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -384,6 +384,29 @@ function Add-ToPath { } } +$script:InstallExitCode = 0 + +function Fail-Install { + param([int]$Code = 1) + + $script:InstallExitCode = $Code + return $false +} + +function Complete-Install { + param([bool]$Succeeded) + + if ($Succeeded) { + return + } + + if ($PSCommandPath) { + exit $script:InstallExitCode + } + + throw "OpenClaw installation failed with exit code $($script:InstallExitCode)." +} + # Main function Main { Write-Banner @@ -394,16 +417,16 @@ function Main { if (!(Ensure-ExecutionPolicy)) { Write-Host "" Write-Host "Installation cannot continue due to execution policy restrictions" -Level error - exit 1 + return (Fail-Install) } if (!(Ensure-Node)) { - exit 1 + return (Fail-Install) } if ($InstallMethod -eq "git") { if (!(Ensure-Git)) { - exit 1 + return (Fail-Install) } if ($DryRun) { @@ -421,7 +444,7 @@ function Main { Write-Host "[DRY RUN] Would install OpenClaw via npm ($((Resolve-PackageInstallSpec -Target $Tag)))" -Level info } else { if (!(Install-OpenClawNpm -Target $Tag)) { - exit 1 + return (Fail-Install) } } } @@ -446,6 +469,8 @@ function Main { Write-Host "" Write-Host "🦞 OpenClaw installed successfully!" -Level success + return $true } -Main +$installSucceeded = Main +Complete-Install -Succeeded:$installSucceeded diff --git a/test/scripts/install-ps1.test.ts b/test/scripts/install-ps1.test.ts new file mode 100644 index 00000000000..ef16a67809e --- /dev/null +++ b/test/scripts/install-ps1.test.ts @@ -0,0 +1,113 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createScriptTestHarness } from "./test-helpers"; + +const SCRIPT_PATH = "scripts/install.ps1"; + +function extractFunctionBody(source: string, name: string): string { + const match = source.match( + new RegExp(`^function ${name} \\{\\r?\\n([\\s\\S]*?)^\\}\\r?\\n`, "m"), + ); + expect(match?.[1]).toBeDefined(); + return match![1]; +} + +function findPowerShell(): string | undefined { + for (const candidate of ["pwsh", "powershell"]) { + const result = spawnSync( + candidate, + ["-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion"], + { + encoding: "utf8", + }, + ); + if (result.status === 0) { + return candidate; + } + } + return undefined; +} + +function toPowerShellSingleQuotedLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function createFailingNodeFixture(source: string): string { + const scriptWithoutEntryPoint = source.replace( + /\r?\n\$installSucceeded = Main\r?\nComplete-Install -Succeeded:\$installSucceeded\s*$/m, + "", + ); + expect(scriptWithoutEntryPoint).not.toBe(source); + + return [ + scriptWithoutEntryPoint, + "", + "function Write-Banner { }", + "function Ensure-ExecutionPolicy { return $true }", + "function Ensure-Node { return $false }", + "", + "$installSucceeded = Main", + "Complete-Install -Succeeded:$installSucceeded", + "", + ].join("\n"); +} + +describe("install.ps1 failure handling", () => { + const harness = createScriptTestHarness(); + const source = readFileSync(SCRIPT_PATH, "utf8"); + const powershell = findPowerShell(); + const runIfPowerShell = powershell ? it : it.skip; + + it("does not exit directly from inside Main", () => { + const mainBody = extractFunctionBody(source, "Main"); + expect(mainBody).not.toMatch(/\bexit\b/i); + expect(mainBody).toContain("return (Fail-Install)"); + }); + + it("keeps failure termination in the top-level completion handler", () => { + const completeInstallBody = extractFunctionBody(source, "Complete-Install"); + expect(completeInstallBody).toMatch(/\$PSCommandPath/); + expect(completeInstallBody).toMatch(/\bexit \$script:InstallExitCode\b/); + expect(completeInstallBody).toMatch(/\bthrow "OpenClaw installation failed with exit code/); + }); + + runIfPowerShell("exits non-zero when run as a script file", () => { + const tempDir = harness.createTempDir("openclaw-install-ps1-"); + const scriptPath = join(tempDir, "install.ps1"); + writeFileSync(scriptPath, createFailingNodeFixture(source)); + chmodSync(scriptPath, 0o755); + + const result = spawnSync( + powershell!, + ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(1); + }); + + runIfPowerShell("throws without killing the caller when run as a scriptblock", () => { + const tempDir = harness.createTempDir("openclaw-install-ps1-"); + const scriptPath = join(tempDir, "install.ps1"); + writeFileSync(scriptPath, createFailingNodeFixture(source)); + chmodSync(scriptPath, 0o755); + + const command = [ + "try {", + ` & ([scriptblock]::Create((Get-Content -LiteralPath ${toPowerShellSingleQuotedLiteral(scriptPath)} -Raw)))`, + "} catch {", + ' Write-Output "caught=$($_.Exception.Message)"', + "}", + 'Write-Output "alive-after-install"', + ].join("\n"); + const result = spawnSync(powershell!, ["-NoLogo", "-NoProfile", "-Command", command], { + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("caught=OpenClaw installation failed with exit code 1."); + expect(result.stdout).toContain("alive-after-install"); + }); +});