feat(receipts): update receipts embed the pm sync sections + plugin docs
- finalize_update_receipt folds the newest pm sync receipt's venv_rebuild / plugin_bisect / feature_list into the update receipt (pm_-prefixed) so ONE latest.json carries the whole update story — the embedding contract both receipt plans declared. Additive, exception-swallowing (an embed failure never breaks the update receipt). - pm/receipt: _write_rotated mkdirs itself instead of depending on _receipt_dir()'s mkdir side effect (found via the embed tests — a patched dir lambda made writes FileNotFoundError under the OSError swallow). - website plugins.md: the three new verbs (check-updates, adopt, trust-update-url) + the provenance/cadence/auto_apply/trust flow, user-facing. tests: embed happy/absent/failure-isolated; existing receipt suites green.
This commit is contained in:
@@ -231,6 +231,23 @@ def finalize_update_receipt(
|
||||
receipt.data["stop_reason"] = stop_reason
|
||||
if fleet is not None:
|
||||
receipt.data["fleet"] = fleet
|
||||
# EMBED the pm sync sections (the settled receipts contract): the
|
||||
# update's rebuild/bisect ran through pm's own sync receipt, which
|
||||
# finalizes before this one. Fold the newest sync receipt's
|
||||
# venv_rebuild + plugin_bisect into the update receipt so ONE file
|
||||
# carries the whole story (desktop reads a single latest.json).
|
||||
try:
|
||||
from pm import receipt as pm_receipt
|
||||
|
||||
sync = pm_receipt.latest()
|
||||
if isinstance(sync, dict):
|
||||
for key in ("venv_rebuild", "plugin_bisect", "feature_list"):
|
||||
if sync.get(key) is not None:
|
||||
receipt.data[f"pm_{key}"] = sync[key]
|
||||
if sync.get("outcome") is not None:
|
||||
receipt.data["pm_sync_outcome"] = sync.get("outcome")
|
||||
except Exception as exc: # pragma: no cover — embedding is additive
|
||||
logger.debug("pm sync-section embed skipped: %s", exc)
|
||||
directory = _receipt_dir()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
stamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
@@ -133,6 +133,9 @@ def latest() -> Optional[dict[str, Any]]:
|
||||
|
||||
def _write_rotated(data: dict[str, Any]) -> Path:
|
||||
d = _receipt_dir()
|
||||
# _write_rotated must not depend on _receipt_dir()'s mkdir side
|
||||
# effect (a patched/injected dir lambda breaks it) — self-sufficient.
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
|
||||
kind = data.get("kind") or "sync"
|
||||
path = d / f"{stamp}-{kind}.json"
|
||||
|
||||
77
tests/hermes_cli/test_update_receipt_pm_embed.py
Normal file
77
tests/hermes_cli/test_update_receipt_pm_embed.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Tests: update receipts embed the pm sync sections (the settled contract).
|
||||
|
||||
finalize_update_receipt folds the newest pm sync receipt's
|
||||
venv_rebuild / plugin_bisect / feature_list into the update receipt
|
||||
(pm_*-prefixed), so one latest.json carries the whole story.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.update_receipt as ur
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def homed(tmp_path, monkeypatch):
|
||||
import hermes_constants
|
||||
|
||||
monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: tmp_path)
|
||||
import pm.receipt as pm_receipt_mod
|
||||
|
||||
monkeypatch.setattr(pm_receipt_mod, "_receipt_dir", lambda: tmp_path / "logs" / "update_receipts")
|
||||
monkeypatch.setattr(ur, "_receipt_dir", lambda: tmp_path / "logs" / "update_receipts")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _seed_sync_receipt(homed, **sections):
|
||||
import pm.receipt as pm_receipt_mod
|
||||
|
||||
pm_receipt_mod.begin("sync")
|
||||
if "venv_rebuild" in sections:
|
||||
pm_receipt_mod.record_venv_rebuild(**sections.pop("venv_rebuild"))
|
||||
if "bisect" in sections:
|
||||
pm_receipt_mod.record_bisect(sections.pop("bisect"))
|
||||
pm_receipt_mod.finalize("ok")
|
||||
|
||||
|
||||
def test_update_receipt_embeds_pm_sections(homed):
|
||||
_seed_sync_receipt(
|
||||
homed,
|
||||
venv_rebuild={"ok": True, "reason": ""},
|
||||
bisect=[{"plugin": "bad", "action": "disabled", "reason": "conflict"}],
|
||||
)
|
||||
ur.begin_update_receipt()
|
||||
ur.record_step("git-pull", True)
|
||||
path = ur.finalize_update_receipt("success")
|
||||
|
||||
data = json.loads((homed / "logs" / "update_receipts" / "latest.json").read_text(encoding="utf-8"))
|
||||
assert data["outcome"] == "success"
|
||||
assert data["pm_venv_rebuild"] == {"ok": True, "reason": ""}
|
||||
assert data["pm_plugin_bisect"][0]["plugin"] == "bad"
|
||||
assert data["pm_sync_outcome"] == "ok"
|
||||
|
||||
|
||||
def test_update_receipt_without_sync_embeds_nothing(homed):
|
||||
ur.begin_update_receipt()
|
||||
ur.finalize_update_receipt("success")
|
||||
data = json.loads((homed / "logs" / "update_receipts" / "latest.json").read_text(encoding="utf-8"))
|
||||
assert "pm_venv_rebuild" not in data
|
||||
assert data["outcome"] == "success"
|
||||
|
||||
|
||||
def test_embed_failure_never_breaks_the_update_receipt(homed, monkeypatch):
|
||||
def boom():
|
||||
raise RuntimeError("pm import exploded")
|
||||
|
||||
import pm.receipt as pm_receipt_mod
|
||||
|
||||
monkeypatch.setattr(pm_receipt_mod, "latest", boom)
|
||||
ur.begin_update_receipt()
|
||||
path = ur.finalize_update_receipt("success")
|
||||
assert path is not None # receipt written despite the embed failure
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert data["outcome"] == "success"
|
||||
@@ -350,8 +350,37 @@ hermes plugins remove my-plugin # uninstall
|
||||
hermes plugins enable my-plugin # add to allow-list
|
||||
hermes plugins disable my-plugin # remove from allow-list + add to disabled
|
||||
hermes plugins capabilities [my-plugin] # declared vs granted capabilities
|
||||
hermes plugins check-updates # read-only: is any installed plugin outdated?
|
||||
hermes plugins adopt my-plugin # track a self-cloned plugin dir (read its git origin)
|
||||
hermes plugins trust-update-url my-plugin # confirm a changed update_url after review
|
||||
```
|
||||
|
||||
### Update checks and provenance
|
||||
|
||||
Hermes records where every `hermes plugins install` came from (the git
|
||||
source and exact revision, in `.install-metadata.json`), and
|
||||
`hermes plugins check-updates` uses that provenance to answer "is it
|
||||
outdated?" without touching your working tree: git-installed plugins are
|
||||
compared against their remote's HEAD via `git ls-remote`, and plugins
|
||||
whose manifest declares an `update_url` (a small update-feed file) are
|
||||
checked against that feed — the standard way for non-github-hosted
|
||||
plugins to be update-checkable. Plugins you cloned yourself (no install
|
||||
record) are offered `hermes plugins adopt` to become tracked installs.
|
||||
|
||||
The check is read-only; applying updates stays explicit via
|
||||
`hermes plugins update`, which re-runs the security scan on the pulled
|
||||
code. A background check runs at most once a day (config key
|
||||
`plugins.auto_update_check_hours`, default 24, `0` disables) and writes
|
||||
its results where the desktop and `hermes pm status` read them; set
|
||||
`plugins.auto_apply: true` to also apply git-plugin updates
|
||||
unattended — the security scan still gates every apply.
|
||||
|
||||
If a plugin's manifest changes its `update_url` after install (visible
|
||||
as a *needs fixing* warning in check-updates and `hermes doctor`),
|
||||
Hermes refuses to fetch from the new address until you confirm it with
|
||||
`hermes plugins trust-update-url` — an update can never silently
|
||||
redirect where its code comes from.
|
||||
|
||||
### One-click install links (Desktop)
|
||||
|
||||
Hermes Desktop registers the `hermes://` URL scheme, so a website, README, or
|
||||
|
||||
Reference in New Issue
Block a user