Files
hermes-agent/pm/shell.py
ethernet 3d12e86ef1 feat(pm): unified package manager — pm store foundation
Introduce the pm store: a unified, hash-verified package store that
replaces lazy_deps and the old installer's ad-hoc tool downloads.
Store tools are provisioned on PATH (ffmpeg, node/npm via pinned uv),
with a resumable 8-way downloader, verify() returning failure reasons,
and adopt() made EPERM-safe. chromium ships in the payload for every
target. The 3600-line install.sh is replaced by a staged bootstrapper
(heavy deps are pm's job after this); setup-hermes.sh, Dockerfile and
nix pin tables are rewired onto the store. Old install-script tests,
lazy_deps/managed_uv/build_info, and the ps1/bash installer test
batteries are removed with the machinery they tested.

Rebuilt from ethie/pm onto upstream/main (ac6c8028e0) after the
utf-8-sig sweep. 16 hot files (main also churned them) hand-merged:
platform adapters, main.py, electron/main.ts, tui_gateway/server.py,
cua_backend, installer-tests workflow, install.sh (full rewrite),
setup-hermes.sh, plugins doc.
2026-08-31 18:00:48 -04:00

85 lines
2.9 KiB
Python

"""pm.shell(): the one place Hermes resolves the shell it runs commands with.
Owned by pm because the shell is a bundled tool on Windows (Git for Windows
carries bash.exe), and the store is the authority on whether it exists.
Callers that need bash (the terminal backend, `_find_bash`) call this instead
of hunting fixed locations.
Resolution order:
1. Windows: the git Package's staged bash (via facts.json) — the store
structurally guarantees it in a bundle; no hunt.
2. Provisioned PATH: shutil.which("bash") — the store dirs are on the
process PATH after pm.activate() ran.
3. POSIX fallback table for non-bundle / daemon-launch PATH edge cases
(/usr/bin/bash, /bin/bash, $SHELL, /bin/sh). A systemd/cron-launched
gateway may have a minimal PATH and macOS /bin/bash is not on PATH by
default, so `which` alone is not enough there.
"""
from __future__ import annotations
import os
import shutil
from pm import paths
from pm.lock import Facts
def _staged_bash() -> str | None:
"""The git Package's bash.exe under the store (Windows bundles only)."""
import platform
if platform.system() != "Windows":
return None
facts_path = paths.facts_path()
if not facts_path.is_file():
return None
try:
facts = Facts(facts_path)
fact = facts.get("git")
if fact is None or "entry" not in fact:
return None
entry = paths.store_root() / fact["entry"]
for candidate in (
entry / "usr" / "bin" / "bash.exe",
entry / "bin" / "bash.exe",
):
if candidate.is_file():
return str(candidate)
except Exception:
return None
return None
def bash() -> str | None:
"""Resolve the bash binary to use, or None if none is available."""
staged = _staged_bash()
if staged:
return staged
on_path = shutil.which("bash")
# An MSIX-packaged bash (WindowsApps alias) only spawns inside its
# package's context — from an arbitrary process (dev checkout, test
# child, emulated-python CreateProcess) it fails with WinError 5.
# Prefer a conventional install when one exists.
if on_path and "windowsapps" not in on_path.lower():
return on_path
if on_path and os.name == "nt":
programfiles = os.environ.get("ProgramFiles", r"C:\Program Files")
for rel in (("Git", "bin", "bash.exe"), ("Git", "usr", "bin", "bash.exe")):
cand = os.path.join(programfiles, *rel)
if os.path.isfile(cand):
return cand
return on_path # nothing conventional — the packaged one is all there is
# POSIX fallbacks for minimal-PATH daemon launches / macOS /bin/bash.
for candidate in (
"/usr/bin/bash",
"/bin/bash",
os.environ.get("SHELL"),
"/bin/sh",
):
if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None