From e49073ecf36217392a278c76bf54ec0630eb197a Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 10 Sep 2026 01:58:34 -0400 Subject: [PATCH] fix(install): publish source launchers through one writer Setup searched for console scripts in a checkout-local venv that PM no longer creates. Publish both commands through the shared writer after PM setup, using store Python rather than a dependency interpreter. Native and shell launchers load the selected dependency generation at boot, retain the custom-home default, and ignore foreign Python paths. Both installer stages reuse their bootstrap interpreter for publication. PowerShell passes the resolved home to child processes. Verified: 57 focused tests passed with one POSIX host skip. Real Windows launchers, generated shell scripts, and both stage callers ran against temporary trees. No full cold dependency install or native POSIX install was run. The user-PATH edge is stubbed in the PowerShell test. --- hermes_cli/_launchers.py | 141 +++++++-------- scripts/install.ps1 | 49 +++--- scripts/install.sh | 61 ++----- setup-hermes.sh | 28 ++- .../test_launcher_runtime_selection.py | 3 +- .../test_source_launcher_publication.py | 118 +++++++++++++ ...t_install_ps1_managed_python_provenance.py | 2 +- tests/test_source_launcher_stages.py | 160 ++++++++++++++++++ 8 files changed, 403 insertions(+), 159 deletions(-) create mode 100644 tests/hermes_cli/test_source_launcher_publication.py create mode 100644 tests/test_source_launcher_stages.py diff --git a/hermes_cli/_launchers.py b/hermes_cli/_launchers.py index 07912adacc..914f02f510 100644 --- a/hermes_cli/_launchers.py +++ b/hermes_cli/_launchers.py @@ -1,39 +1,26 @@ -"""Windows launcher staging: `hermes` / `hermes-acp` launchers that boot the -pm STORE python with ``PYTHONPATH=;/site-packages`` — never -``venv\\Scripts\\python.exe`` (pm work item 3, "no boot through the venv"; -``pyvenv.cfg`` is inert dead config). +"""Source-install launchers shared by setup, installers, and Windows repair. -Two consumers: +Launchers execute store Python in isolated mode. They set the install's +default home and load hermes_bootstrap before the entry point. Bootstrap +reads the selected dependency generation at each start. -- ``scripts/install.ps1`` Stage-Path (bootstrap) calls - :func:`ensure_install_launchers` through the venv python — install-time - use of the venv interpreter is fine; it is the materializer, not a boot - path. On a fresh install the pm store interpreter does not exist yet, so - a runtime-resolving ``.cmd`` delegator is staged and - ``hermes_cli._install_repair.ensure_windows_bin_launchers`` upgrades it - to an exe once ``hermes pm install`` lands the store python. -- ``hermes_cli/_install_repair.py`` calls the same machinery per-name when - a launcher is missing or still boots through the venv. - -Store paths come from the stdlib-only runtime_paths owner, shared with PM. -Launchers import hermes_bootstrap before the entry point: the dependency -selection is read at boot, not frozen when the launcher is minted. - -The exe form uses distlib's ScriptMaker with a customized script template -that inserts the repo root and the venv's site-packages into ``sys.path`` -before importing the entry point — distlib is taken from pip's vendored -copy when the standalone package is absent (uv-synced venvs carry pip but -rarely standalone distlib). When distlib is unavailable, a ``.cmd`` -delegator carrying the same PYTHONPATH composition is written instead. +Windows uses distlib executables or a command-file fallback. POSIX uses +an executable shell wrapper. The standalone writer requires PM's store +interpreter before it publishes either command. """ from __future__ import annotations import json import os +import shlex +import sys from pathlib import Path -from hermes_constants import project_venv_dir +if __name__ == "__main__": + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from hermes_constants import get_hermes_home, project_venv_dir from hermes_cli.runtime_paths import site_packages, store_root #: Launcher command names — keep in lockstep with scripts/install.ps1 @@ -148,45 +135,26 @@ def mint_launcher( python_exe: Path, site_packages: Path | None, ) -> Path | None: - """Write one launcher for *name* into out_dir. Returns the written path - or None. Prefers a distlib exe trampoline whose embedded script inserts - the repo root and site-packages before importing the entry point; falls - back to a .cmd delegator (same interpreter, same PYTHONPATH) when - distlib is unavailable.""" + """Write a native launcher with the shared bootstrap script, or return None.""" module, func = ENTRY_POINTS[name] out_dir = Path(out_dir) + script = _launcher_script(name, Path(repo_root), site_packages) + + if not _is_windows(): + return _mint_shell_launcher(name, out_dir, python_exe, script) script_maker_cls = _load_script_maker() if script_maker_cls is not None: - # The template is %-formatted by distlib with (module, import_name, - # func); the install-time paths are baked in as literals. The repo - # root goes FIRST — its packages win over site-packages (same - # ordering as the desktop shim's PYTHONPATH composition). - esc = lambda p: str(p).replace("%", "%%") # noqa: E731 - site_line = ( - f"sys.path.append({esc(site_packages)!r})\n" if site_packages else "" - ) - class _PathedScriptMaker(script_maker_cls): # type: ignore[misc,valid-type] - script_template = ( - "# -*- coding: utf-8 -*-\n" - "import re\n" - "import sys\n" - f"sys.path.insert(0, {esc(repo_root)!r})\n" - f"{site_line}" - "import hermes_bootstrap\n" - "if __name__ == '__main__':\n" - " from %(module)s import %(import_name)s\n" - " sys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\n" - " sys.exit(%(func)s())\n" - ) + def _get_script_text(self, entry): + return script maker = _PathedScriptMaker(None, str(out_dir), add_launchers=True) maker.executable = str(python_exe) maker.variants = {""} maker.clobber = True try: - written = maker.make(f"{name} = {module}:{func}") + written = maker.make(f"{name} = {module}:{func}", {"interpreter_args": ["-I"]}) except Exception: written = [] for path in written: @@ -194,29 +162,50 @@ def mint_launcher( return Path(path) # distlib ran but produced no exe (unexpected) — fall through to cmd. - site = f";{site_packages}" if site_packages else "" - code = f"import sys; import hermes_bootstrap; from {module} import {func}; sys.exit({func}())" + # The script is data to Python, not interpolated shell source. + import base64 + encoded = base64.b64encode(script.encode("utf-8")).decode("ascii") + code = f"import base64; exec(base64.b64decode('{encoded}'))" body = ( "@echo off\r\n" "chcp 65001 >nul\r\n" - f'set "PYTHONPATH={repo_root}{site}"\r\n' - 'set "PYTHONHOME="\r\n' - f'"{python_exe}" -c "{code}" %*\r\n' + f'"{python_exe}" -I -c "{code}" %*\r\n' ) return _write_atomic(out_dir / f"{name}.cmd", lambda p: p.write_text(body, encoding="utf-8")) +def _launcher_script(name: str, repo_root: Path, dependencies: Path | None) -> str: + module, func = ENTRY_POINTS[name] + return ( + "import os, re, sys\n" + f"os.environ['HERMES_HOME'] = os.environ.get('HERMES_HOME') or {str(get_hermes_home())!r}\n" + "os.environ.pop('PYTHONHOME', None)\n" + "os.environ.pop('PYTHONPATH', None)\n" + f"sys.path.insert(0, {str(repo_root.resolve())!r})\n" + + (f"sys.path.append({str(dependencies)!r})\n" if dependencies else "") + + "import hermes_bootstrap\n" + f"from {module} import {func}\n" + "sys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\n" + f"sys.exit({func}())\n" + ) + + +def _mint_shell_launcher(name: str, out_dir: Path, python_exe: Path, script: str) -> Path | None: + command = shlex.join([str(python_exe), "-I", "-c", script]) + + def write(staging: Path) -> None: + staging.write_text(f'#!/bin/sh\nexec {command} "$@"\n', encoding="utf-8", newline="\n") + staging.chmod(0o755) + + return _write_atomic(out_dir / name, write) + + def stage_launcher(name: str, repo_root: Path, out_dir: Path) -> Path | None: - """Stage/refresh ONE launcher for the install rooted at repo_root, - writing what the CURRENT state supports: + """Publish one launcher bound to the current store interpreter. - - store interpreter present → exe (or .cmd if distlib is missing) bound - to it, with the repo-first PYTHONPATH baked in; - - store interpreter absent → a runtime-resolving .cmd that finds the - store python at boot and fails with a clear message until - `hermes pm install` materializes it. - - Never raises; returns the written path or None.""" + Windows repair retains a command-file fallback when the store is absent. + The standalone install writer refuses that incomplete state. + """ repo_root = Path(repo_root) venv_dir = project_venv_dir(repo_root) dependencies = site_packages(venv_dir) if venv_dir else None @@ -225,6 +214,8 @@ def stage_launcher(name: str, repo_root: Path, out_dir: Path) -> Path | None: path = mint_launcher(name, repo_root, out_dir, store_python, dependencies) if path is not None: return path + if not _is_windows(): + return None return _write_runtime_cmd(name, repo_root, dependencies, out_dir) @@ -282,3 +273,19 @@ def _write_runtime_cmd( Path(out_dir) / f"{name}.cmd", lambda p: p.write_text(body, encoding="utf-8"), ) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Publish source-install launchers.") + parser.add_argument("out_dir", type=Path) + args = parser.parse_args() + repo_root = Path(__file__).resolve().parents[1] + if resolve_store_python(repo_root) is None: + parser.exit(1, "hermes: store interpreter is missing; finish pm install before publishing launchers\n") + args.out_dir.mkdir(parents=True, exist_ok=True) + written = ensure_install_launchers(repo_root, args.out_dir) + if len(written) != len(ENTRY_POINTS): + parser.exit(1, "hermes: launcher publication failed\n") + print("\n".join(written)) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index eec720d5c9..42fa6de6c4 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -299,10 +299,9 @@ function Initialize-ResolvedPaths { if ($script:BoundParams.ContainsKey('InstallDir')) { $script:InstallDir = ConvertTo-LongPath $script:InstallDir } else { - $script:InstallDir = ConvertTo-LongPath $( - if ($env:HERMES_HOME) { "$env:HERMES_HOME\hermes-agent" } else { "$env:LOCALAPPDATA\hermes\hermes-agent" } - ) + $script:InstallDir = Join-Path $script:HermesHome 'hermes-agent' } + $env:HERMES_HOME = $script:HermesHome if ($script:NormalizedProfilePaths) { Write-PathDiag "resolved install paths: HermesHome=$script:HermesHome InstallDir=$script:InstallDir" } @@ -501,20 +500,25 @@ function Stage-Venv { # tool store — all hash-verified against pm/lock.json + uv.lock. install.ps1 # no longer runs `uv sync` directly; pm is the single install authority # (the run_locked_uv_sync contract moved into pm/packages.py::uv_env). -function Invoke-BootstrapPm { +function Get-BootstrapPython { $uv = Get-Uv $lock = Get-Content (Join-Path $InstallDir "pm\lock.json") -Raw | ConvertFrom-Json $pyPin = $lock.packages.python $pyVersion = if ($pyPin) { ($pyPin.version -split '\+')[0] -replace '^(\d+\.\d+).*', '$1' } else { '3.14' } + & $uv python install --no-bin $pyVersion | Out-Host + if ($LASTEXITCODE) { Fail "bootstrap Python installation failed" } + $bootPy = (& $uv python find --managed-python --no-project $pyVersion) -join "`n" + if ($LASTEXITCODE -or -not $bootPy) { Fail "bootstrap Python lookup failed" } + return $bootPy.Trim() +} + +function Invoke-BootstrapPm { + $bootPy = Get-BootstrapPython Log "delegating python + venv + tools to pm (hash-verified via uv.lock)" Push-Location $InstallDir try { # Finish bootstrap uv before PM replaces or cleans its store entry. - & $uv python install --no-bin $pyVersion - if ($LASTEXITCODE) { Fail "bootstrap Python installation failed" } - $bootPy = (& $uv python find --managed-python $pyVersion) -join "`n" - if ($LASTEXITCODE -or -not $bootPy) { Fail "bootstrap Python lookup failed" } - & $bootPy.Trim() -m pm.cli install + & $bootPy -m pm.cli install if ($LASTEXITCODE) { Fail "pm install failed" } } finally { Pop-Location @@ -531,28 +535,25 @@ function Stage-NodeDeps { function Stage-Path { $binDir = Join-Path $HermesHome "bin" - New-Item -ItemType Directory -Force -Path $binDir | Out-Null - # Mint the boot launchers (hermes / hermes-acp) bound to the pm STORE - # python with PYTHONPATH=repo;venv-site-packages — never the venv - # python (no boot through the venv; pyvenv.cfg is inert dead config). - # The venv python below is install-time machinery (the materializer), - # not a boot path. On a fresh install the store interpreter does not - # exist yet, so a runtime-resolving .cmd delegator is staged; - # hermes_cli/_install_repair.py upgrades it to an exe once - # `hermes pm install` materializes the store. - $venvPython = Join-Path $InstallDir "venv\Scripts\python.exe" - if (-not (Test-Path $venvPython)) { Fail "venv python missing at $venvPython" } + $bootPy = Get-BootstrapPython Push-Location $InstallDir - & $venvPython -c "from hermes_cli._launchers import ensure_install_launchers; import sys; written = ensure_install_launchers(r'$InstallDir', r'$binDir'); print(';'.join(written)); sys.exit(0 if written else 1)" - $code = $LASTEXITCODE - Pop-Location + try { + & $bootPy -I -X utf8 hermes_cli/_launchers.py $binDir + $code = $LASTEXITCODE + } finally { + Pop-Location + } if ($code) { Fail "launcher staging failed" } + Set-LauncherUserPath $binDir + Log "hermes command installed at $binDir" +} + +function Set-LauncherUserPath([string]$binDir) { $userPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($userPath -notlike "*$binDir*") { [Environment]::SetEnvironmentVariable("Path", "$binDir;$userPath", "User") Log "added $binDir to your user PATH (new shells pick it up)" } - Log "hermes command installed at $binDir" } function Stage-Config { diff --git a/scripts/install.sh b/scripts/install.sh index f8c51af3c2..737c5d9b6f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -307,21 +307,24 @@ stage_venv() { (cd "$INSTALL_DIR" && "$UV_CMD" venv --allow-existing venv) || fail "uv venv failed" } -# uv installs and locates bootstrap Python, then exits before PM starts. -# PM owns the final interpreter, tool store, and selected dependency generation. -bootstrap_pm() { +# Resolve the bootstrap interpreter without assuming a checkout-local venv. +bootstrap_python() { ensure_uv local _py _py="$(awk '/^ "python": \{/ { in_py = 1 } in_py && /^ "version":/ { gsub(/.*: "|"$|",$/, ""); print; exit }' \ "$INSTALL_DIR/pm/lock.json" | cut -d+ -f1 | cut -d. -f1,2)" [ -n "$_py" ] || _py="3.14" - log "delegating python + venv + tools to pm (hash-verified via uv.lock)" - # Finish bootstrap uv before PM replaces or cleans its store entry. "$UV_CMD" python install --no-bin "$_py" || fail "bootstrap Python installation failed" - local boot_py boot_py="$("$UV_CMD" python find --managed-python "$_py")" || fail "bootstrap Python lookup failed" boot_py="${boot_py%$'\r'}" +} + +# uv exits before PM can replace its tool entry. +bootstrap_pm() { + local boot_py + bootstrap_python + log "delegating python + venv + tools to pm (hash-verified via uv.lock)" (cd "$INSTALL_DIR" && "$boot_py" -m pm.cli install) || fail "pm install failed" } @@ -337,49 +340,9 @@ stage_node_deps() { stage_path() { local link_dir="$HOME/.local/bin" - mkdir -p "$link_dir" - rm -f "$link_dir/hermes" - # Boot wrapper: exec the pm STORE python with PYTHONPATH=repo:venv- - # site-packages (repo first). Never boots through venv/bin/python — - # the venv is only a uv sync target and pyvenv.cfg is inert dead - # config (pm work item 3). Store root + python entry mirror - # pm/paths.py / pm facts.json; resolved at BOOT so the wrapper picks - # up `hermes pm install` whenever it materializes the store. - cat > "$link_dir/hermes" <&2 - exit 1 -fi -site="" -for d in "\$repo"/venv/lib/python3.*/site-packages "\$repo"/.venv/lib/python3.*/site-packages; do - [ -d "\$d" ] && site="\$d" -done -export PYTHONPATH="\$repo\${site:+:\$site}" -exec "\$store_py" "\$repo/hermes" "\$@" -WRAPPER - chmod +x "$link_dir/hermes" + local boot_py + bootstrap_python + (cd "$INSTALL_DIR" && "$boot_py" -I -X utf8 hermes_cli/_launchers.py "$link_dir") || fail "launcher publication failed" case ":$PATH:" in *":$link_dir:"*) : ;; *) log "add $link_dir to your PATH to use the hermes command" ;; diff --git a/setup-hermes.sh b/setup-hermes.sh index 969b82bef9..49afcc82aa 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -155,27 +155,21 @@ else fi # ============================================================================ -# PATH setup — symlink hermes into a user-facing bin dir +# Publish user-facing launchers # ============================================================================ echo -e "${CYAN}→${NC} Setting up hermes command..." -# pm installs the venv; find its python across layouts (posix venv vs win). -PYBIN="" -for candidate in "$SCRIPT_DIR/venv/bin/python" "$SCRIPT_DIR/venv/Scripts/python.exe"; do - [ -x "$candidate" ] && { PYBIN="$candidate"; break; } -done - -HERMES_BIN="" -for candidate in "$SCRIPT_DIR/venv/bin/hermes" "$SCRIPT_DIR/venv/Scripts/hermes.exe"; do - [ -e "$candidate" ] && { HERMES_BIN="$candidate"; break; } -done - -if [ -n "$HERMES_BIN" ] && [ "$os" != win32 ]; then - mkdir -p "$HOME/.local/bin" - ln -sf "$HERMES_BIN" "$HOME/.local/bin/hermes" - echo -e "${GREEN}✓${NC} Symlinked hermes → ~/.local/bin/hermes" +# Reuse the bootstrap interpreter only to run the shared launcher writer. +bin_dir="$HOME/.local/bin" +if [ "$os" = win32 ]; then + bin_dir="$(cygpath -am "${HERMES_HOME:-${LOCALAPPDATA:-$HOME/AppData/Local}/hermes}/bin")" fi +if ! "$boot_py" -I -X utf8 hermes_cli/_launchers.py "$bin_dir"; then + echo -e "${RED}✗${NC} launcher publication failed" >&2 + exit 1 +fi +echo -e "${GREEN}✓${NC} Published Hermes commands in $bin_dir" if [ "$os" != win32 ]; then # Determine the appropriate shell config file @@ -224,7 +218,7 @@ mkdir -p "$HERMES_SKILLS_DIR" echo "" echo "Syncing bundled skills to ~/.hermes/skills/ ..." -if [ -n "$PYBIN" ] && "$PYBIN" "$SCRIPT_DIR/tools/skills_sync.py" 2>/dev/null; then +if "$boot_py" -m tools.skills_sync 2>/dev/null; then echo -e "${GREEN}✓${NC} Skills synced" else # Fallback: copy if sync script fails (missing deps, etc.) diff --git a/tests/hermes_cli/test_launcher_runtime_selection.py b/tests/hermes_cli/test_launcher_runtime_selection.py index 84bbdc214b..51a09c8ae5 100644 --- a/tests/hermes_cli/test_launcher_runtime_selection.py +++ b/tests/hermes_cli/test_launcher_runtime_selection.py @@ -13,7 +13,7 @@ from hermes_cli.runtime_paths import install_state_dir, site_packages @pytest.mark.platforms("windows") def test_minted_launcher_reads_current_selection_and_editable_members(tmp_path, monkeypatch): - from hermes_cli import runtime_paths + from hermes_cli import runtime_paths, runtime_state import hermes_constants root = tmp_path / "repo" @@ -22,6 +22,7 @@ def test_minted_launcher_reads_current_selection_and_editable_members(tmp_path, (package / "__init__.py").write_text("") # Real selection code, with a fixture entry point rather than a live CLI. (package / "runtime_paths.py").write_bytes(Path(runtime_paths.__file__).read_bytes()) + (package / "runtime_state.py").write_bytes(Path(runtime_state.__file__).read_bytes()) (root / "hermes_constants.py").write_bytes(Path(hermes_constants.__file__).read_bytes()) (root / "hermes_bootstrap.py").write_text( "from pathlib import Path\nfrom hermes_cli.runtime_paths import activate_dependencies\n" diff --git a/tests/hermes_cli/test_source_launcher_publication.py b/tests/hermes_cli/test_source_launcher_publication.py new file mode 100644 index 0000000000..e3ac42abe3 --- /dev/null +++ b/tests/hermes_cli/test_source_launcher_publication.py @@ -0,0 +1,118 @@ +"""Source launchers keep custom-home and selected-generation state at boot.""" +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys + +import pytest + +from hermes_cli import _launchers +from hermes_cli.runtime_paths import install_state_dir, site_packages + +ROOT = Path(__file__).resolve().parents[2] +BOOT_FILES = ( + "hermes_bootstrap.py", "hermes_constants.py", "hermes_cli/__init__.py", "hermes_cli/_launchers.py", + "hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py", + "hermes_cli/_early_recovery.py", "hermes_cli/_parser.py", +) + + +def fixture_tree(tmp_path, monkeypatch): + repo = tmp_path / "source 'café repo" + for relative in BOOT_FILES: + destination = repo / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / relative, destination) + (repo / "acp_adapter").mkdir() + (repo / "acp_adapter" / "__init__.py").write_text("", encoding="utf-8") + entry = ( + "import json, os, sys\n" + "def main():\n" + " import selected_probe\n" + " print(json.dumps({'value': selected_probe.VALUE, 'argv': sys.argv[1:], " + "'home': os.environ.get('HERMES_HOME'), 'exe': sys.executable}))\n" + " return 7\n" + ) + for path in (repo / "hermes_cli/main.py", repo / "acp_adapter/entry.py"): + path.write_text(entry, encoding="utf-8") + home = tmp_path / "custom 'café home" + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.delenv("HERMES_RUNTIME_DIR", raising=False) + store = home / "tools" + store.mkdir(parents=True) + interpreter = Path(sys._base_executable).resolve() + (store / "facts.json").write_text(json.dumps({"schema": 1, "packages": {"python": { + "version": "fixture", "entry": str(interpreter.parent if os.name == "nt" else interpreter.parents[1]) + }}}), encoding="utf-8") + return repo, home, interpreter + + +@pytest.mark.platforms("windows", "posix") +@pytest.mark.parametrize("form", ["native", "shell"]) +def test_source_launchers_boot_selected_generation_from_custom_home(tmp_path, monkeypatch, form): + repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) + out = tmp_path / "commands" + out.mkdir() + # No repo/venv or console script exists. PM selection lives outside the checkout. + launchers = [Path(p) for p in _launchers.ensure_install_launchers(repo, out)] + assert len(launchers) == len(_launchers.ENTRY_POINTS) + if form == "shell": + shell_out = tmp_path / "shell-commands" + shell_out.mkdir() + launchers = [ + _launchers._mint_shell_launcher(name, shell_out, interpreter, + _launchers._launcher_script(name, repo, None)) + for name in _launchers.ENTRY_POINTS + ] + args = ['spaces and café', 'apostrophe\'s', r'one\two', '$HOME; echo no', ''] + for number in (1, 2): + selected = install_state_dir(repo) / "environments" / str(number) / "venv" + site = site_packages(selected) + site.mkdir(parents=True) + (selected / "pyvenv.cfg").write_text("home = fixture\n", encoding="utf-8") + (site / "selected_probe.py").write_text(f"VALUE = {number}\n", encoding="utf-8") + (install_state_dir(repo) / "facts.json").write_text( + json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8") + env = dict(os.environ) + env.pop("HERMES_HOME", None) + env.pop("HERMES_RUNTIME_DIR", None) + env["PYTHONHOME"] = str(tmp_path / "foreign-python") + env["PYTHONPATH"] = str(tmp_path / "foreign-deps") + for launcher in launchers: + assert launcher is not None + command = ["bash", "-s"] if form == "shell" else [str(launcher), *args] + script = "exec " + shlex.join(["bash", str(launcher), *args]) + "\n" if form == "shell" else None + result = subprocess.run(command, input=script, cwd=tmp_path, env=env, + capture_output=True, text=True, encoding="utf-8", timeout=30) + assert result.returncode == 7, result.stdout + result.stderr + receipt = json.loads(result.stdout) + assert receipt["value"] == number + assert receipt["argv"] == args + assert Path(receipt["home"]) == home + assert Path(receipt["exe"]).samefile(interpreter) + assert not (repo / "venv").exists() + + +@pytest.mark.platforms("posix") +def test_posix_materializer_publishes_only_executable_shell_launchers(tmp_path, monkeypatch): + repo, _home, _interpreter = fixture_tree(tmp_path, monkeypatch) + out = tmp_path / "bin" + out.mkdir() + launchers = [Path(p) for p in _launchers.ensure_install_launchers(repo, out)] + assert {p.name for p in launchers} == set(_launchers.ENTRY_POINTS) + assert all(os.access(p, os.X_OK) for p in launchers) + assert set(out.iterdir()) == set(launchers) + + +def test_materializer_cli_refuses_missing_store_without_publishing(tmp_path, monkeypatch): + repo, home, _interpreter = fixture_tree(tmp_path, monkeypatch) + (home / "tools" / "facts.json").unlink() + out = tmp_path / "bin" + result = subprocess.run([sys.executable, "-I", str(repo / "hermes_cli/_launchers.py"), str(out)], + cwd=tmp_path, capture_output=True, text=True, encoding="utf-8", timeout=30) + assert result.returncode == 1, result.stdout + result.stderr + assert "store interpreter" in result.stderr + assert not out.exists() or not list(out.iterdir()) diff --git a/tests/test_install_ps1_managed_python_provenance.py b/tests/test_install_ps1_managed_python_provenance.py index 30533c7ad5..5f63d0b0a1 100644 --- a/tests/test_install_ps1_managed_python_provenance.py +++ b/tests/test_install_ps1_managed_python_provenance.py @@ -65,7 +65,7 @@ exit $LASTEXITCODE capture_output=True, text=True, timeout=120) assert (run.returncode == 0) == (exit_code == 0), run.stdout + run.stderr assert [json.loads(line) for line in log.read_text(encoding="utf-8-sig").splitlines()] == [ - ["python", "install", "--no-bin", "3.12"], ["python", "find", "--managed-python", "3.12"], + ["python", "install", "--no-bin", "3.12"], ["python", "find", "--managed-python", "--no-project", "3.12"], ] assert json.loads(pm_log.read_text(encoding="utf-8-sig")) == ["install"] assert existing.read_text(encoding="utf-8-sig") == "previous generation" diff --git a/tests/test_source_launcher_stages.py b/tests/test_source_launcher_stages.py new file mode 100644 index 0000000000..c88b2d3470 --- /dev/null +++ b/tests/test_source_launcher_stages.py @@ -0,0 +1,160 @@ +"""Bootstrap stages publish launchers through the shared writer after PM setup.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +from hermes_cli.runtime_paths import install_state_dir, site_packages +from pm.lock import Lockfile +from pm.store import current_target +from tests.hermes_cli.test_source_launcher_publication import fixture_tree + +ROOT = Path(__file__).resolve().parents[1] + + +def selected_environment(repo, value=11): + selected = install_state_dir(repo) / 'environments/ready/venv' + site = site_packages(selected) + site.mkdir(parents=True) + (selected / 'pyvenv.cfg').write_text('home = fixture\n', encoding='utf-8') + (site / 'selected_probe.py').write_text(f'VALUE = {value}\n', encoding='utf-8') + (install_state_dir(repo) / 'facts.json').write_text( + json.dumps({'packages': {'venv': {'environment': str(selected)}}}), encoding='utf-8') + + +@pytest.mark.platforms("windows", "posix") +def test_developer_setup_publishes_without_a_checkout_venv(tmp_path, monkeypatch): + repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) + shutil.copy2(ROOT / 'setup-hermes.sh', repo / 'setup-hermes.sh') + store = home / 'tools' + uv = Path(shutil.which('uv') or pytest.fail('canonical test environment needs uv')) + version = Lockfile(ROOT / 'pm/lock.json').version('uv') + entry = store / f'uv-{version}-{current_target()}' + entry.mkdir() + shutil.copy2(uv, entry / ('uv.exe' if os.name == 'nt' else 'uv')) + (repo / 'pm').mkdir() + (repo / 'pm/__init__.py').write_text('', encoding='utf-8') + # Stop at PM's install boundary; the launcher writer and boot selection stay real. + (repo / 'pm/cli.py').write_text('', encoding='utf-8') + lock = Lockfile(repo / 'pm/lock.json') + lock.set_pin('uv', version, {}) + lock.set_pin('python', '.'.join(map(str, sys.version_info[:2])), {}) + lock.save() + selected_environment(repo) + env = dict(os.environ, HOME=str(tmp_path / 'shell-home'), HERMES_HOME=str(home), + HERMES_RUNTIME_DIR=str(store), UV_OFFLINE='1', UV_PYTHON_DOWNLOADS='never') + env.pop('UV_PYTHON_INSTALL_DIR', None) + env.pop('PYTHONHOME', None) + env.pop('PYTHONPATH', None) + Path(env['HOME']).mkdir() + result = subprocess.run(['bash', str(repo / 'setup-hermes.sh')], cwd=tmp_path, env=env, + capture_output=True, timeout=90) + if result.returncode: + print(result.stdout.decode('utf-8', errors='replace')) + print(result.stderr.decode('utf-8', errors='backslashreplace')) + assert result.returncode == 0, result.stdout + result.stderr + out = home / 'bin' if os.name == 'nt' else Path(env['HOME']) / '.local/bin' + launchers = list(out.glob('hermes*')) if out.exists() else [] + assert launchers, result.stdout + result.stderr + command = out / ('hermes.exe' if (out / 'hermes.exe').is_file() else 'hermes.cmd' if os.name == 'nt' else 'hermes') + child_env = dict(env) + child_env.pop('HERMES_HOME') + child_env.pop('HERMES_RUNTIME_DIR') + child = subprocess.run([str(command), 'literal input'], cwd=tmp_path, env=child_env, + capture_output=True, text=True, encoding='utf-8', timeout=30) + assert child.returncode == 7, child.stdout + child.stderr + witness = json.loads(child.stdout) + assert witness['value'] == 11 and witness['argv'] == ['literal input'] + assert Path(witness['home']) == home and Path(witness['exe']).samefile(interpreter) + assert not (repo / 'venv').exists() + + +@pytest.mark.platforms("windows", "posix") +def test_shell_installer_path_stage_uses_shared_publication(tmp_path, monkeypatch): + repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) + (repo / 'pm').mkdir() + lock = Lockfile(repo / 'pm/lock.json') + lock.set_pin('python', '.'.join(map(str, sys.version_info[:2])), {}) + lock.save() + selected_environment(repo) + shell_home = tmp_path / 'shell-home' + shell_home.mkdir() + env = dict(os.environ, PROBE_SCRIPT=(ROOT / 'scripts/install.sh').as_posix(), + PROBE_REPO=repo.as_posix(), PROBE_HOME=home.as_posix(), + PROBE_SHELL_HOME=shell_home.as_posix(), UV_OFFLINE='1', UV_PYTHON_DOWNLOADS='never') + env.pop('UV_PYTHON_INSTALL_DIR', None) + script = '''source "$PROBE_SCRIPT" --manifest --dir "$PROBE_REPO" --hermes-home "$PROBE_HOME" +HOME="$PROBE_SHELL_HOME" +JSON=true +run_stage path +''' + # Source the stage to test its transport on Windows without pretending it is POSIX. + result = subprocess.run(['bash', '-c', script], cwd=tmp_path, env=env, + capture_output=True, timeout=90) + assert result.returncode == 0, result.stdout + result.stderr + frames = [json.loads(line) for line in result.stdout.splitlines() if line.startswith(b'{')] + assert frames == [{'ok': True, 'stage': 'path', 'skipped': False}] + out = shell_home / '.local/bin' + for name in ('hermes', 'hermes-acp'): + command = out / (name + '.exe' if (out / (name + '.exe')).is_file() + else name + '.cmd' if os.name == 'nt' else name) + child_env = dict(env) + child_env.pop('HERMES_HOME', None) + child = subprocess.run([str(command), 'from-stage'], cwd=tmp_path, env=child_env, + capture_output=True, text=True, encoding='utf-8', timeout=30) + assert child.returncode == 7, child.stdout + child.stderr + witness = json.loads(child.stdout) + assert witness['value'] == 11 and witness['argv'] == ['from-stage'] + assert Path(witness['home']) == home and Path(witness['exe']).samefile(interpreter) + assert not (repo / 'venv').exists() + + +@pytest.mark.platforms("windows") +def test_powershell_stage_publishes_without_a_checkout_venv(tmp_path, monkeypatch): + repo, home, interpreter = fixture_tree(tmp_path, monkeypatch) + (repo / 'pm').mkdir() + lock = Lockfile(repo / 'pm/lock.json') + lock.set_pin('python', '.'.join(map(str, sys.version_info[:2])), {}) + lock.save() + selected_environment(repo) + wrapper = tmp_path / 'stage.ps1' + wrapper.write_text('''$ErrorActionPreference = 'Stop' +. $env:PROBE_INSTALLER -InstallDir $env:PROBE_REPO -HermesHome $env:PROBE_HOME +Initialize-ResolvedPaths +# Replace only the registry publication edge, never mutate the actual user PATH. +function Set-LauncherUserPath([string]$binDir) { + if ($binDir -ne (Join-Path $env:PROBE_HOME 'bin')) { throw 'wrong user PATH target' } + $script:publishedPath = $binDir + Write-Output 'REACHED_PATH_PUBLICATION' +} +Stage-Path +if (-not $script:publishedPath) { throw 'registry-publication seam was bypassed' } +exit 0 +''', encoding='utf-8-sig') + powershell = Path(os.environ['SystemRoot']) / 'System32/WindowsPowerShell/v1.0/powershell.exe' + env = dict(os.environ, PROBE_INSTALLER=str(ROOT / 'scripts/install.ps1'), + PROBE_REPO=str(repo), PROBE_HOME=str(home), HERMES_HOME=str(tmp_path / 'other-home'), + UV_OFFLINE='1', UV_PYTHON_DOWNLOADS='never') + uv = Path(shutil.which('uv') or pytest.fail('canonical test environment needs uv')) + env['PATH'] = os.pathsep.join([str(uv.parent), str(powershell.parent), str(Path(os.environ['SystemRoot']) / 'System32')]) + env['PATHEXT'] = '.COM;.EXE;.BAT;.CMD' + env.pop('UV_PYTHON_INSTALL_DIR', None) + result = subprocess.run([str(powershell), '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', str(wrapper)], + cwd=tmp_path, env=env, capture_output=True, timeout=90) + assert result.returncode == 0, result.stdout + result.stderr + assert b'REACHED_PATH_PUBLICATION' in result.stdout + for name in ('hermes', 'hermes-acp'): + command = home / 'bin' / (name + ('.exe' if (home / 'bin' / (name + '.exe')).is_file() else '.cmd')) + child_env = dict(env) + child_env.pop('HERMES_HOME', None) + child = subprocess.run([str(command), 'from-powershell'], cwd=tmp_path, env=child_env, + capture_output=True, text=True, encoding='utf-8', timeout=30) + assert child.returncode == 7, child.stdout + child.stderr + witness = json.loads(child.stdout) + assert witness['value'] == 11 and witness['argv'] == ['from-powershell'] + assert Path(witness['home']) == home and Path(witness['exe']).samefile(interpreter) + assert not (repo / 'venv').exists()