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.
This commit is contained in:
ethernet
2026-08-29 21:23:27 -04:00
parent 6e294f543d
commit 3d12e86ef1
272 changed files with 18065 additions and 14721 deletions

344
pm/store.py Normal file
View File

@@ -0,0 +1,344 @@
"""Machine-wide byte store: download, verify, extract, publish atomically."""
from __future__ import annotations
import os
import platform
import shutil
import stat
import sys
import tempfile
import time
from contextlib import contextmanager
from pathlib import Path
_UA = {"User-Agent": "hermes-pm"}
ALL_TARGETS = (
"win32-x64",
"win32-arm64",
"linux-x64",
"linux-arm64",
"darwin-x64",
"darwin-arm64",
)
def _native_machine() -> str:
"""The MACHINE's architecture, not the interpreter's. An x64 python on
Windows-on-ARM reports AMD64 — staging a payload for the wrong target.
IsWow64Process2 reports the real machine regardless of emulation."""
if sys.platform.startswith("win"):
try:
import ctypes
from ctypes import wintypes
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.GetCurrentProcess.restype = wintypes.HANDLE
k32.IsWow64Process2.argtypes = [
wintypes.HANDLE,
ctypes.POINTER(ctypes.c_ushort),
ctypes.POINTER(ctypes.c_ushort),
]
k32.IsWow64Process2.restype = wintypes.BOOL
process_machine = ctypes.c_ushort()
native_machine = ctypes.c_ushort()
if k32.IsWow64Process2(
k32.GetCurrentProcess(),
ctypes.byref(process_machine),
ctypes.byref(native_machine),
):
if native_machine.value == 0xAA64:
return "arm64"
if native_machine.value == 0x8664:
return "x86_64"
except Exception:
pass
# Pre-IsWow64Process2 hosts: WOW64 exposes the real machine here.
wow = os.environ.get("PROCESSOR_ARCHITEW6432", "")
if wow.upper() == "ARM64":
return "arm64"
if wow.upper() == "AMD64":
return "x86_64"
return platform.machine().lower()
def current_target() -> str:
machine = _native_machine()
if machine in ("arm64", "aarch64"):
arch = "arm64"
elif machine in ("x86_64", "amd64", "x64"):
arch = "x64"
else:
raise RuntimeError(f"unsupported architecture: {platform.machine()}")
if sys.platform.startswith("win"):
return f"win32-{arch}"
if sys.platform == "darwin":
return f"darwin-{arch}"
return f"linux-{arch}"
def sha256_file(path: Path) -> str:
import hashlib
digest = hashlib.sha256()
with open(path, "rb") as f:
for block in iter(lambda: f.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def hash_url(url: str) -> str:
"""sha256 of a url's content, streamed. `pm lock` uses this to pin."""
import hashlib
import urllib.request
from pm.downloader import _OPENER
digest = hashlib.sha256()
with _OPENER.open(
urllib.request.Request(url, headers=_UA), timeout=600
) as resp:
for block in iter(lambda: resp.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def download(url: str, dest: Path, sha256: str, progress=None) -> Path:
"""Fetch url into dest dir, hash-verified, via the resumable downloader.
The digest is proven before the caller ever sees the file.
``progress(done, total)`` ticks per chunk — a several-hundred-MB engine
archive on a slow line must never look hung. Partial state lives in the
store's managed partials area (outside scratch, keyed by sha256(url)), so
an interrupted = failed fetch resumes on the next call instead of
re-fetching the whole archive. Non-https/non-loopback urls are refused by
Download itself (ValueError)."""
from pm.downloader import Download, Source
dest.mkdir(parents=True, exist_ok=True)
archive = dest / url.rsplit("/", 1)[-1]
p = (lambda d, t, r: progress(d, t)) if progress is not None else None
Download([Source(url, archive, sha256)]).run(progress=p)
return archive
def extract(archive: Path, dest: Path) -> None:
import tarfile
shutil.rmtree(dest, ignore_errors=True)
dest.mkdir(parents=True, exist_ok=True)
name = archive.name.lower()
if name.endswith((".tar.gz", ".tgz", ".tar.xz", ".txz", ".tar.bz2")):
with tarfile.open(archive) as tf:
tf.extractall(dest, filter="data")
elif name.endswith(".zip"):
_extract_zip(archive, dest)
else:
raise ValueError(f"unsupported archive: {archive.name}")
def _extract_zip(archive: Path, dest: Path) -> None:
import zipfile
with zipfile.ZipFile(archive) as zf:
symlinks: list[tuple] = []
for info in zf.infolist():
mode = info.external_attr >> 16
if stat.S_ISLNK(mode):
symlinks.append((info, zf.read(info).decode("utf-8")))
continue
written = Path(zf.extract(info, dest))
if mode & 0o111 and written.is_file():
written.chmod(mode & 0o777)
for info, target in symlinks:
_zip_symlink(info.filename, target, dest)
def _zip_symlink(member: str, target: str, dest: Path) -> None:
root = dest.resolve()
link = (root / member).resolve()
if not link.is_relative_to(root):
return
if Path(target).is_absolute():
return
resolved = (link.parent / target).resolve()
if not resolved.is_relative_to(root):
return
link.parent.mkdir(parents=True, exist_ok=True)
try:
link.symlink_to(target)
except OSError:
link.write_text(target, encoding="utf-8")
def flatten_single_dir(dest: Path) -> None:
"""Hoist a lone top-level dir's contents unless it IS the layout
(bin/, cmd/, lib/...). Refuses on name collisions."""
keep = {"bin", "cmd", "lib", "libexec", "share", "etc", "usr"}
entries = list(dest.iterdir())
if len(entries) != 1 or not entries[0].is_dir() or entries[0].name in keep:
return
inner = entries[0]
for item in list(inner.iterdir()):
target = dest / item.name
if target.exists():
return
item.rename(target)
inner.rmdir()
def merge_tree(src: Path, dst: Path) -> None:
"""Move src's tree into dst, keeping both layouts. A file present in
both is unresolvable — two archives disagreeing about one file cannot
be settled by extraction order, so it fails loudly instead."""
for item in sorted(src.rglob("*")):
if item.is_dir():
continue
rel = item.relative_to(src)
target = dst / rel
if target.exists():
raise FileExistsError(f"archives disagree about {rel}")
target.parent.mkdir(parents=True, exist_ok=True)
item.replace(target)
def tree_digest(root: Path) -> str:
"""Deterministic sha256 over a directory tree: walk every file, sort
by posix relpath, hash `relpath\\0<content>` per entry. No mtimes, no
mode bits. Symlinks contribute their LINK TARGET TEXT (os.readlink),
not the target's bytes — the link is the data. Directory symlinks are
not followed."""
import hashlib
files: list[tuple[str, Path]] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames.sort()
for fname in filenames:
path = Path(dirpath) / fname
files.append((path.relative_to(root).as_posix(), path))
files.sort(key=lambda item: item[0])
digest = hashlib.sha256()
for rel, path in files:
digest.update(rel.encode("utf-8"))
digest.update(b"\0")
if path.is_symlink():
digest.update(os.readlink(path).encode("utf-8"))
else:
with open(path, "rb") as f:
for block in iter(lambda: f.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
class Store:
"""One directory of immutable published entries plus a scratch area.
Downloads are entries too, keyed by hash, so rebuilds never re-fetch."""
def __init__(self, root: Path):
self.root = root
def entry(self, name: str) -> Path:
return self.root / name
def published(self, name: str) -> bool:
return self.entry(name).is_dir()
def fetch(self, url: str, sha256: str, scratch: Path, progress=None) -> Path:
"""Verified archive for url, from the store if already fetched.
The cache entry is `fetch-<full sha256>/` holding the single file.
The cached file is RE-HASHED against the requested digest before
it is returned: the cache lives on a mutable disk, so trust is
re-proven, not assumed. On mismatch the entry is deleted and the
archive re-downloaded (the 100MB+ re-hash is install-time only)."""
entry_name = f"fetch-{sha256}"
entry = self.entry(entry_name)
if entry.is_dir():
files = [p for p in entry.iterdir() if p.is_file()]
if len(files) == 1 and sha256_file(files[0]) == sha256:
return files[0]
shutil.rmtree(entry, ignore_errors=True)
archive = download(url, scratch, sha256, progress=progress)
staged = scratch / entry_name
staged.mkdir(parents=True)
archive.rename(staged / archive.name)
published = self.publish(staged, entry_name)
return published / archive.name
@contextmanager
def scratch(self):
self.root.mkdir(parents=True, exist_ok=True)
path = Path(tempfile.mkdtemp(prefix=".staging-", dir=self.root))
try:
yield path
finally:
shutil.rmtree(path, ignore_errors=True)
def publish(self, staged: Path, name: str) -> Path:
"""Atomic rename into place, retried for Windows file-lock holds
(Defender, indexers). A concurrent winner's entry is kept."""
target = self.entry(name)
delay = 0.5
for _ in range(5):
try:
os.replace(staged, target)
return target
except OSError:
if target.is_dir():
return target
time.sleep(delay)
delay *= 2
try:
os.replace(staged, target)
except OSError:
if not target.is_dir():
raise
return target
@contextmanager
def install_lock(self):
"""Single-flight advisory lock for all installs on this machine.
Blocks until acquired: installs legitimately hold it for minutes
(browser downloads), so the Windows rung polls LK_NBLCK instead of
msvcrt's 10-second LK_LOCK ladder."""
self.root.mkdir(parents=True, exist_ok=True)
lock_path = self.root / ".install.lock"
handle = open(lock_path, "a+b")
try:
if sys.platform.startswith("win"):
import msvcrt
handle.seek(0)
waited = 0
while True:
try:
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
break
except OSError:
if waited == 5:
print("waiting for another hermes install to finish...")
time.sleep(1)
waited += 1
else:
import fcntl
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
print("waiting for another hermes install to finish...")
fcntl.flock(handle, fcntl.LOCK_EX)
yield
finally:
try:
if sys.platform.startswith("win"):
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle, fcntl.LOCK_UN)
finally:
handle.close()