From 8bbca1a87278cc69b3aa259e368202d18c0c7309 Mon Sep 17 00:00:00 2001 From: pleb Date: Thu, 9 Jul 2026 20:04:10 -0700 Subject: [PATCH] Track install state by installation id --- README.md | 2 +- docs/review-07.09.2026.md | 8 +- docs/{ROADMAP.md => roadmap-01.md} | 2 +- docs/roadmap-02.md | 9 ++ src/plugin_helper/bootstrap.py | 18 ++-- src/plugin_helper/bsipa.py | 14 ++- src/plugin_helper/cli.py | 58 ++++++++---- src/plugin_helper/config.py | 12 ++- src/plugin_helper/installer.py | 39 +++++--- src/plugin_helper/operations.py | 28 ++++-- src/plugin_helper/planner.py | 13 ++- src/plugin_helper/state.py | 120 ++++++++++++++++++------- src/plugin_helper/tui.py | 19 ++-- src/plugin_helper/update_runner.py | 2 + src/plugin_helper/userdata.py | 12 ++- tests/test_plugin_helper.py | 137 ++++++++++++++++++++++++++--- 16 files changed, 382 insertions(+), 111 deletions(-) rename docs/{ROADMAP.md => roadmap-01.md} (99%) create mode 100644 docs/roadmap-02.md diff --git a/README.md b/README.md index f1f2177..fe5ad2b 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ the configured state directory for one command. Install assets are currently expected to already exist locally, usually under: ```text -/instances//downloads// +/cache/downloads/// ``` Use `updates` to audit the version lock against obvious public update sources: diff --git a/docs/review-07.09.2026.md b/docs/review-07.09.2026.md index a1f73fc..bafee84 100644 --- a/docs/review-07.09.2026.md +++ b/docs/review-07.09.2026.md @@ -1,10 +1,6 @@ **Findings** -- High: install state is still keyed by instance name, not a concrete installation. [state.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/state.py:10) writes everything under `instances/`, and the TUI gives every discovered root the same `runtime.state_root` in [cli.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/cli.py:279). That is fine only if Linux and Windows installs never share a state dir. For package-manager ambitions, introduce an `install_id` derived from profile/root/path, and split reusable asset cache from per-install state. - -- High: `apply_plan` is not transactional. It backs up and writes files one by one, then saves state at the end in [installer.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/installer.py:39). If extraction/copy fails mid-plan, the game tree can be half-mutated without matching install state. A future package manager wants transactions/generations: stage, apply, record transaction, and have a first-class rollback command. - -- Medium-high: installed state records files but not the installed package identity. [installer.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/installer.py:63) stores `path`, `sha256`, and `size`, but not repo/tag/asset/source URL/source hash/reason. The report then uses the current lockfile to describe installed versions, which can drift after a lock update. Record the lock identity into install state at apply time. +- Resolved: install state is now keyed by concrete `install_id`, and reusable plugin assets live under the shared download cache instead of per-install state. - Medium-high: dependency handling is intentionally thin. `Dependency.constraint` exists in [models.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/models.py:10), but [planner.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/planner.py:68) only expands required dependency IDs and does not enforce constraints, conflicts, `loadAfter`, `loadBefore`, BeatMods version-id closures, or “provides” relationships. That is the next big unlock for package-manager behavior. @@ -41,4 +37,4 @@ The next shape I’d aim for is: Beat Saber plugins are heterogeneous in packaging, but homogeneous enough in runtime shape (`Plugins`, `Libs`, `IPA/Pending`, root BSIPA files) that this can become a nice small package manager. The trick is to normalize artifacts into a planned file tree before touching the game. -Validation: `compileall` passed, and `unittest discover` passed: 75 tests, 1 skipped. \ No newline at end of file +Validation: `compileall` passed, and `unittest discover` passed: 75 tests, 1 skipped. diff --git a/docs/ROADMAP.md b/docs/roadmap-01.md similarity index 99% rename from docs/ROADMAP.md rename to docs/roadmap-01.md index 2762874..cc0c9ea 100644 --- a/docs/ROADMAP.md +++ b/docs/roadmap-01.md @@ -1,4 +1,4 @@ -# plugin-helper Roadmap +# Roadmap 1 This roadmap tracks ideas that are useful but not part of the first safe CLI slice. diff --git a/docs/roadmap-02.md b/docs/roadmap-02.md new file mode 100644 index 0000000..c4fc9fd --- /dev/null +++ b/docs/roadmap-02.md @@ -0,0 +1,9 @@ +# Roadmap 2 + +## Package Manager Features + +From a review: + +- High: `apply_plan` is not transactional. It backs up and writes files one by one, then saves state at the end in [installer.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/installer.py:39). If extraction/copy fails mid-plan, the game tree can be half-mutated without matching install state. A future package manager wants transactions/generations: stage, apply, record transaction, and have a first-class rollback command. + +- Medium-high: installed state records files but not the installed package identity. [installer.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/installer.py:63) stores `path`, `sha256`, and `size`, but not repo/tag/asset/source URL/source hash/reason. The report then uses the current lockfile to describe installed versions, which can drift after a lock update. Record the lock identity into install state at apply time. diff --git a/src/plugin_helper/bootstrap.py b/src/plugin_helper/bootstrap.py index a8d74e7..163a4be 100644 --- a/src/plugin_helper/bootstrap.py +++ b/src/plugin_helper/bootstrap.py @@ -215,6 +215,7 @@ def run_bootstrap( native: bool | None = None, progress: Callable[[str], None] | None = None, ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS, + install_id: str | None = None, ) -> dict[str, Any]: tell = progress or (lambda _message: None) use_native = is_windows() if native is None else native @@ -240,6 +241,7 @@ def run_bootstrap( repo_root=repo_root, selected={BSIPA_PLUGIN_ID}, require_bootstrap=False, + install_id=install_id, ) if not plan["changes"]: raise ValueError("BSIPA bootstrap plan has no changes") @@ -292,12 +294,14 @@ def run_bootstrap( "delta": delta, "health": {}, } + if install_id: + state["installId"] = install_id if not use_native: state["proton"] = str(proton or _default_proton()) - save_bootstrap_state(state_root, instance, state) - state["health"] = check_bsipa_health(instance_path, state_root, instance) - save_bootstrap_state(state_root, instance, state) - state["statePath"] = str(bootstrap_state_path(state_root, instance)) + save_bootstrap_state(state_root, instance, state, install_id=install_id) + state["health"] = check_bsipa_health(instance_path, state_root, instance, install_id=install_id) + save_bootstrap_state(state_root, instance, state, install_id=install_id) + state["statePath"] = str(bootstrap_state_path(state_root, instance, install_id=install_id)) if completed["timedOut"]: raise TimeoutError(f"IPA.exe -n timed out after {ipa_timeout_seconds}s; state written to {state['statePath']}") if completed["returncode"] != 0: @@ -319,11 +323,12 @@ def ensure_healthy_bootstrap( native: bool | None = None, progress: Callable[[str], None] | None = None, ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS, + install_id: str | None = None, ) -> None: if not planning_requires_bootstrap(lockfile.plugins, selected_ids): return - health = check_bsipa_health(instance_path, state_root, instance) + health = check_bsipa_health(instance_path, state_root, instance, install_id=install_id) if health["ok"]: return @@ -341,8 +346,9 @@ def ensure_healthy_bootstrap( native=native, progress=progress, ipa_timeout_seconds=ipa_timeout_seconds, + install_id=install_id, ) - health = check_bsipa_health(instance_path, state_root, instance) + health = check_bsipa_health(instance_path, state_root, instance, install_id=install_id) if not health["ok"]: raise ValueError(bootstrap_health_error(health)) diff --git a/src/plugin_helper/bsipa.py b/src/plugin_helper/bsipa.py index aa4865d..157f8bc 100644 --- a/src/plugin_helper/bsipa.py +++ b/src/plugin_helper/bsipa.py @@ -33,8 +33,14 @@ def _native_bootstrap_satisfied(state: dict[str, Any]) -> bool: ) -def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) -> dict[str, Any]: - state = load_bootstrap_state(state_root, instance) +def check_bsipa_health( + instance_path: Path, + state_root: Path, + instance: str, + *, + install_id: str | None = None, +) -> dict[str, Any]: + state = load_bootstrap_state(state_root, instance, install_id=install_id) messages: list[str] = [] required = ["IPA.exe", "winhttp.dll"] @@ -57,12 +63,12 @@ def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) -> messages.append("Logs/_latest.log does not show BSIPA startup") if not state: - messages.append(f"missing bootstrap state: {bootstrap_state_path(state_root, instance)}") + messages.append(f"missing bootstrap state: {bootstrap_state_path(state_root, instance, install_id=install_id)}") return { "ok": not messages, "messages": messages, - "statePath": str(bootstrap_state_path(state_root, instance)), + "statePath": str(bootstrap_state_path(state_root, instance, install_id=install_id)), "logPath": str(log_path), "logSha256": sha256_file(log_path) if log_path.is_file() else None, "bootstrapRecordedAt": state.get("updatedAt"), diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index 5a31f53..8631029 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -19,7 +19,7 @@ from .operations import enable_disabled_plugin, restore_known_good_set, save_kno from .planner import create_plan from .reports import installed_plugins_report, print_installed_plugins from .scanner import scan_instance -from .state import load_installed_state +from .state import installation_id, load_installed_state from .update_runner import run_update from .updates import check_updates from .userdata import restore_windows_data_repo, sync_windows_data_repo @@ -38,6 +38,7 @@ def print_updates(report: dict[str, Any]) -> None: f"{summary.get('reviews', 0)} review, {summary.get('skipped', 0)} skipped, " f"{summary['warnings']} warnings, {summary['errors']} errors" ) + if not plugins: return @@ -63,6 +64,20 @@ def print_updates(report: dict[str, Any]) -> None: print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row))) +def _installation_id_for(runtime: Any, instance_name: str, instance_path: Path) -> str: + return installation_id( + profile_id=getattr(runtime, "profile_id", None), + root=instance_path.parent, + instance=instance_name, + instance_path=instance_path, + ) + + +def _get_installation(runtime: Any, instance_name: str) -> tuple[Any, str]: + instance = get_instance(runtime.instances_roots, instance_name) + return instance, _installation_id_for(runtime, instance_name, instance.path) + + def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None: default = argparse.SUPPRESS if suppress_default else None parser.add_argument("--config", default=default, help="plugin-helper config TOML path") @@ -277,12 +292,13 @@ def _run_menu( return 1 choices: list[InstallationChoice] = [] - for index, root in enumerate(runtime.instances_roots, start=1): + for root in runtime.instances_roots: install_label = str(root) if len(runtime.instances_roots) > 1 else "Default" for instance in list_instances(root): + install_id = _installation_id_for(runtime, instance.name, instance.path) choices.append( InstallationChoice( - install_id=f"root-{index}", + install_id=install_id, install_label=install_label, instance_name=instance.name, instance_path=instance.path, @@ -366,17 +382,19 @@ def run(argv: list[str] | None = None) -> int: return 0 if args.command == "state": - _json(load_installed_state(st_root, args.instance)) + _instance, install_id = _get_installation(runtime, args.instance) + _json(load_installed_state(st_root, args.instance, install_id=install_id)) return 0 if args.command == "installed": + instance, install_id = _get_installation(runtime, args.instance) root = repo_root() registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml" if not lock_path.is_absolute(): lock_path = (root / lock_path).resolve() result = installed_plugins_report( - installed_state=load_installed_state(st_root, args.instance), + installed_state=load_installed_state(st_root, instance.name, install_id=install_id), registry=load_registry(registry_path), lockfile=load_lockfile(lock_path), ) @@ -436,7 +454,7 @@ def run(argv: list[str] | None = None) -> int: return 2 if result["summary"]["errors"] else 0 if args.command == "update": - instance = get_instance(inst_roots, args.instance) + instance, install_id = _get_installation(runtime, args.instance) root = repo_root() registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml" @@ -456,6 +474,7 @@ def run(argv: list[str] | None = None) -> int: include_prerelease=args.include_prerelease, dry_run=args.dry_run, apply=args.apply, + install_id=install_id, ) if args.json: _json(result) @@ -480,7 +499,7 @@ def run(argv: list[str] | None = None) -> int: return 0 if not result["refused"] else 2 if args.command == "bootstrap": - instance = get_instance(inst_roots, args.instance) + instance, install_id = _get_installation(runtime, args.instance) root = repo_root() registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml" @@ -498,6 +517,7 @@ def run(argv: list[str] | None = None) -> int: proton=Path(args.proton).expanduser() if args.proton else None, native=True if args.native else None, progress=lambda message: print(f" {message}", flush=True), + install_id=install_id, ) if args.json: _json(result) @@ -517,8 +537,8 @@ def run(argv: list[str] | None = None) -> int: return 0 if result["health"]["ok"] else 2 if args.command == "bootstrap-check": - instance = get_instance(inst_roots, args.instance) - result = check_bsipa_health(instance.path, st_root, args.instance) + instance, install_id = _get_installation(runtime, args.instance) + result = check_bsipa_health(instance.path, st_root, args.instance, install_id=install_id) if args.json: _json(result) else: @@ -530,7 +550,7 @@ def run(argv: list[str] | None = None) -> int: return 0 if result["ok"] else 2 if args.command == "plan": - instance = get_instance(inst_roots, args.instance) + instance, install_id = _get_installation(runtime, args.instance) root = repo_root() registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml" @@ -548,6 +568,7 @@ def run(argv: list[str] | None = None) -> int: state_root=st_root, repo_root=root, selected=selected, + install_id=install_id, ) print(f"Wrote plan: {path}") print(f"Changes: {len(plan['changes'])}") @@ -564,8 +585,8 @@ def run(argv: list[str] | None = None) -> int: return 0 if args.command == "uninstall": - instance = get_instance(inst_roots, args.instance) - result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force) + instance, install_id = _get_installation(runtime, args.instance) + result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force, install_id=install_id) print(f"Removed: {len(result['removed'])}") if result["skipped"]: print("Skipped:") @@ -574,8 +595,8 @@ def run(argv: list[str] | None = None) -> int: return 0 if result["stateUpdated"] else 2 if args.command == "disable": - instance = get_instance(inst_roots, args.instance) - result = disable_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force) + instance, install_id = _get_installation(runtime, args.instance) + result = disable_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force, install_id=install_id) print(f"Disabled: {args.plugin}") print(f"Removed: {len(result['removed'])}") if result["skipped"]: @@ -585,7 +606,7 @@ def run(argv: list[str] | None = None) -> int: return 0 if result["stateUpdated"] else 2 if args.command == "enable": - instance = get_instance(inst_roots, args.instance) + instance, install_id = _get_installation(runtime, args.instance) progress = (lambda message: print(f" {message}", flush=True)) result = enable_disabled_plugin( instance=args.instance, @@ -595,6 +616,7 @@ def run(argv: list[str] | None = None) -> int: registry=args.registry, lockfile=args.lockfile, progress=progress, + install_id=install_id, ) print(f"Enabled: {args.plugin}") print(f"Plan: {result['planPath']}") @@ -603,7 +625,8 @@ def run(argv: list[str] | None = None) -> int: return 0 if args.command == "save-known-good": - result = save_known_good_set(instance=args.instance, state_root=st_root) + _instance, install_id = _get_installation(runtime, args.instance) + result = save_known_good_set(instance=args.instance, state_root=st_root, install_id=install_id) if args.json: _json(result) else: @@ -613,7 +636,7 @@ def run(argv: list[str] | None = None) -> int: return 0 if args.command == "restore-known-good": - instance = get_instance(inst_roots, args.instance) + instance, install_id = _get_installation(runtime, args.instance) progress = (lambda message: print(f" {message}", flush=True)) result = restore_known_good_set( instance=args.instance, @@ -622,6 +645,7 @@ def run(argv: list[str] | None = None) -> int: registry=args.registry, lockfile=args.lockfile, progress=progress, + install_id=install_id, ) if args.json: _json(result) diff --git a/src/plugin_helper/config.py b/src/plugin_helper/config.py index cc720ef..ff8e568 100644 --- a/src/plugin_helper/config.py +++ b/src/plugin_helper/config.py @@ -189,7 +189,17 @@ def resolve_runtime_config( ) -> RuntimeConfig: repo = root or repo_root() local_config, loaded_path, loaded = load_local_config(config_path_value, root=repo) - profile = _select_profile(local_config, profile_id) + explicit_runtime_paths = bool( + instances_root_value + or state_dir_value + or _env_instances_roots(repo) + or _env_state_root(repo) + ) + profile = ( + _select_profile(local_config, profile_id) + if profile_id or not explicit_runtime_paths + else None + ) resolved_instances = ( _resolve_path_list(instances_root_value, repo) diff --git a/src/plugin_helper/installer.py b/src/plugin_helper/installer.py index 4c7cd0c..66ca652 100644 --- a/src/plugin_helper/installer.py +++ b/src/plugin_helper/installer.py @@ -7,7 +7,7 @@ from typing import Any from zipfile import ZipFile from .fsutil import ensure_inside, ensure_relative, sha256_bytes, sha256_file -from .state import backups_dir, load_installed_state, save_installed_state +from .state import backups_dir, installed_state_path, load_installed_state, save_installed_state def _timestamp() -> str: @@ -26,12 +26,13 @@ def _backup_existing(instance_path: Path, backup_root: Path, rel_target: str) -> def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]: instance = plan["instance"] + install_id = plan.get("installId") instance_path = Path(plan["instancePath"]) if not instance_path.is_dir(): raise FileNotFoundError(f"instance path does not exist: {instance_path}") - backup_root = backups_dir(state_root, instance) / f"apply-{_timestamp()}" - installed_state = load_installed_state(state_root, instance) + backup_root = backups_dir(state_root, instance, install_id=install_id) / f"apply-{_timestamp()}" + installed_state = load_installed_state(state_root, instance, install_id=install_id) installed_state.setdefault("beatSaberVersion", plan.get("beatSaberVersion")) installed_state.setdefault("plugins", {}) @@ -80,12 +81,20 @@ def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]: installed_state.setdefault("disabledPlugins", {}).pop(change["plugin"], None) applied.append({"path": rel_target, "plugin": change["plugin"], "backup": backup}) - save_installed_state(state_root, instance, installed_state) - return {"applied": applied, "statePath": str(state_root / "instances" / instance / "installed.json")} + save_installed_state(state_root, instance, installed_state, install_id=install_id) + return {"applied": applied, "statePath": str(installed_state_path(state_root, instance, install_id=install_id))} -def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugin_id: str, force: bool = False) -> dict[str, Any]: - installed_state = load_installed_state(state_root, instance) +def uninstall_plugin( + instance: str, + instance_path: Path, + state_root: Path, + plugin_id: str, + force: bool = False, + *, + install_id: str | None = None, +) -> dict[str, Any]: + installed_state = load_installed_state(state_root, instance, install_id=install_id) plugin_state = installed_state.get("plugins", {}).get(plugin_id) if not plugin_state: raise KeyError(f"plugin is not recorded in managed install state: {plugin_id}") @@ -109,12 +118,20 @@ def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugi return {"removed": removed, "skipped": skipped, "stateUpdated": False} installed_state.get("plugins", {}).pop(plugin_id, None) - save_installed_state(state_root, instance, installed_state) + save_installed_state(state_root, instance, installed_state, install_id=install_id) return {"removed": removed, "skipped": skipped, "stateUpdated": True} -def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_id: str, force: bool = False) -> dict[str, Any]: - installed_state = load_installed_state(state_root, instance) +def disable_plugin( + instance: str, + instance_path: Path, + state_root: Path, + plugin_id: str, + force: bool = False, + *, + install_id: str | None = None, +) -> dict[str, Any]: + installed_state = load_installed_state(state_root, instance, install_id=install_id) plugin_state = installed_state.get("plugins", {}).get(plugin_id) if not plugin_state: if plugin_id in installed_state.get("disabledPlugins", {}): @@ -143,5 +160,5 @@ def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_ disabled_state["disabledAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") installed_state.setdefault("disabledPlugins", {})[plugin_id] = disabled_state installed_state.get("plugins", {}).pop(plugin_id, None) - save_installed_state(state_root, instance, installed_state) + save_installed_state(state_root, instance, installed_state, install_id=install_id) return {"removed": removed, "skipped": skipped, "stateUpdated": True} diff --git a/src/plugin_helper/operations.py b/src/plugin_helper/operations.py index ae28d8c..b8ef3c5 100644 --- a/src/plugin_helper/operations.py +++ b/src/plugin_helper/operations.py @@ -39,8 +39,9 @@ def enable_disabled_plugin( lockfile: str | None = None, repo: Path | None = None, progress: Callable[[str], None] | None = None, + install_id: str | None = None, ) -> dict[str, Any]: - installed_state = load_installed_state(state_root, instance) + installed_state = load_installed_state(state_root, instance, install_id=install_id) if plugin_id in installed_state.get("plugins", {}): raise KeyError(f"plugin is already enabled: {plugin_id}") @@ -65,6 +66,7 @@ def enable_disabled_plugin( repo_root=root, selected={plugin_id}, require_bootstrap=False, + install_id=install_id, ) ensure_healthy_bootstrap( @@ -77,6 +79,7 @@ def enable_disabled_plugin( repo_root=root, selected_ids={plugin_id}, progress=progress, + install_id=install_id, ) tell(f"Applying {plugin_id}") @@ -94,6 +97,7 @@ def enable_disabled_plugins( lockfile: str | None = None, repo: Path | None = None, progress: Callable[[str], None] | None = None, + install_id: str | None = None, ) -> dict[str, Any]: if not plugin_ids: return {"enabled": [], "errors": []} @@ -104,7 +108,7 @@ def enable_disabled_plugins( lockfile=lockfile, repo=repo, ) - installed_state = load_installed_state(state_root, instance) + installed_state = load_installed_state(state_root, instance, install_id=install_id) enabled_plugins = installed_state.get("plugins", {}) locked_ids = {plugin.id for plugin in loaded_lockfile.plugins} @@ -136,6 +140,7 @@ def enable_disabled_plugins( repo_root=root, selected=selected_ids, require_bootstrap=False, + install_id=install_id, ) ensure_healthy_bootstrap( @@ -148,6 +153,7 @@ def enable_disabled_plugins( repo_root=root, selected_ids=selected_ids, progress=progress, + install_id=install_id, ) tell(f"Applying {len(plannable_ids)} plugins") @@ -163,16 +169,18 @@ def enable_disabled_plugins( return {"enabled": enabled, "errors": errors} -def save_known_good_set(*, instance: str, state_root: Path) -> dict[str, Any]: +def save_known_good_set(*, instance: str, state_root: Path, install_id: str | None = None) -> dict[str, Any]: """Record the currently enabled plugin ids as the known-good set for this instance.""" - installed_state = load_installed_state(state_root, instance) + installed_state = load_installed_state(state_root, instance, install_id=install_id) plugin_ids = sorted(installed_state.get("plugins", {})) known_good = { "instance": instance, "beatSaberVersion": installed_state.get("beatSaberVersion"), "pluginIds": plugin_ids, } - save_known_good_state(state_root, instance, known_good) + if install_id: + known_good["installId"] = install_id + save_known_good_state(state_root, instance, known_good, install_id=install_id) return known_good @@ -185,14 +193,15 @@ def restore_known_good_set( lockfile: str | None = None, repo: Path | None = None, progress: Callable[[str], None] | None = None, + install_id: str | None = None, ) -> dict[str, Any]: """Disable plugins outside the saved known-good set and re-enable the ones that are missing.""" - known_good = load_known_good_state(state_root, instance) + known_good = load_known_good_state(state_root, instance, install_id=install_id) known_good_ids = set(known_good.get("pluginIds", [])) if not known_good: raise KeyError(f"no known-good set is saved for this instance: {instance}") - installed_state = load_installed_state(state_root, instance) + installed_state = load_installed_state(state_root, instance, install_id=install_id) currently_enabled = set(installed_state.get("plugins", {})) to_disable = sorted(currently_enabled - known_good_ids) @@ -200,7 +209,7 @@ def restore_known_good_set( disable_errors: list[dict[str, str]] = [] for plugin_id in to_disable: try: - result = disable_plugin(instance, instance_path, state_root, plugin_id, False) + result = disable_plugin(instance, instance_path, state_root, plugin_id, False, install_id=install_id) if result["stateUpdated"]: disabled.append(plugin_id) else: @@ -209,7 +218,7 @@ def restore_known_good_set( except Exception as exc: disable_errors.append({"plugin": plugin_id, "error": str(exc)}) - installed_state = load_installed_state(state_root, instance) + installed_state = load_installed_state(state_root, instance, install_id=install_id) currently_enabled = set(installed_state.get("plugins", {})) to_enable = sorted(known_good_ids - currently_enabled) enable_result = enable_disabled_plugins( @@ -221,6 +230,7 @@ def restore_known_good_set( lockfile=lockfile, repo=repo, progress=progress, + install_id=install_id, ) return { diff --git a/src/plugin_helper/planner.py b/src/plugin_helper/planner.py index 1bbee07..b13a652 100644 --- a/src/plugin_helper/planner.py +++ b/src/plugin_helper/planner.py @@ -10,7 +10,11 @@ from zipfile import ZipFile from .fsutil import ensure_relative, sha256_bytes, sha256_file from .models import Lockfile, Registry, VALID_STRATEGIES from .bsipa import bootstrap_health_error, check_bsipa_health, planning_requires_bootstrap -from .state import downloads_dir, plans_dir, plugin_downloads_dir +from .state import ( + downloads_dir, + plans_dir, + plugin_downloads_dir, +) ALLOWED_BSIPA_TOP_LEVEL = {"IPA", "Libs", "Plugins"} @@ -96,6 +100,7 @@ def create_plan( repo_root: Path, selected: set[str] | None = None, require_bootstrap: bool = True, + install_id: str | None = None, ) -> tuple[dict[str, Any], Path]: selected_ids = ( _expand_required_dependencies(selected, registry, lockfile) @@ -106,7 +111,7 @@ def create_plan( warnings: list[str] = [] if require_bootstrap and planning_requires_bootstrap(lockfile.plugins, selected_ids): - health = check_bsipa_health(instance_path, state_root, instance) + health = check_bsipa_health(instance_path, state_root, instance, install_id=install_id) if not health["ok"]: raise ValueError(bootstrap_health_error(health)) @@ -175,7 +180,9 @@ def create_plan( "warnings": warnings, "changes": changes, } - plan_path = plans_dir(state_root, instance) / f"plan-{_now_slug()}.json" + if install_id: + plan["installId"] = install_id + plan_path = plans_dir(state_root, instance, install_id=install_id) / f"plan-{_now_slug()}.json" with plan_path.open("w", encoding="utf-8") as handle: json.dump(plan, handle, indent=2, sort_keys=True) handle.write("\n") diff --git a/src/plugin_helper/state.py b/src/plugin_helper/state.py index 2bcab81..4b2f196 100644 --- a/src/plugin_helper/state.py +++ b/src/plugin_helper/state.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import re from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -7,49 +9,97 @@ from typing import Any from .fsutil import atomic_write_json, read_json -def instance_state_dir(state_root: Path, instance: str) -> Path: - return state_root / "instances" / instance +def _slug(value: str) -> str: + slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip()).strip("-._") + return slug[:48] or "install" -def installed_state_path(state_root: Path, instance: str) -> Path: - return instance_state_dir(state_root, instance) / "installed.json" - - -def bootstrap_state_path(state_root: Path, instance: str) -> Path: - return instance_state_dir(state_root, instance) / "bootstrap.json" - - -def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]: - return read_json( - installed_state_path(state_root, instance), - {"instance": instance, "plugins": {}}, +def installation_id( + *, + profile_id: str | None, + root: Path | None, + instance: str, + instance_path: Path, +) -> str: + resolved_root = ( + root.expanduser().resolve(strict=False) + if root + else instance_path.parent.expanduser().resolve(strict=False) ) + resolved_path = instance_path.expanduser().resolve(strict=False) + profile = profile_id or "default" + digest = hashlib.sha256( + f"{profile}\0{resolved_root}\0{resolved_path}".encode("utf-8") + ).hexdigest()[:12] + return f"{_slug(profile)}-{_slug(instance)}-{digest}" -def load_bootstrap_state(state_root: Path, instance: str) -> dict[str, Any]: - return read_json(bootstrap_state_path(state_root, instance), {}) +def instance_state_dir(state_root: Path, instance: str, *, install_id: str | None = None) -> Path: + if install_id: + return state_root / "installs" / install_id + return state_root / "installs" / _slug(instance) -def save_bootstrap_state(state_root: Path, instance: str, state: dict[str, Any]) -> None: +def installed_state_path(state_root: Path, instance: str, *, install_id: str | None = None) -> Path: + return instance_state_dir(state_root, instance, install_id=install_id) / "installed.json" + + +def bootstrap_state_path(state_root: Path, instance: str, *, install_id: str | None = None) -> Path: + return instance_state_dir(state_root, instance, install_id=install_id) / "bootstrap.json" + + +def known_good_state_path(state_root: Path, instance: str, *, install_id: str | None = None) -> Path: + return instance_state_dir(state_root, instance, install_id=install_id) / "known-good.json" + + +def load_installed_state(state_root: Path, instance: str, *, install_id: str | None = None) -> dict[str, Any]: + default = {"instance": instance, "plugins": {}} + state = read_json(installed_state_path(state_root, instance, install_id=install_id), default) + if install_id: + state.setdefault("installId", install_id) + return state + + +def load_bootstrap_state(state_root: Path, instance: str, *, install_id: str | None = None) -> dict[str, Any]: + return read_json(bootstrap_state_path(state_root, instance, install_id=install_id), {}) + + +def save_bootstrap_state( + state_root: Path, + instance: str, + state: dict[str, Any], + *, + install_id: str | None = None, +) -> None: state.setdefault("instance", instance) + if install_id: + state.setdefault("installId", install_id) state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - atomic_write_json(bootstrap_state_path(state_root, instance), state) + atomic_write_json(bootstrap_state_path(state_root, instance, install_id=install_id), state) -def save_installed_state(state_root: Path, instance: str, state: dict[str, Any]) -> None: +def save_installed_state( + state_root: Path, + instance: str, + state: dict[str, Any], + *, + install_id: str | None = None, +) -> None: state.setdefault("instance", instance) + if install_id: + state.setdefault("installId", install_id) state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - atomic_write_json(installed_state_path(state_root, instance), state) + atomic_write_json(installed_state_path(state_root, instance, install_id=install_id), state) -def plans_dir(state_root: Path, instance: str) -> Path: - path = instance_state_dir(state_root, instance) / "plans" +def plans_dir(state_root: Path, instance: str, *, install_id: str | None = None) -> Path: + path = instance_state_dir(state_root, instance, install_id=install_id) / "plans" path.mkdir(parents=True, exist_ok=True) return path def downloads_dir(state_root: Path, instance: str) -> Path: - path = instance_state_dir(state_root, instance) / "downloads" + path = state_root / "cache" / "downloads" / instance path.mkdir(parents=True, exist_ok=True) return path @@ -60,21 +110,25 @@ def plugin_downloads_dir(state_root: Path, instance: str, plugin_id: str) -> Pat return path -def backups_dir(state_root: Path, instance: str) -> Path: - path = instance_state_dir(state_root, instance) / "backups" +def backups_dir(state_root: Path, instance: str, *, install_id: str | None = None) -> Path: + path = instance_state_dir(state_root, instance, install_id=install_id) / "backups" path.mkdir(parents=True, exist_ok=True) return path -def known_good_state_path(state_root: Path, instance: str) -> Path: - return instance_state_dir(state_root, instance) / "known-good.json" +def load_known_good_state(state_root: Path, instance: str, *, install_id: str | None = None) -> dict[str, Any]: + return read_json(known_good_state_path(state_root, instance, install_id=install_id), {}) -def load_known_good_state(state_root: Path, instance: str) -> dict[str, Any]: - return read_json(known_good_state_path(state_root, instance), {}) - - -def save_known_good_state(state_root: Path, instance: str, state: dict[str, Any]) -> None: +def save_known_good_state( + state_root: Path, + instance: str, + state: dict[str, Any], + *, + install_id: str | None = None, +) -> None: state.setdefault("instance", instance) + if install_id: + state.setdefault("installId", install_id) state["savedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - atomic_write_json(known_good_state_path(state_root, instance), state) + atomic_write_json(known_good_state_path(state_root, instance, install_id=install_id), state) diff --git a/src/plugin_helper/tui.py b/src/plugin_helper/tui.py index 085a4d9..d6c09f5 100644 --- a/src/plugin_helper/tui.py +++ b/src/plugin_helper/tui.py @@ -20,7 +20,7 @@ from .operations import ( save_known_good_set, ) from .reports import installed_plugins_report -from .state import load_installed_state, load_known_good_state +from .state import instance_state_dir, load_installed_state, load_known_good_state @dataclass(frozen=True) @@ -133,6 +133,7 @@ class PluginHelperTui(App[int]): target.state_root, plugin_id, False, + install_id=target.install_id, ) if not result["stateUpdated"]: self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}") @@ -148,6 +149,7 @@ class PluginHelperTui(App[int]): plugin_id=plugin_id, repo=self.repo_root, progress=self._operation_progress, + install_id=target.install_id, ) self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.") else: @@ -190,6 +192,7 @@ class PluginHelperTui(App[int]): target.state_root, plugin_id, False, + install_id=target.install_id, ) if result["stateUpdated"]: changed += 1 @@ -225,6 +228,7 @@ class PluginHelperTui(App[int]): plugin_ids=plugin_ids, repo=self.repo_root, progress=self._operation_progress, + install_id=target.install_id, ) except Exception as exc: self._set_status(f"Could not enable plugins: {exc}") @@ -240,7 +244,11 @@ class PluginHelperTui(App[int]): if self._busy or self.mode != "plugins" or self.selected_installation is None: return target = self.selected_installation - known_good = save_known_good_set(instance=target.instance_name, state_root=target.state_root) + known_good = save_known_good_set( + instance=target.instance_name, + state_root=target.state_root, + install_id=target.install_id, + ) count = len(known_good["pluginIds"]) self._set_status(f"Saved known-good set: {count} enabled plugins.") @@ -248,7 +256,7 @@ class PluginHelperTui(App[int]): if self._busy or self.mode != "plugins" or self.selected_installation is None: return target = self.selected_installation - if not load_known_good_state(target.state_root, target.instance_name): + if not load_known_good_state(target.state_root, target.instance_name, install_id=target.install_id): self._set_status("No known-good set saved yet. Press s to save the current selection.") return self._busy = True @@ -261,6 +269,7 @@ class PluginHelperTui(App[int]): state_root=target.state_root, repo=self.repo_root, progress=self._operation_progress, + install_id=target.install_id, ) except Exception as exc: self._set_status(f"Could not restore known-good set: {exc}") @@ -298,7 +307,7 @@ class PluginHelperTui(App[int]): choice.install_label, choice.instance_name, str(choice.instance_path), - str(choice.state_root), + str(instance_state_dir(choice.state_root, choice.instance_name, install_id=choice.install_id)), ) if self.setup_hint: self._set_status(self.setup_hint) @@ -322,7 +331,7 @@ class PluginHelperTui(App[int]): try: lockfile = load_lockfile(self.repo_root / "locks" / f"{target.instance_name}.lock.toml") report = installed_plugins_report( - installed_state=load_installed_state(target.state_root, target.instance_name), + installed_state=load_installed_state(target.state_root, target.instance_name, install_id=target.install_id), registry=load_registry(self.repo_root / "registry" / "plugins"), lockfile=lockfile, ) diff --git a/src/plugin_helper/update_runner.py b/src/plugin_helper/update_runner.py index 526c39f..98a4b2d 100644 --- a/src/plugin_helper/update_runner.py +++ b/src/plugin_helper/update_runner.py @@ -91,6 +91,7 @@ def run_update( apply: bool = False, download_asset: DownloadAsset = _download_asset, apply_plan_func: ApplyPlan = apply_plan, + install_id: str | None = None, ) -> dict[str, Any]: if not selected: raise ValueError("update requires at least one --plugin") @@ -224,6 +225,7 @@ def run_update( state_root=state_root, repo_root=repo_root, selected=set(replacements), + install_id=install_id, ) result["planPath"] = str(plan_path) diff --git a/src/plugin_helper/userdata.py b/src/plugin_helper/userdata.py index 180930c..11da975 100644 --- a/src/plugin_helper/userdata.py +++ b/src/plugin_helper/userdata.py @@ -36,13 +36,19 @@ DEFAULT_BACKUP_EXCLUDES = ( ) -def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]: +def backup_userdata( + instance: str, + instance_path: Path, + state_root: Path, + *, + install_id: str | None = None, +) -> dict[str, Any]: source = instance_path / "UserData" if not source.is_dir(): raise FileNotFoundError(f"UserData directory not found: {source}") created_at = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - destination = backups_dir(state_root, instance) / f"userdata-{created_at}.tar.gz" + destination = backups_dir(state_root, instance, install_id=install_id) / f"userdata-{created_at}.tar.gz" files: list[dict[str, Any]] = [] total_size = 0 for path in sorted(item for item in source.rglob("*") if item.is_file()): @@ -59,6 +65,8 @@ def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dic "totalSize": total_size, "files": files, } + if install_id: + manifest["installId"] = install_id destination.parent.mkdir(parents=True, exist_ok=True) manifest_path = destination.parent / f".{destination.name}.manifest.json" diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 316236d..18e20f4 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -26,7 +26,14 @@ from plugin_helper.models import Dependency, Lockfile, LockedPlugin, Registry, R from plugin_helper.operations import enable_disabled_plugin from plugin_helper.planner import create_plan from plugin_helper.scanner import scan_bootstrap_files, scan_instance -from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state, save_installed_state +from plugin_helper.state import ( + downloads_dir, + installation_id, + load_installed_state, + plugin_downloads_dir, + save_bootstrap_state, + save_installed_state, +) from plugin_helper.tui import InstallationChoice, PluginHelperTui from plugin_helper.update_runner import run_update from plugin_helper.updates import check_updates @@ -863,10 +870,112 @@ sha256 = "{sha256_file(asset)}" self.assertEqual(status, 0) self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") - updated = load_installed_state(state, "1.40.8") + install_id = installation_id( + profile_id=None, + root=instance_root, + instance="1.40.8", + instance_path=instance, + ) + updated = load_installed_state(state, "1.40.8", install_id=install_id) self.assertIn("example", updated["plugins"]) self.assertNotIn("example", updated["disabledPlugins"]) + def test_install_states_are_separate_for_duplicate_instance_names(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + linux_root = work / "linux" + windows_root = work / "windows" + linux_instance = linux_root / "1.40.8" + windows_instance = windows_root / "1.40.8" + state = work / "state" + registry_dir = work / "registry" + locks_dir = work / "locks" + registry_dir.mkdir() + locks_dir.mkdir() + for instance in (linux_instance, windows_instance): + (instance / "Beat Saber_Data").mkdir(parents=True) + (instance / "Plugins").mkdir() + + asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll" + asset.write_bytes(b"managed dll") + self.assertEqual(asset.parent, state / "cache" / "downloads" / "1.40.8" / "example") + (registry_dir / "plugins.toml").write_text( + """ +[[plugins]] +id = "example" +name = "Example" +repo = "owner/example" +asset_patterns = ["*.dll"] +install_strategy = "dll-to-plugins" +""".lstrip(), + encoding="utf-8", + ) + (locks_dir / "1.40.8.lock.toml").write_text( + f""" +beat_saber_version = "1.40.8" +instance = "1.40.8" + +[[plugins]] +id = "example" +repo = "owner/example" +tag = "v1.0.0" +asset = "Example.dll" +sha256 = "{sha256_file(asset)}" +""".lstrip(), + encoding="utf-8", + ) + linux_id = installation_id( + profile_id="shared", + root=linux_root, + instance="1.40.8", + instance_path=linux_instance, + ) + windows_id = installation_id( + profile_id="shared", + root=windows_root, + instance="1.40.8", + instance_path=windows_instance, + ) + disabled_state = { + "instance": "1.40.8", + "plugins": {}, + "disabledPlugins": { + "example": { + "installedAt": "2026-06-14T17:18:40Z", + "disabledAt": "2026-06-14T17:20:00Z", + "files": [ + { + "path": "Plugins/Example.dll", + "sha256": sha256_file(asset), + "size": asset.stat().st_size, + } + ], + } + }, + } + save_installed_state(state, "1.40.8", json.loads(json.dumps(disabled_state)), install_id=linux_id) + save_installed_state(state, "1.40.8", json.loads(json.dumps(disabled_state)), install_id=windows_id) + + with patch("plugin_helper.operations.repo_root", return_value=work): + result = enable_disabled_plugin( + instance="1.40.8", + instance_path=linux_instance, + state_root=state, + plugin_id="example", + repo=work, + install_id=linux_id, + ) + + self.assertTrue((linux_instance / "Plugins" / "Example.dll").is_file()) + self.assertFalse((windows_instance / "Plugins" / "Example.dll").exists()) + self.assertIn(f"installs/{linux_id}/plans", result["planPath"]) + linux_state = load_installed_state(state, "1.40.8", install_id=linux_id) + windows_state = load_installed_state(state, "1.40.8", install_id=windows_id) + self.assertIn("example", linux_state["plugins"]) + self.assertNotIn("example", linux_state["disabledPlugins"]) + self.assertEqual(windows_state["plugins"], {}) + self.assertIn("example", windows_state["disabledPlugins"]) + def test_save_and_restore_known_good_set(self) -> None: from plugin_helper.operations import restore_known_good_set, save_known_good_set from plugin_helper.state import load_known_good_state, save_installed_state @@ -1006,7 +1115,7 @@ instance = "1.40.8" apply_plan(plan, state) self.assertEqual((instance / "IPA" / "Pending" / "Plugins" / "Example.dll").read_bytes(), b"dll") - def test_plan_still_finds_legacy_flat_downloads(self) -> None: + def test_plan_finds_shared_version_downloads(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) instance = work / "instances" / "1.40.8" @@ -1014,7 +1123,7 @@ instance = "1.40.8" instance.mkdir(parents=True) (instance / "Beat Saber_Data").mkdir() asset = downloads_dir(state, "1.40.8") / "Example.dll" - asset.write_bytes(b"legacy flat download") + asset.write_bytes(b"shared version download") plan, _ = create_plan( instance="1.40.8", @@ -2336,6 +2445,7 @@ sha256 = "{sha256_file(asset)}" state, "1.40.8", {"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins}, + install_id="test", ) choice = InstallationChoice( install_id="test", @@ -2405,6 +2515,7 @@ instance = "1.40.8" state, "1.40.8", {"instance": "1.40.8", "plugins": plugins_state, "disabledPlugins": {}}, + install_id="test", ) choice = InstallationChoice( install_id="test", @@ -2440,6 +2551,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): table = app.query_one(DataTable) self.assertEqual(table.row_count, 2) self.assertEqual(app.mode, "installations") + self.assertEqual(str(table.get_cell_at(Coordinate(0, 3))), "/tmp/state-linux/installs/linux") + self.assertEqual(str(table.get_cell_at(Coordinate(1, 3))), "/tmp/state-windows/installs/windows") async def test_single_instance_skips_installation_picker(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -2464,7 +2577,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): await pilot.pause() self.assertFalse((instance / "Plugins" / "Example.dll").exists()) - updated = load_installed_state(state, "1.40.8") + updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id) self.assertNotIn("example", updated["plugins"]) self.assertIn("example", updated["disabledPlugins"]) @@ -2498,7 +2611,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): await pilot.pause() self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") - updated = load_installed_state(state, "1.40.8") + updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id) self.assertIn("example", updated["plugins"]) self.assertNotIn("example", updated["disabledPlugins"]) @@ -2513,7 +2626,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): await pilot.pause() self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") - updated = load_installed_state(state, "1.40.8") + updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id) self.assertIn("example", updated["plugins"]) self.assertNotIn("example", updated.get("disabledPlugins", {})) @@ -2526,7 +2639,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): await pilot.pause() self.assertFalse((instance / "Plugins" / "Example.dll").exists()) - updated = load_installed_state(state, "1.40.8") + updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id) self.assertNotIn("example", updated["plugins"]) self.assertIn("example", updated["disabledPlugins"]) self.assertIn("Disabled 1 plugins", app.status_message) @@ -2540,7 +2653,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): await pilot.pause() self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") - updated = load_installed_state(state, "1.40.8") + updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id) self.assertIn("example", updated["plugins"]) self.assertNotIn("example", updated["disabledPlugins"]) self.assertIn("Enabled 1 plugins", app.status_message) @@ -2555,7 +2668,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): from plugin_helper.state import load_known_good_state - known_good = load_known_good_state(state, "1.40.8") + known_good = load_known_good_state(state, "1.40.8", install_id=app.choices[0].install_id) self.assertEqual(known_good["pluginIds"], ["alpha", "beta"]) self.assertIn("Saved known-good set: 2 enabled plugins", app.status_message) @@ -2577,7 +2690,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): self.assertIn("Restored known-good set", app.status_message) self.assertTrue((instance / "Plugins" / "Beta.dll").exists()) - updated = load_installed_state(state, "1.40.8") + updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id) self.assertEqual(set(updated["plugins"]), {"alpha", "beta"}) async def test_restore_known_good_without_saved_set_reports_status(self) -> None: @@ -2599,7 +2712,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): await pilot.pause() self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll") - updated = load_installed_state(state, "1.40.8") + updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id) self.assertIn("example", updated["plugins"]) self.assertNotIn("example", updated.get("disabledPlugins", {})) self.assertIn("hash mismatch", app.status_message)