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()