build_deb.sh wrote HERMES_DESKTOP_VARIANT=bundled, so the Termux stamp carried payload=bundled and every 'bundled' reader treated the tree as the repo/ of an Electron payload. hermes uninstall --data then called resolve_bundle_layout on it and rejected the plan: the deb has no enclosing app to protect, so data-only removal was impossible on Termux. Add a 'runtime' variant to write_install_stamp.py for a sealed CLI runtime with no desktop app around it. It stays sealed for the update gate and channel identity (source_check, update_channel accept it next to bundled/light) while is_bundled_payload keeps answering False, so cleanup planning protects the APT-owned package tree the ordinary way and never asks where the app is.
159 lines
7.0 KiB
Python
159 lines
7.0 KiB
Python
"""Data deletion respects declared runtime ownership, confirmation and scope."""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.uninstall as uninstall
|
|
|
|
|
|
@pytest.fixture
|
|
def layout(tmp_path, monkeypatch):
|
|
user = tmp_path / "user"
|
|
user.mkdir()
|
|
home = user / "hermes-data"
|
|
source = home / "workspace" / "custom-source"
|
|
store = home / "machine" / "tool-store"
|
|
userdata = user / "desktop-data"
|
|
witnesses = [source / "hermes_cli" / "__init__.py", store / "python" / "python.exe",
|
|
home / "installs" / "other-install" / "facts.json", home / "bin" / "hermes.cmd",
|
|
home / "profiles" / "sibling" / "config.yaml",
|
|
home / "cache" / "partials" / ".locks" / "other-profile-transfer"]
|
|
for path in witnesses:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("keep", encoding="utf-8")
|
|
data = [home / "config.yaml", home / "sessions" / "session.json", home / "workspace" / "notes.txt",
|
|
home / ".env", home / "logs" / "file"]
|
|
for path in data:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("{}" if path.suffix in {".yaml", ".json"} else "user data", encoding="utf-8")
|
|
monkeypatch.setattr(Path, "home", lambda: user)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store))
|
|
monkeypatch.setattr(uninstall, "get_project_root", lambda: source)
|
|
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: home)
|
|
monkeypatch.setattr("hermes_cli.gui_uninstall.desktop_userdata_dir", lambda: userdata)
|
|
return home, witnesses, data
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["confirmed", "cancel", "dry-run"])
|
|
def test_data_only_preserves_runtime_and_sibling_homes(layout, monkeypatch, mode):
|
|
import json
|
|
from pm.environments import install_state_dir, runtime_facts_path, selected_venv
|
|
|
|
monkeypatch.delattr(Path, "is_junction", raising=False)
|
|
home, witnesses, data = layout
|
|
source = uninstall.get_project_root()
|
|
generation = install_state_dir(source) / "environments" / "selected"
|
|
generation.mkdir(parents=True)
|
|
(generation / "pyvenv.cfg").write_text("include-system-site-packages = false", encoding="utf-8")
|
|
runtime_facts_path(source).write_text(json.dumps({"packages": {"venv": {"environment": str(generation)}}}), encoding="utf-8")
|
|
monkeypatch.setattr("builtins.input", lambda *args: "no")
|
|
uninstall.run_data_uninstall(SimpleNamespace(yes=mode != "cancel", dry_run=mode == "dry-run"))
|
|
assert selected_venv(source) == generation
|
|
assert all(path.read_text(encoding="utf-8") == "keep" for path in witnesses)
|
|
assert all(path.exists() is (mode != "confirmed") for path in data)
|
|
assert home.is_dir()
|
|
|
|
|
|
def test_data_only_reports_a_failed_removal(layout, monkeypatch, capsys):
|
|
home, witnesses, _ = layout
|
|
real = uninstall.shutil.rmtree
|
|
|
|
def refuse(path, *args, **kwargs):
|
|
if Path(path) == home / "sessions":
|
|
raise PermissionError("fixture holds sessions")
|
|
return real(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(uninstall.shutil, "rmtree", refuse)
|
|
with pytest.raises(SystemExit) as failure:
|
|
uninstall.run_data_uninstall(SimpleNamespace(yes=True, dry_run=False))
|
|
assert failure.value.code != 0
|
|
output = capsys.readouterr().out
|
|
assert "Hermes data removed" not in output
|
|
assert str(home / "sessions") in output
|
|
assert all(path.exists() for path in witnesses)
|
|
|
|
|
|
def test_named_home_does_not_erase_siblings_or_desktop_data(layout, monkeypatch):
|
|
home, witnesses, _ = layout
|
|
active = home / "profiles" / "active"
|
|
active.mkdir()
|
|
config = active / "config.yaml"
|
|
config.write_text("model: selected", encoding="utf-8")
|
|
userdata = home.parent / "desktop-data"
|
|
userdata.mkdir()
|
|
(userdata / "preferences.json").write_text("keep", encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(active))
|
|
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: active)
|
|
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
|
|
assert not config.exists()
|
|
assert all(path.exists() for path in witnesses)
|
|
assert (userdata / "preferences.json").read_text(encoding="utf-8") == "keep"
|
|
|
|
|
|
def test_directory_replaced_with_a_link_does_not_expand_removal(layout, tmp_path):
|
|
from hermes_cli.data_cleanup import plan_data_removal, remove_data
|
|
|
|
home, _, _ = layout
|
|
original = home / "workspace"
|
|
foreign = tmp_path / "foreign"
|
|
foreign.mkdir()
|
|
witness = foreign / "notes.txt"
|
|
witness.write_text("not Hermes data", encoding="utf-8")
|
|
probe = tmp_path / "symlink-probe"
|
|
try:
|
|
probe.symlink_to(foreign, target_is_directory=True)
|
|
except OSError as exc:
|
|
pytest.skip(f"native symlink creation unavailable: {exc}")
|
|
probe.unlink()
|
|
plan = plan_data_removal(home, uninstall.get_project_root())
|
|
original.rename(home / "workspace-moved")
|
|
original.symlink_to(foreign, target_is_directory=True)
|
|
_, failures = remove_data(plan)
|
|
assert failures
|
|
assert witness.read_text(encoding="utf-8") == "not Hermes data"
|
|
|
|
|
|
def test_data_only_preserves_the_containing_bundled_application(layout, monkeypatch):
|
|
import json
|
|
from hermes_cli.bundled_app import PAYLOAD_DIR_NAME
|
|
|
|
home, _, data = layout
|
|
app = home / "installed-app"
|
|
payload = app / "resources" / PAYLOAD_DIR_NAME
|
|
project = payload / "repo"
|
|
project.mkdir(parents=True)
|
|
(project / "install-stamp.json").write_text(json.dumps({"payload": "bundled"}), encoding="utf-8")
|
|
shell = app / "Hermes.exe"
|
|
shell.write_bytes(b"retained app bytes")
|
|
(payload / "venv").mkdir()
|
|
manifest = payload / "manifest.json"
|
|
manifest.write_text(json.dumps({"repo": "repo", "venv": "venv"}), encoding="utf-8")
|
|
monkeypatch.setattr(uninstall, "get_project_root", lambda: project)
|
|
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
|
|
assert shell.read_bytes() == b"retained app bytes"
|
|
assert manifest.is_file()
|
|
assert all(not path.exists() for path in data)
|
|
|
|
|
|
def test_data_only_works_from_a_self_contained_runtime_without_an_app(layout, monkeypatch):
|
|
"""A Termux-shaped runtime (sealed, APT-owned, no Electron app around it)
|
|
must plan a data-only removal instead of hunting for a desktop app."""
|
|
import json
|
|
|
|
home, _, data = layout
|
|
package = home.parent / "usr" / "lib" / "hermes-agent"
|
|
project = package / "app"
|
|
(project / "hermes_cli").mkdir(parents=True)
|
|
(project / "hermes_cli" / "__init__.py").write_text("", encoding="utf-8")
|
|
(project / "install-stamp.json").write_text(json.dumps(
|
|
{"payload": "runtime", "distribution": "apt-termux", "updateMechanism": "external"}), encoding="utf-8")
|
|
(package / "venv").mkdir()
|
|
monkeypatch.setattr(uninstall, "get_project_root", lambda: project)
|
|
uninstall.run_data_uninstall(SimpleNamespace(yes=True))
|
|
assert all(not path.exists() for path in data)
|
|
assert (project / "install-stamp.json").is_file() and (package / "venv").is_dir()
|