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.
This commit is contained in:
ethernet
2026-09-10 01:58:34 -04:00
parent d0de305077
commit e49073ecf3
8 changed files with 403 additions and 159 deletions

View File

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

View File

@@ -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" <<WRAPPER
#!/usr/bin/env bash
# Hermes boot wrapper: store python + PYTHONPATH (never the venv python).
set -u
repo="\${HERMES_INSTALL_DIR:-$INSTALL_DIR}"
unset PYTHONHOME
runtime="\${HERMES_RUNTIME_DIR:-}"
if [ -z "\$runtime" ] && [ -f "\$repo/install-stamp.json" ]; then
runtime="\$(sed -n 's/.*"runtimeDir"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "\$repo/install-stamp.json" | head -n 1)"
fi
[ -n "\$runtime" ] || runtime="\$HOME/.hermes/tools"
store_py=""
if [ -f "\$runtime/facts.json" ]; then
entry="\$(sed -n 's/.*"entry"[[:space:]]*:[[:space:]]*"\(python-[^"]*\)".*/\1/p' "\$runtime/facts.json" | head -n 1)"
if [ -n "\$entry" ] && [ -x "\$runtime/\$entry/bin/python3" ]; then
store_py="\$runtime/\$entry/bin/python3"
fi
fi
if [ -z "\$store_py" ]; then
for d in "\$runtime"/python-*/bin/python3; do
[ -x "\$d" ] && store_py="\$d"
done
fi
if [ -z "\$store_py" ]; then
echo "hermes: no pm store interpreter under \$runtime - run 'hermes pm install' first" >&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" ;;