from __future__ import annotations from collections.abc import Callable from pathlib import Path from typing import Any from .bootstrap import ensure_healthy_bootstrap from .config import repo_root from .installer import apply_plan, disable_plugin from .models import Lockfile, Registry, load_lockfile, load_registry from .planner import create_plan from .state import load_installed_state, load_known_good_state, save_known_good_state def _resolve_paths( *, instance: str, registry: str, lockfile: str | None, repo: Path | None, ) -> tuple[Path, Path, Path, Lockfile, Registry]: root = repo or repo_root() registry_path = (root / registry).resolve() if not Path(registry).is_absolute() else Path(registry) lock_path = Path(lockfile) if lockfile else root / "locks" / f"{instance}.lock.toml" if not lock_path.is_absolute(): lock_path = (root / lock_path).resolve() loaded_lockfile = load_lockfile(lock_path) loaded_registry = load_registry(registry_path) return root, registry_path, lock_path, loaded_lockfile, loaded_registry def enable_disabled_plugin( *, instance: str, instance_path: Path, state_root: Path, plugin_id: str, registry: str = "registry/plugins", 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, install_id=install_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, registry=registry, lockfile=lockfile, repo=repo, ) if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins): 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, install_id=install_id, ) ensure_healthy_bootstrap( 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_ids={plugin_id}, progress=progress, install_id=install_id, ) tell(f"Applying {plugin_id}") result = apply_plan(plan, state_root) return {"planPath": str(path), **result} def enable_disabled_plugins( *, instance: str, instance_path: Path, state_root: Path, plugin_ids: list[str], registry: str = "registry/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": []} root, _registry_path, _lock_path, loaded_lockfile, loaded_registry = _resolve_paths( instance=instance, registry=registry, lockfile=lockfile, repo=repo, ) 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} 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, install_id=install_id, ) ensure_healthy_bootstrap( 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_ids=selected_ids, progress=progress, install_id=install_id, ) 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} 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, install_id=install_id) plugin_ids = sorted(installed_state.get("plugins", {})) known_good = { "instance": instance, "beatSaberVersion": installed_state.get("beatSaberVersion"), "pluginIds": plugin_ids, } if install_id: known_good["installId"] = install_id save_known_good_state(state_root, instance, known_good, install_id=install_id) return known_good def restore_known_good_set( *, instance: str, instance_path: Path, state_root: Path, registry: str = "registry/plugins", 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, 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, install_id=install_id) currently_enabled = set(installed_state.get("plugins", {})) to_disable = sorted(currently_enabled - known_good_ids) disabled: list[str] = [] disable_errors: list[dict[str, str]] = [] for plugin_id in to_disable: try: result = disable_plugin(instance, instance_path, state_root, plugin_id, False, install_id=install_id) if result["stateUpdated"]: disabled.append(plugin_id) else: skipped = "; ".join(f"{item['path']} {item['reason']}" for item in result["skipped"]) disable_errors.append({"plugin": plugin_id, "error": skipped or "no files changed"}) except Exception as exc: disable_errors.append({"plugin": plugin_id, "error": str(exc)}) 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( instance=instance, instance_path=instance_path, state_root=state_root, plugin_ids=to_enable, registry=registry, lockfile=lockfile, repo=repo, progress=progress, install_id=install_id, ) return { "knownGood": known_good, "disabled": disabled, "disableErrors": disable_errors, "enabled": [item["plugin"] for item in enable_result["enabled"]], "enableErrors": enable_result["errors"], }