From bcabc5b88129fac130ab297a4ef79dc0fa49477d Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 24 Sep 2026 11:31:08 -0400 Subject: [PATCH] fix(install): keep the PowerShell session open when an iex install fails The documented one-liner, iex (irm .../install.ps1), runs the installer inside the user's own session. Fail ended with exit 1, so any failed stage closed the user's PowerShell window. Fail now throws. The two entry points own reporting and the exit code: -Stage prints the reason, emits the -Json frame and exits 1, as before; the full install exits 1 only when it runs from a script file, and under iex it prints the reason, sets LASTEXITCODE=1 and returns. A scriptblock literal's File tells the two apart: $MyInvocation.MyCommand.Path names the caller's script under iex. --- scripts/install.ps1 | 27 +++++++--- .../install/test_install_ps1_iex_failure.py | 52 +++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 tests/scripts/install/test_install_ps1_iex_failure.py 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