diff --git a/hermes_cli/plugins_admission.py b/hermes_cli/plugins_admission.py index 38f296ef13..422234f136 100644 --- a/hermes_cli/plugins_admission.py +++ b/hermes_cli/plugins_admission.py @@ -9,6 +9,28 @@ class AdmissionRefused(RuntimeError): """The candidate set was refused; config and environment untouched.""" +def candidate_member_dirs( + candidate_enabled: Iterable[str], + candidate_disabled: Iterable[str] = (), + *, + active_plugins_dir: Optional[Path] = None, + extra_dirs: Iterable[Path] = (), +) -> list[Path]: + """Shipped callers' member-list adapter; new discovery belongs to PM. + + Without an active plugins dir, preserve every home's recorded selection. + """ + from pm.publication import candidate_members + + active = Path(active_plugins_dir) if active_plugins_dir else None + return candidate_members( + extra_dirs, + proposed_home=active.parent if active else None, + enabled=candidate_enabled, + disabled=candidate_disabled, + ) + + def admit_plugin_set_change( candidate_enabled: set, candidate_disabled: set, diff --git a/hermes_cli/plugins_transaction.py b/hermes_cli/plugins_transaction.py index a45aea3ddb..c7dc6644f1 100644 --- a/hermes_cli/plugins_transaction.py +++ b/hermes_cli/plugins_transaction.py @@ -5,6 +5,13 @@ from pathlib import Path import shutil +def recover_plugin_publication(project: Path, row: dict, journal: Path) -> None: + """Recover a shipped caller's row through the stdlib boot-journal owner.""" + from hermes_cli.runtime_state import _recover_plugin_publication + + _recover_plugin_publication(project, row, journal) + + def publish_plugin(staged: Path, target: Path, old_metadata: dict, new_metadata: dict, *, target_digest: str | None = None) -> None: from pm.client import sync_venv diff --git a/tests/compat/old_updater_surface.json b/tests/compat/old_updater_surface.json index 07a4a1857f..86ab08f07e 100644 --- a/tests/compat/old_updater_surface.json +++ b/tests/compat/old_updater_surface.json @@ -640,7 +640,7 @@ "hermes_cli/auth.py", "hermes_cli/backup.py", "hermes_cli/backup_restore.py", - "hermes_cli/banner.py", + "hermes_cli/backup_sqlite.py", "hermes_cli/cli_output.py", "hermes_cli/colors.py", "hermes_cli/config.py", @@ -695,6 +695,7 @@ "hermes_cli/service_manager.py", "hermes_cli/setup.py", "hermes_cli/sizefmt.py", + "hermes_cli/source_check.py", "hermes_cli/source_releases.py", "hermes_cli/sqlite_runtime.py", "hermes_cli/sqlite_safe_read.py", @@ -752,6 +753,7 @@ "pm/packages.py", "pm/paths.py", "pm/plugins_state.py", + "pm/publication.py", "pm/receipt.py", "pm/recovery.py", "pm/registry.py", @@ -819,7 +821,7 @@ "hermes_cli/auth.py", "hermes_cli/backup.py", "hermes_cli/backup_restore.py", - "hermes_cli/banner.py", + "hermes_cli/backup_sqlite.py", "hermes_cli/cli_output.py", "hermes_cli/colors.py", "hermes_cli/config.py", @@ -873,6 +875,7 @@ "hermes_cli/service_manager.py", "hermes_cli/setup.py", "hermes_cli/sizefmt.py", + "hermes_cli/source_check.py", "hermes_cli/source_releases.py", "hermes_cli/sqlite_runtime.py", "hermes_cli/sqlite_safe_read.py", @@ -925,6 +928,7 @@ "pm/packages.py", "pm/paths.py", "pm/plugins_state.py", + "pm/publication.py", "pm/receipt.py", "pm/recovery.py", "pm/registry.py", @@ -949,10 +953,10 @@ "utils.py" ], "commits_with_audited_changes": 1, - "revisions_read": 173, - "distinct_file_versions": 173, - "analysis_passes": 215, - "versions_prepared": 173, + "revisions_read": 175, + "distinct_file_versions": 175, + "analysis_passes": 218, + "versions_prepared": 175, "mode": "tree" }, "completion_union": "Preserved complete checked-in history and prior tree edges; unioned fresh current-tree audit." @@ -1034,6 +1038,7 @@ "hermes_cli._launchers::ENTRY_POINTS", "hermes_cli._launchers::ensure_install_launchers", "hermes_cli._launchers::exe_is_venv_bound", + "hermes_cli._launchers::expose_cli", "hermes_cli._launchers::installation_command", "hermes_cli._launchers::mint_launcher", "hermes_cli._launchers::resolve_store_python", @@ -1044,6 +1049,7 @@ "hermes_cli._scan_venv_blockers::_is_pausable_gateway", "hermes_cli._subprocess_compat::bounded_probe_run", "hermes_cli._subprocess_compat::kill_process_tree", + "hermes_cli._subprocess_compat::noninteractive_git_env", "hermes_cli._subprocess_compat::pid_is_hermes", "hermes_cli._subprocess_compat::run", "hermes_cli._subprocess_compat::split_command_line", @@ -1265,11 +1271,14 @@ "hermes_cli.runtime_state::_digest", "hermes_cli.runtime_state::_lock", "hermes_cli.runtime_state::collect_generations", + "hermes_cli.runtime_state::finish_publication", "hermes_cli.runtime_state::recover_publication", "hermes_cli.runtime_state::runtime_lock", "hermes_cli.service_manager::detect_service_manager", "hermes_cli.setup::prompt_yes_no", "hermes_cli.sizefmt::format_bytes", + "hermes_cli.source_check::_github_compare_behind", + "hermes_cli.source_check::source_git_env", "hermes_cli.source_releases::resolve_source_release", "hermes_cli.source_releases::source_repository", "hermes_cli.sqlite_runtime::probe_sqlite_runtime", @@ -1474,6 +1483,9 @@ "pm.paths::writable_store_root", "pm.plugins_state::_is_directory", "pm.plugins_state::enabled_plugins_ordered", + "pm.publication::PluginSelection", + "pm.publication::StagedPlugin", + "pm.publication::candidate_members", "pm.recovery::validate_environment", "pm.registry::all_packages", "pm.registry::get_package", diff --git a/tests/test_plugin_historic_compat.py b/tests/test_plugin_historic_compat.py new file mode 100644 index 0000000000..f698133490 --- /dev/null +++ b/tests/test_plugin_historic_compat.py @@ -0,0 +1,178 @@ +"""Shipped plugin callers keep their contract without owning PM publication.""" +from __future__ import annotations + +import base64 +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + + +@pytest.fixture +def isolated_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +def _plugin(home, name, *, dependencies=True): + path = home / "plugins" / name + path.mkdir(parents=True) + (path / "plugin.yaml").write_text(f"name: {name}\n", encoding="utf-8") + if dependencies: + (path / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + return path + + +@pytest.mark.parametrize("active", [None, "default", "profile"]) +def test_candidate_member_dirs_preserves_proposed_home_order_and_extras(isolated_home, monkeypatch, active): + from hermes_cli import plugins_admission + + assert callable(getattr(plugins_admission, "candidate_member_dirs", None)) + home = isolated_home + profile = home / "profiles" / "coder" + profile.mkdir(parents=True) + for directory in (home, profile): + (directory / "config.yaml").write_text("plugins:\n enabled: [old]\n", encoding="utf-8") + _plugin(directory, "old") + _plugin(directory, "new") + _plugin(directory, "blocked") + _plugin(directory, "plain", dependencies=False) + extra = _plugin(home, "extra") + selected_home = profile if active == "profile" else home + expected = [home / "plugins" / "old", profile / "plugins" / "old"] + if active is not None: + expected[active == "profile"] = selected_home / "plugins" / "new" + extra_existing = expected[0] + before = {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} + # Discovery must use PM's independent manifest reader, not application config/UI. + monkeypatch.setitem(sys.modules, "hermes_cli.config", None) + monkeypatch.setitem(sys.modules, "hermes_cli.plugins_cmd", None) + result = plugins_admission.candidate_member_dirs( + iter(["new", "blocked", "plain", "new"]), iter(["blocked"]), + active_plugins_dir=str(selected_home / "plugins") if active else None, + extra_dirs=iter([str(extra_existing), str(extra), str(extra), home / "plugins" / "plain", home / "missing"]), + ) + assert isinstance(result, list) + assert result == [*expected, extra] + assert {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} == before + + +def test_candidate_member_dirs_historical_defaults_do_not_replace_active_selection(isolated_home): + from hermes_cli import plugins_admission + + assert callable(getattr(plugins_admission, "candidate_member_dirs", None)) + old = _plugin(isolated_home, "old") + _plugin(isolated_home, "new") + (isolated_home / "config.yaml").write_text("plugins:\n enabled: [old]\n", encoding="utf-8") + assert plugins_admission.candidate_member_dirs(["new"]) == [old] + + +@pytest.fixture +def publication(isolated_home, tmp_path): + from hermes_cli.runtime_paths import install_state_dir + + project = tmp_path / "checkout" + project.mkdir() + target = _plugin(isolated_home, "published") + (target / "version").write_bytes(b"new code") + backup = _plugin(isolated_home, ".previous-historic") + (backup / "version").write_bytes(b"old code") + metadata = target.parent / ".install-metadata.json" + metadata.write_bytes(b'{"published": "new"}\n') + # Old callers pass the already-decoded row and exact journal path. The + # adapter must not rediscover/reparse a different current journal. + journal = tmp_path / "historic-publication.json" + journal.write_bytes(b"already decoded by the old caller") + canonical = install_state_dir(project) / "publication.json" + canonical.parent.mkdir(parents=True) + canonical.write_bytes(b"unrelated publication; do not read or remove") + row = { + "kind": "plugin", "target": str(target), "backup": str(backup), + "metadata": str(metadata), "target_existed": True, "facts_before": None, + "metadata_before": base64.b64encode(b'{"published": "old"}\n').decode(), + "metadata_after": base64.b64encode(metadata.read_bytes()).decode(), + } + return project, row, journal, canonical + + +def _recover_in_stdlib(publication): + project, row, journal, _ = publication + script = """ +import json +from pathlib import Path +import sys +sys.path.insert(0, sys.argv[1]) +# No selected application environment, no PM, and no CLI config/UI reader. +for module in ('pm', 'hermes_cli.config', 'hermes_cli.plugins_cmd'): + sys.modules[module] = None +from hermes_cli import plugins_transaction +assert callable(getattr(plugins_transaction, 'recover_plugin_publication', None)) +row = json.loads(sys.stdin.read()) +plugins_transaction.recover_plugin_publication( + project=Path(sys.argv[2]), row=row, journal=Path(sys.argv[3]), +) +""" + return subprocess.run( + [sys.executable, "-I", "-S", "-c", script, str(Path(__file__).resolve().parents[1]), str(project), str(journal)], + input=json.dumps(row), text=True, capture_output=True, check=False, + env={**os.environ, "HERMES_HOME": str(Path(row["metadata"]).parent.parent)}, + ) + + +@pytest.mark.parametrize("commit", ["rollback", "explicit", "facts-changed"]) +def test_old_publication_recovers_in_stdlib_using_supplied_row_and_journal(publication, commit): + from hermes_cli.runtime_paths import runtime_facts_path + + project, row, journal, canonical = publication + target, backup, metadata = (Path(row[key]) for key in ("target", "backup", "metadata")) + if commit == "explicit": + row["committed"] = True + elif commit == "facts-changed": + runtime_facts_path(project).write_bytes(b"new facts") + result = _recover_in_stdlib(publication) + assert result.returncode == 0, result.stderr + assert (target / "version").read_bytes() == (b"old code" if commit == "rollback" else b"new code") + expected = row["metadata_before"] if commit == "rollback" else row["metadata_after"] + assert metadata.read_bytes() == base64.b64decode(expected) + assert not backup.exists() + assert not journal.exists() + assert canonical.read_bytes() == b"unrelated publication; do not read or remove" + + +def test_old_publication_rolls_back_a_first_install(publication): + from hermes_cli import plugins_transaction + import shutil + + assert callable(getattr(plugins_transaction, "recover_plugin_publication", None)) + project, row, journal, _ = publication + shutil.rmtree(row["backup"]) + row.update(target_existed=False, metadata_before=None) + plugins_transaction.recover_plugin_publication(project, row, journal) + assert not Path(row["target"]).exists() + assert not Path(row["metadata"]).exists() + assert not journal.exists() + + +@pytest.mark.parametrize("invalid", ["target", "backup", "metadata", "edited-metadata"]) +def test_old_publication_refuses_unsafe_or_changed_state_without_writes(publication, tmp_path, invalid): + from hermes_cli import plugins_transaction + + assert callable(getattr(plugins_transaction, "recover_plugin_publication", None)) + project, row, journal, _ = publication + if invalid == "edited-metadata": + Path(row["metadata"]).write_bytes(b"independent user edit") + else: + outside = tmp_path / "outside" / "plugins" + outside.mkdir(parents=True) + row[invalid] = str(outside / Path(row[invalid]).name) + before = {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} + with pytest.raises(ValueError, match="paths escape|metadata changed"): + plugins_transaction.recover_plugin_publication(project, row, journal) + assert {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} == before + assert journal.exists()