plugins_cmd.py had grown to 2,858 lines, past the ~2,000-line gate. Move each verb family into a plugins_cmd_<topic>.py sibling: git (install metadata + git plumbing), install, update (plus adopt / trust-update-url / check-updates), remove, capabilities, toggle (composite UI) and listing. The facade keeps the shared primitives, enable/disable selection, discovery and the dispatch table, and re-exports the names other modules, tests and the old-updater surface import (978 lines now). Siblings never import the facade at module level; they read facade names through _pc() at call time, so monkeypatching plugins_cmd.<name> still intercepts calls made from a sibling. No behaviour change. Tests that imported three sibling-only helpers now import them from the defining module, and two subprocess.run patches target subprocess directly.
369 lines
18 KiB
Python
369 lines
18 KiB
Python
"""Plugin install metadata (``.install-metadata.json`` source/revision/pin records) and the git plumbing
|
|
behind clone, exact-revision checkout, credential scrubbing and the autostashing ``git pull``.
|
|
|
|
Sibling of :mod:`hermes_cli.plugins_cmd` (the facade re-exports the names other modules use and is
|
|
imported late here, never at module level).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import threading
|
|
import urllib.parse
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from typing import Callable, Optional
|
|
|
|
from hermes_cli._subprocess_compat import noninteractive_git_env
|
|
from hermes_constants import get_hermes_home
|
|
from utils import atomic_write_text
|
|
|
|
|
|
def _pc():
|
|
"""The facade, read at call time: tests patch ``plugins_cmd.<name>`` and sibling calls must see it."""
|
|
from hermes_cli import plugins_cmd
|
|
return plugins_cmd
|
|
|
|
|
|
_EXACT_COMMIT_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
|
|
|
|
def _install_metadata_path() -> Path:
|
|
return get_hermes_home() / "plugins" / ".install-metadata.json"
|
|
|
|
|
|
def _read_install_metadata() -> dict[str, dict[str, object]]:
|
|
"""Read profile-local, non-secret plugin source metadata from disk."""
|
|
path = _install_metadata_path()
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise _pc().PluginOperationError(f"Could not read plugin install metadata: {exc}") from exc
|
|
if not isinstance(value, dict):
|
|
raise _pc().PluginOperationError("Plugin install metadata must be a JSON object.")
|
|
return value
|
|
|
|
|
|
def _write_install_metadata(metadata: dict[str, dict[str, object]]) -> None:
|
|
"""Atomically replace the profile-local plugin install metadata sidecar."""
|
|
path = _install_metadata_path()
|
|
atomic_write_text(
|
|
path, json.dumps(metadata, indent=2, sort_keys=True) + "\n", tmp_prefix=f"{path.name}.tmp-")
|
|
|
|
|
|
_INSTALL_METADATA_LOCK_HOLDER = threading.local()
|
|
|
|
|
|
@contextmanager
|
|
def _install_metadata_lock():
|
|
"""Serialize read-modify-write of the sidecar across threads and processes. Installs overlap (the
|
|
Desktop install card runs its rows a second apart); each held a snapshot read before its clone, so
|
|
the later write dropped the earlier plugin's record."""
|
|
from hermes_cli.auth import _file_lock
|
|
|
|
path = _install_metadata_path()
|
|
with _file_lock(path.with_name(f"{path.name}.lock"), _INSTALL_METADATA_LOCK_HOLDER, 10.0,
|
|
"Timed out waiting for the plugin install metadata lock"):
|
|
yield
|
|
|
|
|
|
def _update_install_record(name: str, update: Callable[[Optional[dict]], Optional[dict]]) -> None:
|
|
"""Rewrite one plugin's record in the CURRENT sidecar, under the lock. *update* maps the current
|
|
record (None when absent) to the new one (None removes it); every other record is re-read here,
|
|
never carried over from a caller's earlier snapshot."""
|
|
with _install_metadata_lock():
|
|
metadata = _pc()._read_install_metadata()
|
|
record = update(metadata.get(name))
|
|
if record is None:
|
|
if name not in metadata:
|
|
return
|
|
del metadata[name]
|
|
else:
|
|
metadata[name] = record
|
|
_pc()._write_install_metadata(metadata)
|
|
|
|
|
|
def pinned_revision(name: str, metadata: Optional[dict] = None) -> Optional[str]:
|
|
"""Full SHA a ``--ref`` install of *name* is pinned to, else ``None``."""
|
|
entry = (metadata if metadata is not None else _pc()._read_install_metadata()).get(name)
|
|
if isinstance(entry, dict) and entry.get("pinned") is True and isinstance(entry.get("revision"), str):
|
|
return entry["revision"]
|
|
return None
|
|
|
|
|
|
def _pin_annotation(name: str, metadata: dict) -> Optional[str]:
|
|
sha = pinned_revision(name, metadata)
|
|
return f"git pinned@{sha[:8]}" if sha else None
|
|
|
|
|
|
def _normalize_exact_revision(ref: str) -> str:
|
|
"""Lowercase a full 40-hex commit SHA; anything else is a PluginOperationError."""
|
|
if not isinstance(ref, str) or not _EXACT_COMMIT_RE.fullmatch(ref):
|
|
raise _pc().PluginOperationError("--ref must be a full 40-character commit SHA.")
|
|
return ref.lower()
|
|
|
|
|
|
def _safe_git_error(result: subprocess.CompletedProcess, source_url: str = "") -> str:
|
|
"""Diagnosable Git output without echoing embedded credentials."""
|
|
from agent.redact import redact_sensitive_text
|
|
error = (result.stderr or result.stdout or "").strip()
|
|
if source_url:
|
|
error = error.replace(source_url, _scrub_git_url(source_url))
|
|
return redact_sensitive_text(error)
|
|
|
|
|
|
def _git_or_raise(
|
|
git_exe: str, repo: Path, *args: str, failure_prefix: str, timeout: int = 60, source_url: str = "",
|
|
auth_url: str = "",
|
|
) -> subprocess.CompletedProcess:
|
|
"""Run git in *repo*; on a non-zero exit raise PluginOperationError(prefix + scrubbed error)."""
|
|
result = _pc()._run_plugin_git(git_exe, repo, *args, timeout=timeout, auth_url=auth_url)
|
|
if result.returncode != 0:
|
|
raise _pc().PluginOperationError(failure_prefix + _safe_git_error(result, source_url))
|
|
return result
|
|
|
|
|
|
def _git_head_revision(repo: Path, git_exe: str) -> str:
|
|
return _git_or_raise(
|
|
git_exe, repo, "rev-parse", "HEAD", timeout=15,
|
|
failure_prefix="Could not determine installed Git revision:\n",
|
|
).stdout.strip().lower()
|
|
|
|
|
|
def _git_resolve_commit(repo: Path, git_exe: str, revision: str) -> str:
|
|
"""The COMMIT a revision names, peeling annotated tags.
|
|
|
|
A catalog pin is 40 hex, but that does not make it a commit: a tag object
|
|
has a sha of its own, and a pin recorded as `git rev-parse <tag>` names the
|
|
tag object, not the commit it points at. Git detaches at the commit, so
|
|
comparing HEAD against the tag object's sha refuses a correct checkout
|
|
(and the catalog installer then cannot install that entry at all). Peeling
|
|
first keeps the guard — HEAD must still BE that commit — while admitting
|
|
the pins authors actually publish. Returns `revision` unchanged when it
|
|
resolves to nothing, so the mismatch guard below still fires.
|
|
"""
|
|
try:
|
|
result = _pc()._run_plugin_git(
|
|
git_exe, repo, "rev-parse", "--verify", "--quiet", f"{revision}^{{commit}}", timeout=15,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return revision
|
|
resolved = result.stdout.strip().lower()
|
|
return resolved if result.returncode == 0 and resolved else revision
|
|
|
|
|
|
def _checkout_exact_revision(repo: Path, git_exe: str, revision: str, source_url: str = "") -> None:
|
|
"""Fetch and detach at one immutable commit, then verify the resulting HEAD. The checkout is
|
|
a network verb too: in a partial (subdirectory) clone it downloads the file contents."""
|
|
timeout = _pc()._clone_timeout_seconds()
|
|
for verb, args, failure_prefix in (
|
|
("fetch", ("fetch", "--depth", "1", "origin", revision), f"Git commit '{revision}' could not be fetched:\n"),
|
|
("checkout", ("checkout", "--detach", revision), f"Git checkout of commit '{revision}' failed:\n"),
|
|
):
|
|
try:
|
|
_git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix, source_url=source_url,
|
|
auth_url=source_url, timeout=timeout)
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise _pc().PluginOperationError(
|
|
f"Git {verb} of commit '{revision}' timed out after {timeout} seconds. {_pc()._CLONE_TIMEOUT_HINT}") from exc
|
|
actual = _pc()._git_head_revision(repo, git_exe)
|
|
if actual != _git_resolve_commit(repo, git_exe, revision):
|
|
raise _pc().PluginOperationError(
|
|
f"Checked-out revision '{actual}' does not match requested commit '{revision}'.")
|
|
|
|
|
|
def _scrub_git_url(git_url: str) -> str:
|
|
"""Strip credentials and query/fragment data from an HTTP Git URL."""
|
|
parsed = urllib.parse.urlsplit(git_url)
|
|
if parsed.scheme in {"http", "https"} and parsed.hostname:
|
|
host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
|
|
if parsed.port is not None:
|
|
host = f"{host}:{parsed.port}"
|
|
return urllib.parse.urlunsplit((parsed.scheme, host, parsed.path, "", ""))
|
|
return git_url
|
|
|
|
|
|
def _canonical_source(git_url: str, subdir: Optional[str]) -> str:
|
|
scrubbed = _scrub_git_url(git_url)
|
|
return f"{scrubbed}#{subdir}" if subdir else scrubbed
|
|
|
|
|
|
def _scrub_cloned_origin(repo: Path, git_exe: str, git_url: str) -> None:
|
|
"""Ensure credentials used for cloning do not survive in ``.git/config``."""
|
|
scrubbed = _scrub_git_url(git_url)
|
|
if scrubbed != git_url:
|
|
_git_or_raise(
|
|
git_exe, repo, "remote", "set-url", "origin", scrubbed, timeout=15,
|
|
failure_prefix="Could not sanitize installed Git remote:\n", source_url=git_url)
|
|
|
|
|
|
def _restrict_checkout_to_subdir(repo: Path, git_exe: str, subdir: str) -> None:
|
|
"""Sparse-check-out only *subdir*. Written as the classic ``info/sparse-checkout`` file
|
|
rather than ``git sparse-checkout set`` so older Git clients work too."""
|
|
_git_or_raise(git_exe, repo, "config", "core.sparseCheckout", "true", timeout=15,
|
|
failure_prefix="Could not enable sparse checkout:\n")
|
|
pattern_file = repo / ".git" / "info" / "sparse-checkout"
|
|
pattern_file.parent.mkdir(parents=True, exist_ok=True)
|
|
escaped = re.sub(r"([\\*?\[])", r"\\\1", subdir.strip("/"))
|
|
pattern_file.write_text(f"/{escaped}/\n", encoding="utf-8")
|
|
|
|
|
|
def _clone_plugin_repo(tmp_clone: Path, git_url: str, revision: Optional[str],
|
|
subdir: Optional[str] = None) -> str:
|
|
"""Shallow-clone *git_url* into *tmp_clone* (detached at *revision* when given), scrub any
|
|
credentials from the recorded origin, and return the installed HEAD SHA.
|
|
|
|
A *subdir* install is a blobless clone with a sparse checkout of that subdirectory: a plugin
|
|
living in a monorepo (Hindsight: 170 MB at depth 1, 2 MB for its plugin folder) otherwise
|
|
downloads every file in the repository, which times out on slow connections."""
|
|
git_exe = _pc()._resolve_git_executable()
|
|
if not git_exe:
|
|
raise _pc().PluginOperationError("git is not installed or not in PATH.")
|
|
clone_timeout = _pc()._clone_timeout_seconds()
|
|
partial = ["--filter=blob:none"] if subdir else []
|
|
no_checkout = ["--no-checkout"] if revision or subdir else []
|
|
clone_args = ["clone", "--depth", "1", *partial, *no_checkout, git_url, str(tmp_clone)]
|
|
try:
|
|
result = _pc()._run_plugin_git(git_exe, tmp_clone.parent, *clone_args, auth_url=git_url,
|
|
timeout=clone_timeout)
|
|
except FileNotFoundError as e:
|
|
raise _pc().PluginOperationError("git is not installed or not in PATH.") from e
|
|
except subprocess.TimeoutExpired as e:
|
|
raise _pc().PluginOperationError(f"Git clone timed out after {clone_timeout} seconds. {_pc()._CLONE_TIMEOUT_HINT}") from e
|
|
if result.returncode != 0:
|
|
raise _pc().PluginOperationError(_pc()._clone_failure_message(git_url, _safe_git_error(result, git_url)))
|
|
_scrub_cloned_origin(tmp_clone, git_exe, git_url)
|
|
if subdir:
|
|
_restrict_checkout_to_subdir(tmp_clone, git_exe, subdir)
|
|
if revision:
|
|
_checkout_exact_revision(tmp_clone, git_exe, revision, source_url=git_url)
|
|
elif subdir:
|
|
try:
|
|
_git_or_raise(git_exe, tmp_clone, "checkout", "HEAD", timeout=clone_timeout, source_url=git_url,
|
|
auth_url=git_url, failure_prefix="Git checkout of the plugin subdirectory failed:\n")
|
|
except subprocess.TimeoutExpired as e:
|
|
raise _pc().PluginOperationError(
|
|
f"Git checkout timed out after {clone_timeout} seconds. {_pc()._CLONE_TIMEOUT_HINT}") from e
|
|
return _pc()._git_head_revision(tmp_clone, git_exe)
|
|
|
|
|
|
def _run_plugin_git(
|
|
git_exe: str, target: Path, *args: str, timeout: int = 60, auth_url: str = "",
|
|
) -> subprocess.CompletedProcess:
|
|
"""Run one git command inside a plugin checkout (non-interactive). *auth_url* names the remote
|
|
a network verb talks to; it runs anonymously first and a stored user credential for that host
|
|
is attached only when the remote refuses anonymous access (private repos)."""
|
|
from hermes_cli.git_credentials import run_git_with_credential_fallback
|
|
return run_git_with_credential_fallback(
|
|
[git_exe, *args], auth_url, env=noninteractive_git_env(), capture_output=True, text=True,
|
|
encoding='utf-8', errors='replace', timeout=timeout, cwd=str(target))
|
|
|
|
|
|
def _stash_ref(git_exe: str, target: Path) -> str:
|
|
"""Current ``refs/stash`` commit, or empty string when no stash exists."""
|
|
probe = _pc()._run_plugin_git(git_exe, target, "rev-parse", "--verify", "refs/stash")
|
|
return probe.stdout.strip() if probe.returncode == 0 else ""
|
|
|
|
|
|
def _reapply_stash(git_exe: str, target: Path, stash_sha: str) -> bool:
|
|
"""``stash apply`` the autostash commit *stash_sha*; drop it on a clean apply. False when it
|
|
applied with errors or left unmerged paths (the stash entry is kept in that case).
|
|
|
|
Git is addressed by the stash's commit sha, never a ``stash@{N}`` selector: on native Windows
|
|
the MSYS runtime re-parses git.exe's argv and strips the braces, so ``stash@{0}`` reaches git
|
|
as ``stash@0`` and both the apply and the drop fail (#87542)."""
|
|
restore = _pc()._run_plugin_git(git_exe, target, "stash", "apply", stash_sha)
|
|
unmerged = _pc()._run_plugin_git(git_exe, target, "diff", "--name-only", "--diff-filter=U")
|
|
if restore.returncode != 0 or unmerged.stdout.strip():
|
|
return False
|
|
# `stash drop` only takes a selector; a bare `drop` targets the newest entry, so drop
|
|
# positionally only while the newest entry is still our autostash.
|
|
if _stash_ref(git_exe, target) == stash_sha:
|
|
_pc()._run_plugin_git(git_exe, target, "stash", "drop")
|
|
return True
|
|
|
|
|
|
def _autostash_dirty_tree(git_exe: str, target: Path) -> tuple[str, str]:
|
|
"""Stash local edits before a pull. Returns ``(stash_sha, error)``; *stash_sha* is empty when
|
|
the tree was clean, and a non-empty error means the tree is dirty but nothing was saved, so
|
|
the pull must not run."""
|
|
status = _pc()._run_plugin_git(git_exe, target, "status", "--porcelain", "-z")
|
|
if status.returncode != 0 or not status.stdout.strip():
|
|
return "", ""
|
|
# `git add -N` entries make `git stash push` fail outright (see update_cmd_stash), so promote them
|
|
# to real staged adds first; the checkout's own local edits are otherwise unstashable.
|
|
from hermes_cli.update_cmd_stash import _intent_to_add_paths
|
|
|
|
intent_to_add = _intent_to_add_paths(status.stdout)
|
|
if intent_to_add:
|
|
_pc()._run_plugin_git(git_exe, target, "add", "--", *intent_to_add)
|
|
pre_stash = _stash_ref(git_exe, target)
|
|
push = _pc()._run_plugin_git(
|
|
git_exe, target, "stash", "push", "--include-untracked", "-m", "hermes-plugin-update-autostash")
|
|
post_stash = _stash_ref(git_exe, target)
|
|
if not post_stash or post_stash == pre_stash:
|
|
err = _safe_git_error(push)
|
|
return "", (
|
|
"Local changes in the plugin checkout could not be "
|
|
"stashed; update aborted before touching the checkout."
|
|
+ (f"\n{err}" if err else ""))
|
|
if push.returncode != 0:
|
|
# Saved-but-couldn't-clean (undeletable untracked files): the stash entry is complete;
|
|
# reset tracked mods so the pull isn't blocked by a still-dirty tree.
|
|
_pc()._run_plugin_git(git_exe, target, "reset", "--hard", "HEAD")
|
|
return post_stash, ""
|
|
|
|
|
|
def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
|
|
"""``git pull --ff-only`` a plugin checkout, autostashing local edits (users patch installed
|
|
plugins in place, and a plain ff-only pull would then refuse forever).
|
|
|
|
Users tweak installed plugins in place (config constants, small patches), and a plain ``pull --ff-only``
|
|
then aborts with "Your local changes ... would be overwritten by merge" — making the plugin permanently
|
|
un-updatable until they hand-run git. Same UX class Factory Droid fixed in v0.188 ("Updating a plugin
|
|
marketplace now succeeds when its checkout has local changes"), and the same autostash approach ``hermes
|
|
update`` already uses for the main checkout (PR #70161).
|
|
"""
|
|
git_exe = _pc()._resolve_git_executable()
|
|
if not git_exe:
|
|
return False, "git is not installed or not in PATH."
|
|
try:
|
|
stash_sha, err = _autostash_dirty_tree(git_exe, target)
|
|
if err:
|
|
return False, err
|
|
origin = _pc()._run_plugin_git(git_exe, target, "remote", "get-url", "origin", timeout=15)
|
|
result = _pc()._run_plugin_git(git_exe, target, "pull", "--ff-only", auth_url=origin.stdout.strip())
|
|
if result.returncode != 0:
|
|
err = _safe_git_error(result) or "git pull failed."
|
|
if not stash_sha:
|
|
return False, err
|
|
# Put the user's edits back before reporting the failure.
|
|
if _reapply_stash(git_exe, target, stash_sha):
|
|
note = "Local changes were restored."
|
|
else:
|
|
note = "Local changes are preserved in git stash (restore with: git stash pop)."
|
|
return False, f"{err}\n{note}"
|
|
|
|
pulled = result.stdout.strip()
|
|
if not stash_sha:
|
|
return True, pulled
|
|
if _reapply_stash(git_exe, target, stash_sha):
|
|
return True, pulled + "\nLocal changes were re-applied on top of the update."
|
|
|
|
# Conflicted re-apply: leave the plugin importable on the updated
|
|
# revision; the user's edits stay safe in the stash entry.
|
|
_pc()._run_plugin_git(git_exe, target, "reset", "--hard", "HEAD")
|
|
return True, pulled + (
|
|
"\n⚠ Local changes in this plugin conflicted with the update and "
|
|
"were NOT re-applied. They are preserved in git stash — inspect "
|
|
"with `git stash show -p` and re-apply with "
|
|
f"`git stash pop` inside {target}.")
|
|
except FileNotFoundError:
|
|
return False, "git is not installed or not in PATH."
|
|
except subprocess.TimeoutExpired:
|
|
return False, "Git operation timed out after 60 seconds."
|