Merge ethie/cli-bundles at 0765ad689b.
Keep PM runtime publication, install identity, TLS policy, and module
boundaries from pm-clean.
Resolve the Node version-discovery method in its owning class. Preserve
staged tools if a repin download or publication fails. Carry extra-only
memory-provider setup through PM and retain restart-required reporting.
Keep target-specific TUI path assertions and discard obsolete self-lock
fixtures and the orphaned Windows service handler.
Verified locally with the canonical Python runner, root JS checks, TUI
checks/build, shell parsing, and workflow YAML parsing. Existing platform
pins and executable modes are unchanged. No full-suite CI, new bionic
bundle, or phone acceptance is claimed for this merge.
224 lines
8.3 KiB
Python
224 lines
8.3 KiB
Python
"""The lockfile (versions + hashes, machine-written) and the installed-state
|
|
file (what is actually on this machine).
|
|
|
|
lock.json: {"schema": 1, "packages": {name: {"version": ..., "artifacts": {target: {"url": ..., "sha256": ...}}}}}
|
|
facts.json: {"schema": 1, "packages": {name: {"entry": ..., "version": ..., "env": ..., "stamp": ...}}}
|
|
|
|
A target's value is one {"url", "sha256"} object, or a LIST of them when the
|
|
package is split across several archives that must land in one directory
|
|
(llama.cpp's engine zip and its cudart zip: Windows resolves a DLL from the
|
|
loading executable's own directory). Both shapes read back as a list.
|
|
|
|
lock.json artifacts carry the RESOLVED url beside the hash: the lockfile is
|
|
the complete machine interface (nix reads it as pure data), and the python
|
|
url templates are consulted only at `pm lock --bump` time. The "any" target
|
|
key covers target-independent artifacts (npm's tarball).
|
|
|
|
facts.json env values hold {{store}} templates so a CI-built file adopts onto
|
|
any machine by substitution.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
SCHEMA = 1
|
|
STORE_TOKEN = "{{store}}"
|
|
|
|
|
|
def _read(path: Path) -> dict:
|
|
try:
|
|
text = path.read_text(encoding="utf-8-sig")
|
|
except OSError:
|
|
return {"schema": SCHEMA, "packages": {}}
|
|
try:
|
|
data = json.loads(text)
|
|
if isinstance(data, dict) and data.get("schema") == SCHEMA and isinstance(data.get("packages"), dict):
|
|
return data
|
|
except ValueError:
|
|
pass
|
|
if text.strip():
|
|
# An unparsable-but-nonempty state file is evidence, not garbage:
|
|
# the next _write would silently discard every installed-state
|
|
# record. Keep the bytes for post-mortem.
|
|
try:
|
|
from pm.paths import store_root
|
|
if path.parent == store_root() and (store_root().parent / "manifest.json").is_file():
|
|
import logging
|
|
logging.getLogger(__name__).warning("invalid shipped state file: %s", path)
|
|
else:
|
|
path.with_suffix(".corrupt").write_text(text, encoding="utf-8")
|
|
except OSError:
|
|
pass
|
|
return {"schema": SCHEMA, "packages": {}}
|
|
|
|
|
|
def _write(path: Path, data: dict) -> None:
|
|
from hermes_cli.runtime_state import _atomic_bytes
|
|
_atomic_bytes(path, (json.dumps(data, indent=2, sort_keys=True) + "\n").encode("utf-8"))
|
|
|
|
class Lockfile:
|
|
"""Read side of lock.json. Written only by `pm lock --bump` (cli)."""
|
|
|
|
def __init__(self, path: Path):
|
|
self.path = path
|
|
self._packages = _read(path)["packages"]
|
|
|
|
def version(self, name: str) -> str | None:
|
|
return (self._packages.get(name) or {}).get("version")
|
|
|
|
def artifacts(self, name: str, target: str) -> list[dict]:
|
|
"""Every archive this target needs, in extraction order. One
|
|
artifact and a list of them are the same thing here — the single
|
|
form is just the common case written short."""
|
|
artifacts = (self._packages.get(name) or {}).get("artifacts") or {}
|
|
found = artifacts.get(target)
|
|
if found is None:
|
|
found = artifacts.get("any")
|
|
if found is None:
|
|
return []
|
|
return list(found) if isinstance(found, list) else [found]
|
|
|
|
def names(self) -> list[str]:
|
|
return sorted(self._packages)
|
|
|
|
def set_pin(self, name: str, version: str, artifacts: dict[str, dict]) -> None:
|
|
self._packages[name] = {"version": version, "artifacts": artifacts}
|
|
|
|
def save(self) -> None:
|
|
_write(self.path, {"schema": SCHEMA, "packages": self._packages})
|
|
|
|
|
|
def termux_docker_digest() -> str:
|
|
"""The pinned termux-docker image digest (pm package `termux-docker`).
|
|
|
|
Single reader for every consumer -- the build/deb scripts, the builder
|
|
image script, and the release workflow -- so a lock-schema change lands
|
|
once.
|
|
"""
|
|
from pm.paths import lockfile_path
|
|
|
|
return Lockfile(lockfile_path()).version("termux-docker")
|
|
|
|
|
|
class Facts:
|
|
"""The installed-state file. Written only by pm."""
|
|
|
|
def __init__(self, path: Path):
|
|
self.path = path
|
|
self._packages = _read(path)["packages"]
|
|
|
|
def reload(self) -> None:
|
|
self._packages = _read(self.path)["packages"]
|
|
|
|
def get(self, name: str) -> dict | None:
|
|
return self._packages.get(name)
|
|
|
|
def installed(
|
|
self,
|
|
name: str,
|
|
expected_version: str | None,
|
|
store_root: Path,
|
|
expected_identity=None,
|
|
) -> bool:
|
|
"""``expected_identity`` is (target, tuple(artifact sha256s)) — the
|
|
identity the lock currently pins. When given, the fact must record
|
|
that exact identity: a same-version re-pin, a different target, or
|
|
a fact written before identity existed all count as NOT installed
|
|
and force a reinstall. ``None`` keeps the version+path check."""
|
|
fact = self._packages.get(name)
|
|
if not fact:
|
|
return False
|
|
if expected_version is not None and fact.get("version") != expected_version:
|
|
return False
|
|
if expected_identity is not None:
|
|
target, shas = expected_identity
|
|
if "target" not in fact or "artifacts" not in fact:
|
|
# Legacy fact: pre-dates digest-bound identity. Not
|
|
# vouchable — force one reinstall.
|
|
return False
|
|
if (fact["target"], tuple(fact["artifacts"])) != (target, tuple(shas)):
|
|
return False
|
|
return (store_root / fact["entry"]).exists()
|
|
|
|
def env_for(self, name: str, store_root: Path) -> dict:
|
|
fact = self._packages.get(name) or {}
|
|
return _resolve(fact.get("env", {}), store_root)
|
|
|
|
def _merge_and_write(self, name: str, fact: dict) -> None:
|
|
"""Read-modify-write against disk so concurrent installs of
|
|
different packages never clobber each other."""
|
|
on_disk = _read(self.path)["packages"]
|
|
for key, value in self._packages.items():
|
|
on_disk.setdefault(key, value)
|
|
self._packages = on_disk
|
|
self._packages[name] = fact
|
|
_write(self.path, {"schema": SCHEMA, "packages": self._packages})
|
|
|
|
def record(
|
|
self,
|
|
name: str,
|
|
version: str,
|
|
entry: str,
|
|
env: dict,
|
|
store_root: Path,
|
|
target: str | None = None,
|
|
artifacts: list[str] | None = None,
|
|
digest: str | None = None,
|
|
) -> None:
|
|
"""``target``/``artifacts`` record the identity this install came
|
|
from (work item 1); ``digest`` is the realized tree digest of the
|
|
published entry (work item 2). All three are additive — schema
|
|
stays 1 and older facts without them still read back."""
|
|
fact: dict = {
|
|
"entry": entry,
|
|
"version": version,
|
|
"env": _templatize(env, store_root),
|
|
}
|
|
if target is not None:
|
|
fact["target"] = target
|
|
if artifacts is not None:
|
|
fact["artifacts"] = list(artifacts)
|
|
if digest is not None:
|
|
fact["digest"] = digest
|
|
self._merge_and_write(name, fact)
|
|
|
|
def record_state(
|
|
self, name: str, stamp: str, extras: list[str], *,
|
|
environment: Path | None = None, resolved_lock: Path | None = None,
|
|
) -> None:
|
|
"""Commit a verified state package and its selected environment atomically."""
|
|
fact = {"stamp": stamp, "extras": extras}
|
|
if environment is not None:
|
|
fact["environment"] = str(environment.resolve())
|
|
if resolved_lock is not None:
|
|
fact["resolved_lock"] = str(resolved_lock.resolve())
|
|
self._merge_and_write(name, fact)
|
|
|
|
def entries_in_use(self) -> set[str]:
|
|
return {f["entry"] for f in self._packages.values() if "entry" in f}
|
|
|
|
|
|
def _templatize(env: dict, store_root: Path) -> dict:
|
|
root = str(store_root)
|
|
out = {}
|
|
for key, value in env.items():
|
|
if isinstance(value, list):
|
|
out[key] = [str(v).replace(root, STORE_TOKEN) for v in value]
|
|
else:
|
|
out[key] = str(value).replace(root, STORE_TOKEN)
|
|
return out
|
|
|
|
|
|
def _resolve(env: dict, store_root: Path) -> dict:
|
|
root = str(store_root)
|
|
out = {}
|
|
for key, value in env.items():
|
|
if isinstance(value, list):
|
|
out[key] = [str(v).replace(STORE_TOKEN, root) for v in value]
|
|
else:
|
|
out[key] = str(value).replace(STORE_TOKEN, root)
|
|
return out
|