From 2eadea653af2b5156c0891be32a2a508ee786a5f Mon Sep 17 00:00:00 2001 From: itsflownium <157689911+itsflownium@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:18:59 +0000 Subject: [PATCH] fix(cli): let uninstall dry-runs run without a TTY and list the install record A dry run never prompts, but plain `hermes uninstall --dry-run` still went through the interactive-terminal gate and was refused when stdin was not a TTY. Exempt --dry-run from that gate, matching --gui and --data. The GUI removal plan also omitted desktop-installed-apps.json, which uninstall_gui() deletes. List it under "Will remove" so the preview matches what a real run removes. Adds a CLI-level regression test covering `--gui --dry-run` with and without --yes, and plain `uninstall --dry-run`, asserting nothing is prompted or removed. Refs: #128974 Salvaged-from: #131147 --- hermes_cli/main.py | 2 +- hermes_cli/uninstall.py | 3 + .../test_gui_uninstall_retention.py | 72 +++++++++++++++++++ website/docs/user-guide/desktop.md | 2 + 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_gui_uninstall_retention.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 140a437b1e..467836a870 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2369,7 +2369,7 @@ def cmd_uninstall(args): run_gui_uninstall(args) return - if not getattr(args, "yes", False): + if not getattr(args, "yes", False) and not getattr(args, "dry_run", False): _require_tty("uninstall") from hermes_cli.uninstall import run_uninstall diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index ddec1ea357..490373221e 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -695,6 +695,7 @@ def run_gui_uninstall(args): """ from hermes_cli.gui_uninstall import ( agent_is_installed, + desktop_install_record, gui_install_summary, uninstall_gui, ) @@ -719,6 +720,8 @@ def run_gui_uninstall(args): print(color("Will remove:", Colors.YELLOW, Colors.BOLD)) for p in (*summary["source_built_artifacts"], *summary["packaged_app_paths"]): print(f" • {p}") + if (install_record := desktop_install_record()).exists(): + print(f" • {install_record} (desktop install record)") if summary["userdata_exists"]: print(f" • {summary['userdata_dir']} (desktop app data)") print() diff --git a/tests/hermes_cli/test_gui_uninstall_retention.py b/tests/hermes_cli/test_gui_uninstall_retention.py new file mode 100644 index 0000000000..ea722b195d --- /dev/null +++ b/tests/hermes_cli/test_gui_uninstall_retention.py @@ -0,0 +1,72 @@ +"""Regression for #128974: GUI dry-run and GUI removal preserve shared state.""" + +import sys +from pathlib import Path + +import pytest + +from hermes_cli import gui_uninstall, main +from hermes_constants import get_hermes_home + + +@pytest.fixture +def installation(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + home = get_hermes_home() + assert home.resolve().is_relative_to(tmp_path.resolve()) + root = home / "hermes-agent" + protected = { + root / "hermes_cli" / "__init__.py": b"# agent source\n", + root / "venv" / "runtime": b"agent runtime", + root / "node_modules" / "shared-package" / "index.js": b"shared workspace dependency", + home / "config.yaml": b"model: fixture-model\n", + home / ".env": b"FIXTURE_TOKEN=fake\n", + home / "sessions" / "saved.json": b'{"saved": true}', + } + record = tmp_path / "desktop-installed-apps.json" + bundle = tmp_path / "apps" / "Hermes.app" + userdata = tmp_path / "desktop-data" + gui_files = { + root / "apps" / "desktop" / "dist" / "index.html": b"renderer", + root / "apps" / "desktop" / "release" / "artifact": b"release", + root / "apps" / "desktop" / "node_modules" / "private-module": b"desktop dependency", + home / "desktop-build-stamp.json": b'{"fixture": true}', + bundle / "app": b"desktop app", + userdata / "connections.json": b'{"fixture": true}', + record: b'{"fixture": true}', + } + for path, data in {**protected, **gui_files}.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + monkeypatch.setattr(gui_uninstall, "packaged_gui_app_paths", lambda: [bundle]) + monkeypatch.setattr(gui_uninstall, "desktop_userdata_dir", lambda: userdata) + monkeypatch.setattr(gui_uninstall, "desktop_install_record", lambda: record) + return protected, gui_files, record + + +def _run(monkeypatch, *args): + monkeypatch.setattr(sys, "argv", ["hermes", "uninstall", "--gui", *args]) + return main.main() + + +def test_gui_dry_run_never_confirms_or_removes_installed_state(installation, monkeypatch, capsys): + protected, gui_files, record = installation + + def unexpected_input(*args): + pytest.fail("A dry-run must not request removal confirmation") + + monkeypatch.setattr("builtins.input", unexpected_input) + for flags in (("--yes",), ()): + _run(monkeypatch, "--dry-run", *flags) + for path, data in {**protected, **gui_files}.items(): + assert path.read_bytes() == data + output = capsys.readouterr().out + assert "Dry run" in output + assert str(record) in output + assert "Uninstalled!" not in output + + # The sibling full-agent preview must also work without an interactive terminal. + monkeypatch.setattr(sys, "argv", ["hermes", "uninstall", "--dry-run"]) + main.main() + for path, data in {**protected, **gui_files}.items(): + assert path.read_bytes() == data diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 2767f21482..96351c9e8d 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -388,6 +388,8 @@ These controls are hidden for Nix, bundled/Light packages, and other externally For self-managed installations, you can do the same from the terminal — `hermes uninstall --gui` for the GUI alone, or `hermes uninstall` / `hermes uninstall --full` for the agent too. +Preview GUI cleanup with `hermes uninstall --gui --dry-run`. It lists the removal targets and returns without prompting or removing anything, including when `--yes` is also supplied. + :::note Running `hermes uninstall --gui` from a **source checkout** (a `hermes desktop` dev build) also removes the workspace `node_modules` and `apps/desktop/{dist,release}` build output, since those are GUI build artifacts. They're recoverable with `hermes desktop` (or `npm install` + a rebuild) — but if you're actively hacking on the desktop app, expect to reinstall dependencies afterward. :::