diff --git a/README.md b/README.md index 0ca0c35..a78770a 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ The first implementation focuses on safe local workflows: - discover BSManager instances - scan existing `Plugins/` and `Libs/` files -- read checked-in registry and per-version lockfiles +- read the checked-in plugin catalog and per-version locks - generate a machine-readable install plan from local release assets -- apply exactly that plan and record install state -- uninstall only files recorded in install state +- apply exactly that plan and record managed install state +- uninstall only files recorded in managed install state Default BSManager instance root: @@ -125,12 +125,12 @@ That is equivalent to `PYTHONPATH=src python -m plugin_helper menu` when run from an interactive terminal. The menu reads `plugin-helper.local.toml` when present, shows each discovered -Beat Saber install with its resolved state directory, and lets you toggle -managed plugins with arrow keys and Space. In the plugin table, use `d` to -disable all currently enabled managed plugins (except `bsipa`) and `e` to enable all -currently disabled managed plugins. With a single Beat Saber installation, the -menu opens the plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when -needed. +Beat Saber install with its resolved state directory, and lists plugins from +that instance's version lock. Managed install state decides whether each row is +currently checked or unchecked. In the plugin table, use `d` to disable all +currently enabled plugins (except `bsipa`) and `e` to enable all currently +unchecked plugins. With a single Beat Saber installation, the menu opens the +plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when needed. The individual subcommands are mostly for automation and debugging. If you use them, pass `--state-dir` directly only when you intentionally want to override @@ -216,7 +216,7 @@ custom content or non-obvious user choices rather than pure cache data. Windows or through Proton on Linux), and records every bootstrap-relevant file under root `IPA.exe*`, `winhttp.dll`, `Libs/`, and `IPA/`, including backups created during patching. -- If an instance lockfile includes `bsipa`, ordinary plugin plans require a +- If an instance version lock includes `bsipa`, ordinary plugin plans require a recorded bootstrap state plus a `Logs/_latest.log` that shows BSIPA startup. Use `bootstrap-check` before planning a batch when you want a quick gate. - Use [`docs/SMOKETEST.md`](docs/SMOKETEST.md) after installing or removing a diff --git a/src/plugin_helper/bootstrap.py b/src/plugin_helper/bootstrap.py index e832238..ba16430 100644 --- a/src/plugin_helper/bootstrap.py +++ b/src/plugin_helper/bootstrap.py @@ -18,6 +18,9 @@ from .scanner import scan_bootstrap_files from .state import bootstrap_state_path, plugin_downloads_dir, save_bootstrap_state +DEFAULT_IPA_TIMEOUT_SECONDS = 30 + + def _now_iso() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") @@ -112,7 +115,7 @@ def _fetch_github_release_asset(locked: LockedPlugin, destination: Path) -> dict def fetch_locked_bsipa_archive(lockfile: Lockfile, state_root: Path) -> dict[str, Any]: locked = next((plugin for plugin in lockfile.plugins if plugin.id == BSIPA_PLUGIN_ID), None) if not locked: - raise ValueError("lockfile does not include a bsipa entry") + raise ValueError("version lock does not include a bsipa entry") if not locked.asset: raise ValueError("locked BSIPA entry has no asset") destination = plugin_downloads_dir(state_root, lockfile.instance, BSIPA_PLUGIN_ID) / locked.asset @@ -210,7 +213,7 @@ def run_bootstrap( proton: Path | None = None, native: bool | None = None, progress: Callable[[str], None] | None = None, - ipa_timeout_seconds: int = 120, + ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS, ) -> dict[str, Any]: tell = progress or (lambda _message: None) use_native = is_windows() if native is None else native @@ -311,7 +314,7 @@ def ensure_healthy_bootstrap( proton: Path | None = None, native: bool | None = None, progress: Callable[[str], None] | None = None, - ipa_timeout_seconds: int = 120, + ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS, ) -> None: if not planning_requires_bootstrap(lockfile.plugins, selected_ids): return diff --git a/src/plugin_helper/checker.py b/src/plugin_helper/checker.py index a983511..b8f2282 100644 --- a/src/plugin_helper/checker.py +++ b/src/plugin_helper/checker.py @@ -24,7 +24,7 @@ def check_lock( messages: list[dict[str, str]] = [] if not registry_plugin: - messages.append({"level": "warning", "message": "missing registry entry"}) + messages.append({"level": "warning", "message": "missing catalog entry"}) if strategy == "manual": messages.append({"level": "error", "message": "manual install strategy cannot be applied"}) if not locked.asset: diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index 14938e1..e22cc25 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -95,14 +95,14 @@ def build_parser() -> argparse.ArgumentParser: state = subcommands.add_parser( "state", - help="Show recorded plugin-helper install state", + help="Show recorded plugin-helper managed install state", parents=[_common_parent()], ) state.add_argument("--instance", required=True) installed = subcommands.add_parser( "installed", - help="List plugins installed by plugin-helper with locked release versions", + help="List plugins from the version lock with managed install state", parents=[_common_parent()], ) installed.add_argument("--instance", required=True) @@ -112,7 +112,7 @@ def build_parser() -> argparse.ArgumentParser: check = subcommands.add_parser( "check", - help="Validate local registry, lockfile, and release asset readiness", + help="Validate local catalog, version lock, and release asset readiness", parents=[_common_parent()], ) check.add_argument("--instance", required=True) @@ -154,7 +154,7 @@ def build_parser() -> argparse.ArgumentParser: plan = subcommands.add_parser( "plan", - help="Create a dry-run install plan from registry and lockfile", + help="Create a dry-run install plan from the catalog and version lock", parents=[_common_parent()], ) plan.add_argument("--instance", required=True) @@ -189,7 +189,7 @@ def build_parser() -> argparse.ArgumentParser: enable = subcommands.add_parser( "enable", - help="Reinstall a disabled locked plugin from local assets", + help="Enable a version-locked plugin from local assets", parents=[_common_parent()], ) enable.add_argument("--instance", required=True) diff --git a/src/plugin_helper/installer.py b/src/plugin_helper/installer.py index bb51fec..4c7cd0c 100644 --- a/src/plugin_helper/installer.py +++ b/src/plugin_helper/installer.py @@ -88,7 +88,7 @@ def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugi installed_state = load_installed_state(state_root, instance) plugin_state = installed_state.get("plugins", {}).get(plugin_id) if not plugin_state: - raise KeyError(f"plugin is not recorded in install state: {plugin_id}") + raise KeyError(f"plugin is not recorded in managed install state: {plugin_id}") removed: list[str] = [] skipped: list[dict[str, str]] = [] @@ -119,7 +119,7 @@ def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_ if not plugin_state: if plugin_id in installed_state.get("disabledPlugins", {}): raise KeyError(f"plugin is already disabled: {plugin_id}") - raise KeyError(f"plugin is not recorded in install state: {plugin_id}") + raise KeyError(f"plugin is not recorded in managed install state: {plugin_id}") removed: list[str] = [] skipped: list[dict[str, str]] = [] diff --git a/src/plugin_helper/operations.py b/src/plugin_helper/operations.py index fa05ef7..0969019 100644 --- a/src/plugin_helper/operations.py +++ b/src/plugin_helper/operations.py @@ -41,8 +41,8 @@ def enable_disabled_plugin( progress: Callable[[str], None] | None = None, ) -> dict[str, Any]: installed_state = load_installed_state(state_root, instance) - if plugin_id not in installed_state.get("disabledPlugins", {}): - raise KeyError(f"plugin is not recorded as disabled: {plugin_id}") + if plugin_id in installed_state.get("plugins", {}): + raise KeyError(f"plugin is already enabled: {plugin_id}") root, registry_path, _lock_path, loaded_lockfile, loaded_registry = _resolve_paths( instance=instance, @@ -51,7 +51,21 @@ def enable_disabled_plugin( repo=repo, ) if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins): - raise KeyError(f"plugin is disabled but not locked for this instance: {plugin_id}") + raise KeyError(f"plugin is not locked for this instance: {plugin_id}") + + tell = progress or (lambda _message: None) + tell(f"Planning {plugin_id} from local assets") + plan, path = create_plan( + instance=instance, + instance_path=instance_path, + beat_saber_version=loaded_lockfile.beat_saber_version, + registry=loaded_registry, + lockfile=loaded_lockfile, + state_root=state_root, + repo_root=root, + selected={plugin_id}, + require_bootstrap=False, + ) ensure_healthy_bootstrap( instance=instance, @@ -65,16 +79,7 @@ def enable_disabled_plugin( progress=progress, ) - plan, path = create_plan( - instance=instance, - instance_path=instance_path, - beat_saber_version=loaded_lockfile.beat_saber_version, - registry=loaded_registry, - lockfile=loaded_lockfile, - state_root=state_root, - repo_root=root, - selected={plugin_id}, - ) + tell(f"Applying {plugin_id}") result = apply_plan(plan, state_root) return {"planPath": str(path), **result} @@ -100,8 +105,38 @@ def enable_disabled_plugins( repo=repo, ) installed_state = load_installed_state(state_root, instance) - disabled_plugins = installed_state.get("disabledPlugins", {}) - selected_ids = set(plugin_ids) + enabled_plugins = installed_state.get("plugins", {}) + locked_ids = {plugin.id for plugin in loaded_lockfile.plugins} + + enabled: list[dict[str, Any]] = [] + errors: list[dict[str, str]] = [] + plannable_ids: list[str] = [] + for plugin_id in plugin_ids: + if plugin_id in enabled_plugins: + errors.append({"plugin": plugin_id, "error": f"plugin is already enabled: {plugin_id}"}) + continue + if plugin_id not in locked_ids: + errors.append({"plugin": plugin_id, "error": f"plugin is not locked for this instance: {plugin_id}"}) + continue + plannable_ids.append(plugin_id) + + if not plannable_ids: + return {"enabled": enabled, "errors": errors} + + selected_ids = set(plannable_ids) + tell = progress or (lambda _message: None) + tell(f"Planning {len(plannable_ids)} plugins from local assets") + batch_plan, batch_path = create_plan( + instance=instance, + instance_path=instance_path, + beat_saber_version=loaded_lockfile.beat_saber_version, + registry=loaded_registry, + lockfile=loaded_lockfile, + state_root=state_root, + repo_root=root, + selected=selected_ids, + require_bootstrap=False, + ) ensure_healthy_bootstrap( instance=instance, @@ -115,31 +150,14 @@ def enable_disabled_plugins( progress=progress, ) - enabled: list[dict[str, Any]] = [] - errors: list[dict[str, str]] = [] - for plugin_id in plugin_ids: - if plugin_id not in disabled_plugins: - errors.append({"plugin": plugin_id, "error": f"plugin is not recorded as disabled: {plugin_id}"}) - continue - if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins): - errors.append( - {"plugin": plugin_id, "error": f"plugin is disabled but not locked for this instance: {plugin_id}"} - ) - continue - try: - plan, path = create_plan( - instance=instance, - instance_path=instance_path, - beat_saber_version=loaded_lockfile.beat_saber_version, - registry=loaded_registry, - lockfile=loaded_lockfile, - state_root=state_root, - repo_root=root, - selected={plugin_id}, - ) - result = apply_plan(plan, state_root) - enabled.append({"plugin": plugin_id, "planPath": str(path), "applied": len(result["applied"])}) - except Exception as exc: - errors.append({"plugin": plugin_id, "error": str(exc)}) + tell(f"Applying {len(plannable_ids)} plugins") + result = apply_plan(batch_plan, state_root) + applied_by_plugin: dict[str, int] = {} + for item in result["applied"]: + plugin_id = item["plugin"] + applied_by_plugin[plugin_id] = applied_by_plugin.get(plugin_id, 0) + 1 + + for plugin_id in plannable_ids: + enabled.append({"plugin": plugin_id, "planPath": str(batch_path), "applied": applied_by_plugin.get(plugin_id, 0)}) return {"enabled": enabled, "errors": errors} diff --git a/src/plugin_helper/planner.py b/src/plugin_helper/planner.py index 8512bdc..1bbee07 100644 --- a/src/plugin_helper/planner.py +++ b/src/plugin_helper/planner.py @@ -118,11 +118,11 @@ def create_plan( if strategy not in VALID_STRATEGIES: raise ValueError(f"{locked.id}: invalid install strategy: {strategy}") if strategy == "manual": - raise ValueError(f"{locked.id}: install_strategy is manual; add a concrete registry rule first") + raise ValueError(f"{locked.id}: install_strategy is manual; add a concrete catalog rule first") if not locked.asset: raise ValueError(f"{locked.id}: lock entry has no asset") if registry_plugin and not _asset_matches_patterns(Path(locked.asset).name, registry_plugin.asset_patterns): - warnings.append(f"{locked.id}: asset does not match registry patterns") + warnings.append(f"{locked.id}: asset does not match catalog patterns") asset_path = _find_asset(locked.asset, state_root, instance, repo_root, locked.id) if not asset_path: diff --git a/src/plugin_helper/reports.py b/src/plugin_helper/reports.py index 218e7fb..2788b1e 100644 --- a/src/plugin_helper/reports.py +++ b/src/plugin_helper/reports.py @@ -13,15 +13,24 @@ def installed_plugins_report( ) -> dict[str, Any]: locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins} plugins: list[dict[str, Any]] = [] - state_plugins = [ - (plugin_id, plugin_state, "enabled") - for plugin_id, plugin_state in installed_state.get("plugins", {}).items() - ] - state_plugins.extend( - (plugin_id, plugin_state, "disabled") - for plugin_id, plugin_state in installed_state.get("disabledPlugins", {}).items() + enabled_plugins = installed_state.get("plugins", {}) + disabled_plugins = installed_state.get("disabledPlugins", {}) + plugin_ids = list(locked_by_id) + plugin_ids.extend( + sorted( + plugin_id + for plugin_id in set(enabled_plugins) | set(disabled_plugins) + if plugin_id not in locked_by_id + ) ) - for plugin_id, plugin_state, status in sorted(state_plugins): + + for plugin_id in plugin_ids: + if plugin_id in enabled_plugins: + plugin_state = enabled_plugins[plugin_id] + status = "enabled" + else: + plugin_state = disabled_plugins.get(plugin_id, {}) + status = "disabled" registry_plugin = registry.get(plugin_id) locked = locked_by_id.get(plugin_id) files = plugin_state.get("files", []) @@ -50,9 +59,9 @@ def installed_plugins_report( def print_installed_plugins(report: dict[str, Any]) -> None: plugins = report["plugins"] - print(f"{report['instance']} managed plugins ({len(plugins)})") + print(f"{report['instance']} version-locked plugins ({len(plugins)})") if not plugins: - print("No plugins have been installed by plugin-helper yet.") + print("No plugins are listed in this instance's version lock.") return headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed") diff --git a/src/plugin_helper/tui.py b/src/plugin_helper/tui.py index 06543c4..d491e81 100644 --- a/src/plugin_helper/tui.py +++ b/src/plugin_helper/tui.py @@ -140,7 +140,7 @@ class PluginHelperTui(App[int]): state_root=target.state_root, plugin_id=plugin_id, repo=self.repo_root, - progress=self._bootstrap_progress, + progress=self._operation_progress, ) self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.") else: @@ -217,7 +217,7 @@ class PluginHelperTui(App[int]): state_root=target.state_root, plugin_ids=plugin_ids, repo=self.repo_root, - progress=self._bootstrap_progress, + progress=self._operation_progress, ) except Exception as exc: self._set_status(f"Could not enable plugins: {exc}") @@ -229,8 +229,8 @@ class PluginHelperTui(App[int]): self._set_bulk_status("Enabled", changed, errors) self._show_plugins(preserve_status=True) - def _bootstrap_progress(self, message: str) -> None: - self.call_from_thread(self._set_status, f"Bootstrapping: {message}") + def _operation_progress(self, message: str) -> None: + self.call_from_thread(self._set_status, message) def _show_installations(self) -> None: self.mode = "installations" @@ -297,7 +297,7 @@ class PluginHelperTui(App[int]): f"Space toggles selected. d disables all. e enables all.{back_hint}" ) else: - self._set_status("No managed plugins recorded for this installation.") + self._set_status("No version-locked plugins for this installation.") def _cursor_index(self, row_count: int) -> int | None: table = self.query_one(DataTable) diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 1da8980..9117a93 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -547,7 +547,7 @@ state_dir = ".state" check_health.assert_not_called() run_bootstrap.assert_not_called() - def test_enable_disabled_plugin_bootstraps_before_plan(self) -> None: + def test_enable_disabled_plugin_plans_before_bootstrap(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) instance = work / "instances" / "1.44.1" @@ -628,7 +628,55 @@ install_strategy = "dll-to-plugins" lockfile=str(lock_path), repo=work, ) - self.assertEqual(call_order, ["ensure", "plan", "apply"]) + self.assertEqual(call_order, ["plan", "ensure", "apply"]) + + def test_enable_locked_plugin_missing_asset_does_not_bootstrap(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + instance = work / "instances" / "1.44.1" + state = work / "state" + instance.mkdir(parents=True) + (instance / "Beat Saber_Data").mkdir() + (instance / "Plugins").mkdir() + (work / "locks").mkdir() + (work / "registry").mkdir() + lock_path = work / "locks" / "1.44.1.lock.toml" + lock_path.write_text( + """ +beat_saber_version = "1.44.1" +instance = "1.44.1" + +[[plugins]] +id = "example" +tag = "v1.0.0" +asset = "Missing.dll" +""".lstrip(), + encoding="utf-8", + ) + registry_path = work / "registry" / "plugins.toml" + registry_path.write_text( + """ +[[plugins]] +id = "example" +name = "Example" +install_strategy = "dll-to-plugins" +""".lstrip(), + encoding="utf-8", + ) + + with patch("plugin_helper.operations.ensure_healthy_bootstrap") as ensure: + with self.assertRaisesRegex(FileNotFoundError, "asset not found"): + enable_disabled_plugin( + instance="1.44.1", + instance_path=instance, + state_root=state, + plugin_id="example", + registry=str(registry_path), + lockfile=str(lock_path), + repo=work, + ) + + ensure.assert_not_called() def test_plan_apply_and_uninstall_dll(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -1399,6 +1447,33 @@ sha256 = "{sha256_file(asset)}" self.assertEqual(report["plugins"][0]["asset"], "Example.dll") self.assertEqual(report["plugins"][0]["fileCount"], 1) + def test_installed_plugins_report_starts_from_version_lock(self) -> None: + registry = Registry( + { + "alpha": RegistryPlugin(id="alpha", name="Alpha", repo="owner/alpha"), + "beta": RegistryPlugin(id="beta", name="Beta", repo="owner/beta"), + } + ) + lockfile = Lockfile( + beat_saber_version="1.40.8", + instance="1.40.8", + plugins=( + LockedPlugin(id="alpha", repo="owner/alpha", tag="v1.0.0", asset="Alpha.dll", sha256="a"), + LockedPlugin(id="beta", repo="owner/beta", tag="v2.0.0", asset="Beta.dll", sha256="b"), + ), + ) + + report = installed_plugins_report( + installed_state={"instance": "1.40.8", "plugins": {}}, + registry=registry, + lockfile=lockfile, + ) + + self.assertEqual([plugin["id"] for plugin in report["plugins"]], ["alpha", "beta"]) + self.assertEqual([plugin["status"] for plugin in report["plugins"]], ["disabled", "disabled"]) + self.assertEqual([plugin["fileCount"] for plugin in report["plugins"]], [0, 0]) + self.assertEqual(report["plugins"][1]["version"], "v2.0.0") + def test_update_check_reports_current_matching_asset(self) -> None: registry = Registry( { @@ -1535,7 +1610,13 @@ sha256 = "{sha256_file(asset)}" self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new") -def _make_tui_fixture(root: Path, *, disabled: bool = False, hash_mismatch: bool = False) -> tuple[PluginHelperTui, Path, Path]: +def _make_tui_fixture( + root: Path, + *, + disabled: bool = False, + hash_mismatch: bool = False, + recorded: bool = True, +) -> tuple[PluginHelperTui, Path, Path]: repo = root / "repo" instance_root = root / "instances" instance = instance_root / "1.40.8" @@ -1574,7 +1655,10 @@ sha256 = "{sha256_file(asset)}" ) target = instance / "Plugins" / "Example.dll" - if disabled: + if not recorded: + plugins = {} + disabled_plugins = {} + elif disabled: plugins = {} disabled_plugins = { "example": { @@ -1762,6 +1846,21 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): self.assertIn("example", updated["plugins"]) self.assertNotIn("example", updated["disabledPlugins"]) + async def test_space_enables_locked_unrecorded_plugin_from_asset(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + app, instance, state = _make_tui_fixture(Path(tmp), recorded=False) + + async with app.run_test() as pilot: + self.assertEqual(app.mode, "plugins") + self.assertEqual(app.plugin_rows[0]["status"], "disabled") + await pilot.press("space") + await pilot.pause() + + self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") + updated = load_installed_state(state, "1.40.8") + self.assertIn("example", updated["plugins"]) + self.assertNotIn("example", updated.get("disabledPlugins", {})) + async def test_disable_all_disables_enabled_plugins(self) -> None: with tempfile.TemporaryDirectory() as tmp: app, instance, state = _make_tui_fixture(Path(tmp))