With the live-gateway refusal reverted (#134824), `hermes plugins uninstall` on Windows still failed with [WinError 5] whenever the running gateway had the plugin loaded with a file open or a native .pyd mapped: Windows will neither delete such a file nor rename its directory. The command exited 1 and left a half-deleted plugin. Removal now deletes what it can. When a held file stops the delete, the rest of the tree moves out of plugins/ into <HERMES_HOME>/cache/plugin-pending-delete, which works for mapped DLLs. When the directory itself is held and cannot move, removal deletes its manifests so the plugin can no longer load. The install record is dropped in both cases, and the gateway's next start sweeps what was parked. POSIX is unchanged: a PermissionError still raises. Live proof: tests/hermes_cli/test_plugin_uninstall_windows_live.py (a real gateway with plain, open-handle and native-.pyd plugins) fails 2 of 3 on the revert branch and passes 3 of 3 with this change on windows-latest.
154 lines
6.8 KiB
Python
154 lines
6.8 KiB
Python
"""``hermes plugins remove``: tree + install-metadata removal kept consistent, config bookkeeping, and the
|
|
dashboard/TUI remove path.
|
|
|
|
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 os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
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
|
|
|
|
|
|
_PENDING_DELETE_DIR = "plugin-pending-delete"
|
|
|
|
|
|
def _pending_delete_root() -> Path:
|
|
"""``<HERMES_HOME>/cache/plugin-pending-delete``: trees a running process still held at removal."""
|
|
from hermes_constants import get_hermes_home
|
|
return get_hermes_home() / "cache" / _PENDING_DELETE_DIR
|
|
|
|
|
|
def sweep_pending_plugin_deletes() -> None:
|
|
"""Delete what an earlier remove parked because another process held it; cheap when empty.
|
|
A ``<name>.path`` note names a stripped tree that could not even be moved out of ``plugins/``."""
|
|
root = _pending_delete_root()
|
|
if not root.is_dir():
|
|
return
|
|
for entry in root.iterdir():
|
|
if entry.suffix == ".path" and entry.is_file():
|
|
held = Path(entry.read_text(encoding="utf-8-sig").strip())
|
|
_pc().rmtree_readonly(held, ignore_errors=True)
|
|
if not held.exists():
|
|
entry.unlink(missing_ok=True)
|
|
else:
|
|
_pc().rmtree_readonly(entry, ignore_errors=True)
|
|
|
|
|
|
def _discard_tree(tree: Path) -> None:
|
|
"""Delete *tree*. On Windows a running gateway / plugin host that loaded the plugin keeps its
|
|
native modules mapped and its open files locked (WinError 5/32), and deleting stops at the first
|
|
one. The rest is then moved out of ``plugins/`` into the pending-delete area, so nothing loadable
|
|
stays behind, and the sweep at the next start finishes the job."""
|
|
try:
|
|
_pc().rmtree_readonly(tree)
|
|
return
|
|
except PermissionError:
|
|
if os.name != "nt" or not tree.exists():
|
|
raise
|
|
root = _pending_delete_root()
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
parked = Path(tempfile.mkdtemp(prefix=f"{tree.name}-", dir=root)) / "plugin"
|
|
try:
|
|
os.replace(tree, parked) # same volume (both under HERMES_HOME): a rename, allowed on mapped DLLs
|
|
except OSError:
|
|
# A held directory itself (a process's cwd, an open handle on it) cannot even be renamed. Strip
|
|
# its manifests so the remnant no longer loads, and leave the files to the next start.
|
|
for name in ("plugin.yaml", "plugin.yml", "plugin.json", "__init__.py"):
|
|
(tree / name).unlink(missing_ok=True)
|
|
(root / f"{tree.parent.name}-{tree.name}.path").write_text(str(tree), encoding="utf-8")
|
|
|
|
|
|
def _remove_plugin_core(target: Path) -> None:
|
|
"""Remove one plugin and its metadata without splitting their state."""
|
|
if target.name not in _pc()._read_install_metadata():
|
|
_discard_tree(target)
|
|
return
|
|
staging = Path(tempfile.mkdtemp(prefix=f".{target.name}.remove-", dir=target.parent))
|
|
backup = staging / "plugin"
|
|
try:
|
|
os.replace(target, backup)
|
|
except PermissionError:
|
|
_pc().rmtree_readonly(staging, ignore_errors=True)
|
|
if os.name != "nt":
|
|
raise
|
|
# Windows refuses to rename a directory another process holds open; record removal first
|
|
# (the plugin is uninstalled from this point), then discard the tree in place.
|
|
_pc()._update_install_record(target.name, lambda _current: None)
|
|
_discard_tree(target)
|
|
return
|
|
try:
|
|
_pc()._update_install_record(target.name, lambda _current: None)
|
|
except Exception:
|
|
try:
|
|
os.replace(backup, target)
|
|
except OSError as restore_exc:
|
|
raise _pc().PluginOperationError(
|
|
f"Plugin metadata update failed and '{target.name}' could not be "
|
|
f"restored automatically; recovery copy remains at {backup}."
|
|
) from restore_exc
|
|
_pc().rmtree_readonly(staging, ignore_errors=True)
|
|
raise
|
|
_discard_tree(staging)
|
|
|
|
|
|
def cmd_remove(name: str) -> None:
|
|
"""Remove an installed plugin by name."""
|
|
console = _pc()._console()
|
|
plugins_dir = _pc()._plugins_dir()
|
|
target = _pc()._require_installed_plugin(name, plugins_dir, console)
|
|
try:
|
|
result = _remove_user_plugin(plugins_dir, name, target)
|
|
except (OSError, _pc().PluginOperationError) as exc:
|
|
_pc()._fail(console, f"[red]Error:[/red] Could not remove plugin '{name}': {exc}")
|
|
console.print()
|
|
console.print(f"[red]✗[/red] Plugin [bold]{name}[/bold] removed from {plugins_dir}")
|
|
if result.get("cleared_memory_provider"):
|
|
console.print("[yellow]memory.provider pointed at this plugin and was reset; "
|
|
"run `hermes memory setup` to pick another.[/yellow]")
|
|
console.print()
|
|
|
|
|
|
def _remove_user_plugin(plugins_dir: Path, name: str, target: Path) -> dict[str, Any]:
|
|
"""Shared ``remove`` tail for the CLI, the dashboard and the ``plugins.manage`` RPC.
|
|
|
|
*target* is the resolved directory; when ``plugins_dir/name`` itself is a symlink only the link
|
|
goes — the tree it points at may be another installed plugin (a dev alias to a sibling checkout),
|
|
and following it deleted that plugin plus its install metadata while the alias stayed dangling.
|
|
Config bookkeeping (aliases, toolset) is gathered before the tree disappears.
|
|
"""
|
|
link = plugins_dir / name.strip("/")
|
|
if link.is_symlink():
|
|
link.unlink()
|
|
return {"ok": True, "name": name, **_pc()._forget_plugin_config({link.name})}
|
|
entry = next((e for e in _pc()._discover_all_plugins() if Path(str(e[4])) == target), None)
|
|
key = entry[5] if entry else target.name
|
|
aliases = _pc()._plugin_aliases(key) | {target.name}
|
|
if _pc()._read_manifest(target).get("provides_tools"):
|
|
_pc()._toggle_plugin_toolset(key, enable=False)
|
|
_remove_plugin_core(target)
|
|
return {"ok": True, "name": name, **_pc()._forget_plugin_config(aliases)}
|
|
|
|
|
|
def dashboard_remove_user_plugin(name: str) -> dict[str, Any]:
|
|
"""Delete a plugin tree under ``~/.hermes/plugins/`` only."""
|
|
plugins_dir = _pc()._plugins_dir()
|
|
if any(n == name and src == "bundled" for n, _ver, _d, src, _path, _key in _pc()._discover_all_plugins()):
|
|
return {"ok": False, "error": "Bundled plugins cannot be removed from the dashboard."}
|
|
target = _pc()._user_installed_plugin_dir(name)
|
|
if target is None:
|
|
return {"ok": False, "error": f"Plugin '{name}' was not found under {plugins_dir}."}
|
|
try:
|
|
return _remove_user_plugin(plugins_dir, name, target)
|
|
except (OSError, _pc().PluginOperationError) as exc:
|
|
return {"ok": False, "error": f"Could not remove plugin '{name}': {exc}"}
|