Review findings against the pm-clean installers, each reproduced first: - install.sh: `curl | bash` aborted before main under `set -u` (empty BASH_SOURCE). The entry guard falls back to $0. - install.sh: setup/gateway read stdin, which under `curl | bash` is the script itself. They open /dev/tty when a terminal can be opened, and otherwise skip with guidance. - Both: any uv on PATH was trusted. uv 0.6.17 has no `python install --no-bin`. A PATH uv now has to run and be at least the pinned version, otherwise the pin is staged. - install.sh: the staged uv went under ~/.hermes/tools even with a custom --hermes-home. It now goes to pm's store_root() default, $HERMES_HOME/tools. - Both: when a stash failed, the script logged "overwritten below" and ran `reset --hard` anyway. Local work is now parked before checkout, and a stash failure stops the install. - Both: reruns ignored an explicit HERMES_REPO_URL. It now repoints origin. - Both: --commit had no ancestor guard. The pin must be on the installed branch. - install.sh: the blobless fallback was `--depth 1 --single-branch`, so a non-tip --commit could not check out. It now keeps full history with blobs fetched on demand. - Both: ported main's recovery for a commit-less .git (moved aside, #40998) and for an unmerged index (reset -q before the stash, #4735). Stashing before checkout makes both reachable. - install.ps1: on Windows PowerShell 5.1, `native 2>$null` / `2>&1` under Stop turns stderr into a terminating NativeCommandError, verified on a Win11 host. Every native call now goes through Invoke-Native, which relaxes the preference only for that call. - install.ps1: clone publishes from a staging dir, with retries and a blobless fallback, and refuses a non-empty destination (mirrors install.sh). UV_NO_CONFIG and `--no-registry` are restored. pwsh 7 HttpRequestException falls back to the mirror, except TLS trust failures. tar resolves from System32. A literal CR/LF in the desktop failure message is removed. Deletes six tests that regex-extracted main's legacy install.sh functions (node, browsers, PATH block, lockfile churn; pm runs `npm ci` whenever a lockfile exists). The two behaviours still relevant are covered by new behavioural tests.
92 lines
4.0 KiB
Python
92 lines
4.0 KiB
Python
"""install.ps1 native calls survive Windows PowerShell 5.1, and reruns keep local work.
|
|
|
|
Windows PowerShell 5.1 turns a native command's redirected stderr into an
|
|
ErrorRecord that terminates a script running under
|
|
``$ErrorActionPreference = "Stop"``. These run the real installer under
|
|
``powershell.exe`` (5.1) -- dot-sourced for its helpers, or as the real
|
|
``-Stage repository -Json`` entry against local git repositories.
|
|
"""
|
|
import json
|
|
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"
|
|
|
|
|
|
def _powershell() -> str:
|
|
powershell = shutil.which("powershell")
|
|
assert powershell, "Windows PowerShell 5.1 is part of every supported Windows"
|
|
return powershell
|
|
|
|
|
|
def _dot_sourced(body: str) -> subprocess.CompletedProcess:
|
|
script = f'$ErrorActionPreference = "Stop"; . "{INSTALLER}"; {body}'
|
|
return subprocess.run([_powershell(), "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
|
capture_output=True, text=True, timeout=120)
|
|
|
|
|
|
def test_native_stderr_under_stop_leaves_the_exit_code_to_the_caller():
|
|
result = _dot_sourced('Invoke-Native { cmd /c "echo soft-failure 1>&2 & exit 3" 2>$null }; '
|
|
'"exit=$LASTEXITCODE preference=$ErrorActionPreference"')
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert "exit=3 preference=Stop" in result.stdout
|
|
|
|
|
|
def test_path_uv_must_run_and_meet_the_pin(tmp_path):
|
|
for name, body in {"old": "@echo uv 0.6.17", "new": "@echo uv 99.0.0 (abc 2099-01-01)",
|
|
"broken": "@echo boom 1>&2 & exit /b 1"}.items():
|
|
(tmp_path / f"{name}.cmd").write_text(body + "\r\n")
|
|
checks = "; ".join(f'"{n}=$(Test-UvAtLeastPin \'{tmp_path / (n + ".cmd")}\')"' for n in ("old", "new", "broken"))
|
|
result = _dot_sourced(checks)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert result.stdout.split() == ["old=False", "new=True", "broken=False"]
|
|
|
|
|
|
def _git(repo: Path, *args: str) -> str:
|
|
return subprocess.run(["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t", *args],
|
|
check=True, capture_output=True, text=True).stdout.strip()
|
|
|
|
|
|
def _stage(origin: Path, home: Path, *extra: str) -> tuple[subprocess.CompletedProcess, dict]:
|
|
env = dict(os.environ, HERMES_REPO_URL=str(origin), HERMES_HOME=str(home))
|
|
result = subprocess.run([_powershell(), "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(INSTALLER),
|
|
"-Stage", "repository", "-Json", *extra],
|
|
env=env, capture_output=True, text=True, timeout=180)
|
|
frames = [json.loads(line) for line in result.stdout.splitlines() if line.startswith("{")]
|
|
assert len(frames) == 1, result.stdout + result.stderr
|
|
return result, frames[0]
|
|
|
|
|
|
def test_rerun_parks_local_work_and_pins_only_branch_commits(tmp_path):
|
|
origin = tmp_path / "origin"
|
|
origin.mkdir()
|
|
_git(origin, "init", "-q", "-b", "main")
|
|
(origin / "README").write_text("one")
|
|
_git(origin, "add", "README")
|
|
_git(origin, "commit", "-qm", "one")
|
|
home = tmp_path / "home"
|
|
install = home / "hermes-agent"
|
|
assert _stage(origin, home)[1]["ok"] is True
|
|
(install / "README").write_text("local edit")
|
|
(origin / "README").write_text("two")
|
|
_git(origin, "commit", "-qam", "two")
|
|
_git(origin, "checkout", "-qb", "side")
|
|
(origin / "README").write_text("side")
|
|
_git(origin, "commit", "-qam", "side")
|
|
off_branch = _git(origin, "rev-parse", "HEAD")
|
|
_git(origin, "checkout", "-q", "main")
|
|
|
|
result, frame = _stage(origin, home)
|
|
assert frame["ok"] is True, result.stdout + result.stderr
|
|
assert (install / "README").read_text() == "two"
|
|
assert "local edit" in _git(install, "stash", "show", "-p", "stash@{0}")
|
|
|
|
result, frame = _stage(origin, home, "-Commit", off_branch)
|
|
assert frame["ok"] is False
|
|
assert "is not on branch main" in frame["reason"]
|