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
This commit is contained in:
itsflownium
2026-10-02 17:18:59 +00:00
committed by unsupportedpastels
parent 049028e0f1
commit 2eadea653a
4 changed files with 78 additions and 1 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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.
:::