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.
This commit is contained in:
ethernet
2026-09-24 11:31:08 -04:00
parent fdea3e92e0
commit bcabc5b881
2 changed files with 72 additions and 7 deletions

View File

@@ -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
}

View File

@@ -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