Activation reaches plugin discovery before the application dependencies exist. Give PM its own locked Python project and runtime so it can install or repair the application without importing that dependency tree. Keep PM outside the application workspace. A shared uv workspace resolves the application graph and cannot provide this isolation. Route mutations through an isolated worker and preserve transaction callbacks, cancellation, custom package registrations, and correlated receipts. Use the same runtime builder for source installs and packaged payloads. Keep offline wheelhouse support in that builder. Nix builds the independent PM lock as a separate derivation. Refuse lazy-disabled bootstrap before installing tools or dependencies. Move first-party YAML readers and writers to ruamel. Keep the application lock's transitive PyYAML requirements for third-party packages. Verification: - Focused canonical Python suite: 177 passed, 1 host-gated skip. - Electron backend probes: 12 passed. Electron typecheck passed. - Both uv locks, scoped lint, Bash syntax, and whitespace checks passed. - Cold activation, corrupt-app repair, offline staging, and relocation ran. - Built and exercised the Nix PM runtime and standalone YAML merge script. Six broader caller test files retain the same 24 failing test IDs as an archive of HEAD. The existing real-home guard blocks those tests before they can exercise the affected paths. No full-suite pass is claimed. Native Windows signing and full Bionic package execution remain unverified.
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.client 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
|