diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 76f5669d25..f0417982ff 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -37,6 +37,13 @@ $ErrorActionPreference = "Stop" # path/expression. The flag is checked before the entry dispatch at the bottom # (part 2), so dot-sourcing still loads every function definition. $script:IsDotSourced = $MyInvocation.InvocationName -eq '.' +# `iex (irm .../install.ps1)` runs this text inside the caller's session, +# where `exit` closes their PowerShell window (or ends their script). Only a +# script file (-File, `& .\install.ps1`) owns its process and may exit with a +# code. A scriptblock literal records the file its text was parsed from; +# iex'd text has none. ($MyInvocation.MyCommand.Path is the CALLER's script +# under iex, so it cannot tell the two apart.) +$script:RunAsFile = [bool]{}.File # $PSBoundParameters inside a FUNCTION refers to the function's own binding, # so the script's binding is captured here, once, at script scope. $script:BoundParams = $PSBoundParameters @@ -577,11 +584,9 @@ function Test-UvAtLeastPin([string]$Path) { try { return ([version]$have -ge [version]$script:UvPinVersion) } catch { return $false } } function Fail([string]$msg) { - Write-Host "[hermes] $msg" -ForegroundColor Red - # `exit` unwinds past the stage dispatcher's try/catch, so a -Json caller - # would otherwise get NO frame at all; emit the failure frame here. - if ($Json -and $Stage) { Emit-Frame $false $Stage $false $msg } - exit 1 + # Throw, never exit: the entry points below own reporting and the exit + # code, and the stage dispatcher's catch emits the -Json failure frame. + throw $msg } function Emit-Frame([bool]$ok, [string]$name, [bool]$skipped, [string]$reason = "") { @@ -1096,6 +1101,7 @@ if ($Stage) { if ($Json) { Emit-Frame $true $Stage $false } exit 0 } catch { + Write-Host "[hermes] $_" -ForegroundColor Red if ($Json) { Emit-Frame $false $Stage $false "$_" } exit 1 } @@ -1103,6 +1109,13 @@ if ($Stage) { # No -Stage: run the whole ladder — the same authoritative list the # manifest prints, so -IncludeDesktop inserts desktop here too. -foreach ($s in $Stages) { - Invoke-StageByName $s.name +try { + foreach ($s in $Stages) { + Invoke-StageByName $s.name + } +} catch { + Write-Host "[hermes] $_" -ForegroundColor Red + if ($script:RunAsFile) { exit 1 } + # Under iex: report failure without closing the user's window. + $global:LASTEXITCODE = 1 } diff --git a/tests/scripts/install/test_install_ps1_iex_failure.py b/tests/scripts/install/test_install_ps1_iex_failure.py new file mode 100644 index 0000000000..d63b453e91 --- /dev/null +++ b/tests/scripts/install/test_install_ps1_iex_failure.py @@ -0,0 +1,52 @@ +"""A failed install run through `iex (irm ...)` must not end the caller's session. + +`iex` runs the installer text inside the user's PowerShell session, so an +`exit` on failure closed their window. A script file run keeps its exit code. +""" +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + +pytestmark = pytest.mark.platforms("windows") +INSTALLER = Path(__file__).resolve().parents[3] / "scripts" / "install.ps1" +REFUSAL = "exists and is not a Hermes git checkout" + + +def _failing_install_env(tmp_path): + """An occupied non-checkout makes the repository stage Fail before any + network or git call; a present pinned-git slot satisfies prerequisites.""" + home = tmp_path / "home" + (home / "hermes-agent").mkdir(parents=True) + (home / "hermes-agent" / "user-file").write_text("preserve me", encoding="utf-8") + tools = tmp_path / "tools" + for arch in ("x64", "arm64"): + git = tools / f"git-2.53.0+3-win32-{arch}" / "cmd" / "git.exe" + git.parent.mkdir(parents=True) + git.write_bytes(b"") + return dict(os.environ, HERMES_HOME=str(home), HERMES_RUNTIME_DIR=str(tools)) + + +def _powershell(): + powershell = shutil.which("powershell") + assert powershell + return powershell + + +def test_failed_iex_install_reports_and_returns_to_the_callers_session(tmp_path): + command = (f"iex (Get-Content -Raw -LiteralPath '{INSTALLER}'); " + "Write-Output \"session alive: $LASTEXITCODE\"") + result = subprocess.run([_powershell(), "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], + env=_failing_install_env(tmp_path), capture_output=True, text=True, timeout=60) + assert REFUSAL in result.stdout + assert "session alive: 1" in result.stdout, result.stdout + result.stderr + assert (tmp_path / "home" / "hermes-agent" / "user-file").read_text(encoding="utf-8") == "preserve me" + + +def test_failed_file_install_still_exits_nonzero(tmp_path): + result = subprocess.run([_powershell(), "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(INSTALLER)], + env=_failing_install_env(tmp_path), capture_output=True, text=True, timeout=60) + assert REFUSAL in result.stdout + assert result.returncode == 1