From fbac1003f0e4f158e2c936411540808c7e7b7c75 Mon Sep 17 00:00:00 2001 From: octopus-agent Date: Thu, 2 Jul 2026 09:10:03 +0800 Subject: [PATCH] fix(install): do not kill PowerShell host under 'irm | iex' The Windows installer used 'exit' inside Install-Octopus. When run via 'irm | iex' (the documented one-liner), there is no script scope, so 'exit' terminates the entire PowerShell host process and closes the terminal window instead of just ending the install. Most users hit this via the 'already installed' branch (exit 0). Replace 'exit' with: - 'return' for the success (already-installed) path - 'throw' for error paths (still non-zero exit when run as .\install.ps1 in CI; prints the error and returns to the prompt under iex) Add tests/test.ps1 as a regression guard (source-verification + a behavioral test proving iex no longer terminates the host). --- install.ps1 | 16 ++++------- tests/test.ps1 | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 tests/test.ps1 diff --git a/install.ps1 b/install.ps1 index 620f205..e2ed669 100644 --- a/install.ps1 +++ b/install.ps1 @@ -96,16 +96,13 @@ function Install-Octopus { try { $null = Invoke-WebRequest -Uri $releaseUrl -Method Head -UseBasicParsing -ErrorAction Stop } catch { - Write-Color "Error: Release v$specificVersion not found." "Red" - Write-Color "Available releases: $baseUrl/$REPO/releases" "DarkGray" - exit 1 + throw "Release v$specificVersion not found. Available releases: $baseUrl/$REPO/releases" } } else { Write-Color "Fetching latest version..." "DarkGray" $specificVersion = Get-LatestVersion if (-not $specificVersion) { - Write-Color "Error: Failed to fetch latest version." "Red" - exit 1 + throw "Failed to fetch latest version." } } @@ -116,7 +113,7 @@ function Install-Octopus { $installedVersion = & $existing --version 2>$null if ($installedVersion -eq $specificVersion) { Write-Color "Version $specificVersion already installed." "DarkGray" - exit 0 + return } else { Write-Color "Installed version: $installedVersion" "DarkGray" } @@ -144,10 +141,8 @@ function Install-Octopus { $ProgressPreference = "SilentlyContinue" Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath -UseBasicParsing } catch { - Write-Color "Error: Failed to download $downloadUrl" "Red" - Write-Color $_.Exception.Message "Red" Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue - exit 1 + throw "Failed to download $downloadUrl : $($_.Exception.Message)" } # Extract @@ -157,9 +152,8 @@ function Install-Octopus { New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null $exeSrc = Join-Path $tmpDir "$APP.exe" if (-not (Test-Path $exeSrc)) { - Write-Color "Error: $APP.exe not found in archive." "Red" Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue - exit 1 + throw "$APP.exe not found in archive." } Copy-Item -Path $exeSrc -Destination (Join-Path $INSTALL_DIR "$APP.exe") -Force Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue diff --git a/tests/test.ps1 b/tests/test.ps1 new file mode 100644 index 0000000..e8865ef --- /dev/null +++ b/tests/test.ps1 @@ -0,0 +1,75 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Regression tests for install.ps1. + +.BUG + `irm | iex` closed the terminal window. Cause: the script used + `exit`, which under Invoke-Expression terminates the entire PowerShell + host process (there is no script scope to exit). Fix: use `return` + (success) / `throw` (errors) so the host survives. + +.NOTES + Host-process termination cannot be observed from inside the dying process, + so these tests combine a source-verification guard with a behavioral test + run in a child process (`exit` cannot be caught by try/catch and kills the + child; `throw` is catchable). +#> +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$installScript = Join-Path $repoRoot "install.ps1" +$failures = [System.Collections.Generic.List[string]]::new() + +function Assert-True([bool]$Cond, [string]$Message) { + if (-not $Cond) { $failures.Add($Message) | Out-Null } +} + +# --- Test 1 (source verification): no host-terminating `exit` --- +$content = Get-Content -Path $installScript -Raw +$exitCount = ([regex]::Matches($content, '(?m)^\s*exit\b')).Count +Assert-True ($exitCount -eq 0) "install.ps1 must not use bare 'exit' (found $exitCount); 'irm | iex' would kill the host. Use return/throw." + +# --- Test 2: parses cleanly --- +$parseOk = $true +try { [void][scriptblock]::Create($content) } catch { $parseOk = $false } +Assert-True $parseOk "install.ps1 must parse without syntax errors." + +# --- Test 3 (behavioral): iex must NOT kill the host --- +# Force the version lookup to fail fast against an unreachable address so the +# script reaches its early-exit/throw path offline. `exit` terminates the child +# (RESULT never emitted); `throw` is caught and RESULT is emitted. +$inner = @' +$ErrorActionPreference = "Stop" +$env:OCTOPUS_DOWNLOAD_BASE = "http://127.0.0.1:1" +$r = "nothing" +try { + Invoke-Expression (Get-Content -Raw "__INSTALL__") + $r = "completed" +} catch { + $r = "caught-throw" +} +Write-Output ("RESULT=$r") +'@.Replace("__INSTALL__", $installScript) + +$tmp = Join-Path $env:TEMP ("octopus_iex_test_" + $PID + ".ps1") +Set-Content -Path $tmp -Value $inner -Encoding UTF8 +try { + $childOut = (& powershell -NoProfile -File $tmp 2>&1 | Out-String) +} finally { + Remove-Item $tmp -ErrorAction SilentlyContinue +} +Assert-True ($childOut -match 'RESULT=') "iex must not terminate the host (try/catch must observe a RESULT). Child output:`n$childOut" + +# --- Report --- +if ($failures.Count -gt 0) { + Write-Host ("FAIL: " + $failures.Count + " test(s)") -ForegroundColor Red + foreach ($f in $failures) { Write-Host (" - " + $f) -ForegroundColor Red } + exit 1 +} +Write-Host "PASS: all regression tests passed." -ForegroundColor Green +exit 0