Show version-locked plugins in menu

This commit is contained in:
pleb
2026-07-05 15:01:58 -07:00
parent 1858f76766
commit 53ad6e2c0d
10 changed files with 212 additions and 83 deletions
+6 -3
View File
@@ -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
+1 -1
View File
@@ -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:
+5 -5
View File
@@ -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)
+2 -2
View File
@@ -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]] = []
+59 -41
View File
@@ -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}
+2 -2
View File
@@ -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:
+19 -10
View File
@@ -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")
+5 -5
View File
@@ -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)