"""Publish plugin code and its dependency selection through one recoverable handoff.""" from __future__ import annotations from collections.abc import Callable from pathlib import Path import shutil def recover_plugin_publication(project: Path, row: dict, journal: Path) -> None: """Recover a shipped caller's row through the stdlib boot-journal owner.""" from hermes_cli.runtime_state import _recover_plugin_publication _recover_plugin_publication(project, row, journal) def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: dict, *, target_digest: str | None = None, require_consent: bool = False) -> None: from pm.client import sync_venv from pm.plugin_inputs import StagedUpdate from pm.store import tree_digest if require_consent: from hermes_cli import plugins_cmd from pm.workspace import enabled_plugin_dirs if target.resolve() in enabled_plugin_dirs(installing=target): consented, reason = plugins_cmd._install_plugin_python_deps( plugins_cmd._read_manifest_for_install(staged), staged, plugins_cmd._console()) if not consented: raise plugins_cmd.PluginOperationError( f"Reinstall declined: {reason}. The installed plugin and active environment are unchanged.") sync_venv(explicit=True, plugins=StagedUpdate({ "staged": str(staged.resolve()), "target": str(target.absolute()), "old_metadata": old_metadata, "new_metadata": new_metadata, "target_digest": target_digest if target_digest is not None else (tree_digest(target) if target.exists() else None), })) def _refresh_declared_dependencies(target: Path, staged: Path, manifest: dict, *, interactive: bool) -> None: """Dependency changes carried by an update clear the same gates as an install. New Python requirements install packages into the shared environment: install and reinstall prompt for that, so an update prompts too, and an unattended one (dashboard, ``plugins.auto_apply`` from the gateway) is REFUSED rather than consented on the user's behalf. Publication replaces the whole tree, so a Node sidecar the user accepted earlier is rebuilt in the staged copy when its package.json/lock moved (custom pulls copy a stale node_modules; catalog re-pins clone without one). """ from hermes_cli import plugins_cmd as pc from hermes_cli.runtime_state import _bytes from pm.plugin_declarations import read_python_declaration from pm.workspace import enabled_plugin_dirs, install_node_sidecar if (target / "node_modules").is_dir() and (staged / "package.json").is_file() and ( not (staged / "node_modules").is_dir() or any(_bytes(target / name) != _bytes(staged / name) for name in ("package.json", "package-lock.json"))): reason = install_node_sidecar(staged, explicit=True) if reason: raise pc.PluginOperationError( f"Node dependencies could not be refreshed: {reason}. " "The installed plugin and active environment are unchanged.") if target.resolve() not in enabled_plugin_dirs(installing=target): return # a disabled plugin's deps are admitted (and consented) by `hermes plugins enable` before, after = read_python_declaration(target), read_python_declaration(staged) added = tuple(spec for spec in after.install_requirements if spec not in before.install_requirements) if not added and not (after.is_member and not before.is_member): return if not interactive: raise pc.PluginOperationError( f"The update declares new Python dependencies ({', '.join(added) or 'in its pyproject.toml'}); " f"run `hermes plugins update {target.name}` in a terminal to review them.") consented, reason = pc._consent_python_deps(manifest.get("name", target.name), added, pc._console()) if not consented: raise pc.PluginOperationError( f"Update declined: {reason}. The installed plugin and active environment are unchanged.") def update_plugin( target: Path, *, catalog_entry=None, interactive: bool = False, carry_user_files: Callable[[Path], list[str]] | None = None, ) -> str: """Prepare a catalog re-pin or custom Git pull without changing the live tree. *interactive*: a terminal user is present to consent to newly declared dependencies; the dashboard and the gateway's auto-apply pass False and get a refusal instead. *carry_user_files(staged)* may merge user-owned state into the staged tree before manifest validation, example-file generation, dependency preparation and publication; it returns the carried paths so a scan block can attribute findings to them.""" import tempfile from hermes_cli import plugins_cmd as pc from hermes_cli.plugins_cmd_catalog import refuse_if_installed_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) refuse_if_installed_removed(target.name, target) 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 output = f"Updated to catalog pin {revision}" catalog_record = { "name": catalog_entry.name, "repo": catalog_entry.repo, "tier": catalog_entry.tier, "pin": catalog_entry.sha, "sha": revision, } record.update( catalog=catalog_record, pinned=True, source=pc._canonical_source(git_url, subdir), ) record.pop("catalog_name", None) record.pop("catalog_tier", None) from hermes_cli.plugins_cmd_catalog import write_catalog_sidecar_record write_catalog_sidecar_record(staged, catalog_record, revision) 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()) merged = carry_user_files(staged) if carry_user_files is not None else None manifest = pc._read_manifest_for_install(staged) installed_name = str(manifest.get("name") or target.name) if catalog_entry is None and installed_name != target.name: raise pc.PluginOperationError("The updated plugin changed its installed name; reinstall it explicitly.") try: new_target = pc._sanitize_plugin_name(installed_name, target.parent) except ValueError as exc: raise pc.PluginOperationError(str(exc)) from exc if new_target != target and new_target.exists(): raise pc.PluginOperationError( f"The updated plugin renamed itself to '{installed_name}', but that plugin already exists.") pc._check_manifest_version(manifest, installed_name) pc._scan_merged_tree(staged, source, merged, force=False) pc._copy_example_files(staged, pc._console()) _refresh_declared_dependencies(target, staged, manifest, interactive=interactive) if tree_digest(target) != before: raise pc.PluginOperationError("Plugin files changed while preparing the update; retry.") record["revision"] = revision if new_target == target and tree_digest(staged) == before: return output publish_plugin( staged, new_target, metadata, {**metadata, installed_name: record}, target_digest=before if new_target == target else None, ) return output except pc.PluginOperationError: raise except Exception as exc: raise pc.PluginOperationError(f"Plugin '{target.name}' update was not published: {exc}") from exc