Keep upstream's reviewed catalog as the only plugin name index. Catalog pins and custom update sources share staged PM validation. Publish code and dependencies with recovery after process death. Reject a concurrent enablement change before publishing disabled code. Use the manifest loader's supported version in the installer. Keep probe cooldowns for timeouts, not TLS failures that a CA change fixes. Preserve the backup, uninstall, browser and memory-provider repairs. Verified with the canonical runner on native Windows ARM64, real Git repositories, local TLS endpoints and UV dependency generations. Desktop catalog tests and both TypeScript checks pass. The full suite and native release builds were not run. No remote push.
205 lines
11 KiB
Python
205 lines
11 KiB
Python
"""Publish plugin code and its dependency selection through one recoverable handoff."""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import uuid
|
|
|
|
|
|
def recover_plugin_publication(project: Path, row: dict, journal: Path) -> None:
|
|
from hermes_cli.fs_utils import rmtree_force
|
|
from hermes_cli.runtime_paths import dependency_home_root, runtime_facts_path
|
|
from hermes_cli.runtime_state import _atomic_bytes, _bytes, _digest
|
|
|
|
target, backup, metadata = (Path(row[key]) for key in ("target", "backup", "metadata"))
|
|
home = dependency_home_root().resolve()
|
|
if (not target.resolve().is_relative_to(home) or target.parent.name != "plugins"
|
|
or backup.parent != target.parent or not backup.name.startswith(".previous-")
|
|
or metadata != target.parent / ".install-metadata.json"):
|
|
raise ValueError("plugin publication paths escape their home")
|
|
committed = row.get("committed") or _digest(runtime_facts_path(project)) != row["facts_before"]
|
|
if committed:
|
|
if backup.exists():
|
|
rmtree_force(backup)
|
|
else:
|
|
old = base64.b64decode(row["metadata_before"], validate=True) if row["metadata_before"] is not None else None
|
|
current = _bytes(metadata)
|
|
new = base64.b64decode(row["metadata_after"], validate=True)
|
|
if current not in (old, new):
|
|
raise ValueError("plugin metadata changed after publication; preserve it for manual recovery")
|
|
if backup.exists():
|
|
if target.exists():
|
|
rmtree_force(target)
|
|
os.replace(backup, target)
|
|
elif not row["target_existed"] and target.exists():
|
|
rmtree_force(target)
|
|
if old is None:
|
|
metadata.unlink(missing_ok=True)
|
|
else:
|
|
_atomic_bytes(metadata, old)
|
|
journal.unlink()
|
|
|
|
|
|
class PluginPublication:
|
|
def __init__(self, project: Path, staged: Path, target: Path, metadata: dict):
|
|
from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path
|
|
from hermes_cli.runtime_state import _atomic_bytes, _bytes, _digest
|
|
|
|
self.project = project
|
|
self.journal = install_state_dir(project) / "publication.json"
|
|
backup = target.parent / f".previous-{uuid.uuid4().hex}"
|
|
metadata_path = target.parent / ".install-metadata.json"
|
|
previous = _bytes(metadata_path)
|
|
proposed = (json.dumps(metadata, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
self.row = {
|
|
"kind": "plugin", "target": str(target), "backup": str(backup), "metadata": str(metadata_path),
|
|
"target_existed": target.exists(), "facts_before": _digest(runtime_facts_path(project)),
|
|
"metadata_before": base64.b64encode(previous).decode() if previous is not None else None,
|
|
"metadata_after": base64.b64encode(proposed).decode(),
|
|
}
|
|
_atomic_bytes(self.journal, json.dumps(self.row).encode())
|
|
try:
|
|
if target.exists():
|
|
os.replace(target, backup)
|
|
os.replace(staged, target)
|
|
_atomic_bytes(metadata_path, proposed)
|
|
except BaseException:
|
|
self()
|
|
raise
|
|
|
|
def __call__(self) -> None:
|
|
recover_plugin_publication(self.project, self.row, self.journal)
|
|
|
|
def finish(self) -> None:
|
|
from hermes_cli.runtime_state import _atomic_bytes
|
|
|
|
# A code-only update has no new environment fact to mark its commit.
|
|
self.row["committed"] = True
|
|
_atomic_bytes(self.journal, json.dumps(self.row).encode())
|
|
recover_plugin_publication(self.project, self.row, self.journal)
|
|
|
|
|
|
def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: dict) -> None:
|
|
from hermes_cli import plugins_cmd
|
|
from hermes_cli.runtime_state import recover_publication, runtime_lock
|
|
from pm import paths
|
|
from pm.ensure import sync_venv
|
|
from pm.workspace import _is_member_candidate, enabled_plugin_dirs, member_sources
|
|
|
|
project = paths.repo_root()
|
|
|
|
def candidate_members():
|
|
if plugins_cmd._read_install_metadata() != old_metadata:
|
|
raise plugins_cmd.PluginOperationError("Plugin install metadata changed while preparing the update; retry.")
|
|
sources = member_sources(enabled_plugin_dirs())
|
|
active = target.resolve() in sources
|
|
if active:
|
|
sources[target.resolve()] = staged
|
|
for source in sources.values():
|
|
plugins_cmd._check_manifest_version(plugins_cmd._read_manifest_for_install(source), source.name)
|
|
return {identity: source for identity, source in sources.items() if _is_member_candidate(source)}
|
|
|
|
# Validate all active dependencies. The installed identity remains stable while
|
|
# PM snapshots the staged inputs into the candidate generation's workspace.
|
|
active = target.resolve() in member_sources(enabled_plugin_dirs())
|
|
if active:
|
|
sync_venv(explicit=True, plugin_dirs=candidate_members,
|
|
before_publish=lambda: PluginPublication(project, staged, target, new_metadata))
|
|
else:
|
|
with runtime_lock(project):
|
|
recover_publication(project)
|
|
if target.resolve() in member_sources(enabled_plugin_dirs()):
|
|
raise plugins_cmd.PluginOperationError("Plugin enablement changed while preparing the install; retry.")
|
|
if plugins_cmd._read_install_metadata() != old_metadata:
|
|
raise plugins_cmd.PluginOperationError("Plugin install metadata changed while preparing the install; retry.")
|
|
PluginPublication(project, staged, target, new_metadata).finish()
|
|
|
|
|
|
def update_plugin(target: Path, *, catalog_entry=None) -> str:
|
|
"""Prepare a catalog re-pin or custom Git pull without changing the live tree."""
|
|
import tempfile
|
|
|
|
from hermes_cli import plugins_cmd as pc
|
|
from hermes_cli.plugins_cmd_catalog import raise_if_removed
|
|
from pm.store import tree_digest
|
|
|
|
target = target.resolve()
|
|
metadata = pc._read_install_metadata()
|
|
record = dict(metadata.get(target.name, {}))
|
|
if catalog_entry is None and record.get("pinned"):
|
|
raise pc.PluginOperationError(f"Plugin '{target.name}' is pinned; reinstall with an explicit --ref to change it.")
|
|
source = catalog_entry.install_identifier if catalog_entry else str(record.get("source") or "")
|
|
if not source or (catalog_entry is None and not (target / ".git").is_dir()):
|
|
raise pc.PluginOperationError(f"Plugin '{target.name}' has no owned Git checkout; reinstall from its source.")
|
|
feed_revision = None
|
|
if catalog_entry is None:
|
|
from hermes_cli.plugins_provenance import Provenance, ProvenanceClass
|
|
from hermes_cli.plugins_updates import check_local_provenance, default_fetch, parse_feed_yml
|
|
|
|
checked = check_local_provenance(Provenance(target.name, ProvenanceClass.GIT, target, record))
|
|
if checked.needs_fixing:
|
|
raise pc.PluginOperationError(checked.needs_fixing)
|
|
if record.get("update_url"):
|
|
feed = parse_feed_yml(default_fetch(record["update_url"]))
|
|
proposed = (feed.get("artifacts") or {}).get("git", "")
|
|
if pc._EXACT_COMMIT_RE.fullmatch(proposed):
|
|
feed_revision = proposed.lower()
|
|
elif proposed not in (source, source.split("#", 1)[0]):
|
|
raise pc.PluginOperationError("Update feed must select a commit or the recorded Git source.")
|
|
if feed.get("min_hermes"):
|
|
pc._check_manifest_version({"requires_hermes": feed["min_hermes"]}, target.name)
|
|
raise_if_removed(target.name, source.split("#", 1)[0])
|
|
before = tree_digest(target)
|
|
with tempfile.TemporaryDirectory(prefix=".update-", dir=target.parent) as directory:
|
|
staged = Path(directory) / "plugin"
|
|
try:
|
|
if catalog_entry:
|
|
git_url, subdir = pc._resolve_git_url(source)
|
|
revision = pc._clone_plugin_repo(staged, git_url, catalog_entry.sha)
|
|
staged = pc._resolve_subdir_within(staged, subdir) if subdir else staged
|
|
# Catalog re-pins cannot discard edits to a currently installed tree.
|
|
if (target / ".git").is_dir():
|
|
git = pc._resolve_git_executable()
|
|
changed = pc._git_or_raise(git, target, "status", "--porcelain", "--untracked-files=normal",
|
|
failure_prefix="Could not inspect plugin edits: ")
|
|
if changed.stdout.strip():
|
|
raise pc.PluginOperationError("Catalog plugin has local changes; save them before updating.")
|
|
output = f"Updated to catalog pin {revision}"
|
|
record.update(catalog_name=catalog_entry.name, catalog_tier=catalog_entry.tier,
|
|
pinned=True, source=pc._canonical_source(git_url, subdir))
|
|
else:
|
|
# Copy Git metadata and local changes. Autostash only ever touches the copy.
|
|
shutil.copytree(target, staged, symlinks=True, ignore=shutil.ignore_patterns("__pycache__"))
|
|
if feed_revision:
|
|
git = pc._resolve_git_executable()
|
|
status = pc._git_or_raise(git, target, "status", "--porcelain", failure_prefix="Could not inspect plugin edits: ")
|
|
if status.stdout.strip():
|
|
raise pc.PluginOperationError("Pinned feed update has local changes; save them before updating.")
|
|
pc._checkout_exact_revision(staged, git, feed_revision)
|
|
output = f"Updated to feed commit {feed_revision}"
|
|
else:
|
|
ok, output = pc._git_pull_plugin_dir(staged)
|
|
if not ok:
|
|
raise pc.PluginOperationError(output)
|
|
revision = pc._git_head_revision(staged, pc._resolve_git_executable())
|
|
manifest = pc._read_manifest_for_install(staged)
|
|
if manifest.get("name", target.name) != target.name:
|
|
raise pc.PluginOperationError("The updated plugin changed its installed name; reinstall it explicitly.")
|
|
pc._check_manifest_version(manifest, target.name)
|
|
pc._scan_plugin_tree(staged, source, force=False)
|
|
pc._copy_example_files(staged, pc._console())
|
|
if tree_digest(target) != before:
|
|
raise pc.PluginOperationError("Plugin files changed while preparing the update; retry.")
|
|
record["revision"] = revision
|
|
if tree_digest(staged) == before:
|
|
return output
|
|
publish_plugin(staged, target, metadata, {**metadata, target.name: record})
|
|
return output
|
|
except pc.PluginOperationError:
|
|
raise
|
|
except Exception as exc:
|
|
raise pc.PluginOperationError(f"Plugin '{target.name}' update was not published: {exc}") from exc
|