Add profile-aware plugin TUI

This commit is contained in:
pleb
2026-07-01 11:44:03 -07:00
parent 13b1840ba0
commit 69f9dbd9b1
10 changed files with 876 additions and 265 deletions
+98 -197
View File
@@ -4,9 +4,9 @@ import argparse
import json
import sys
from pathlib import Path
from typing import Any, Callable
from typing import Any
from .config import instances_roots, repo_root, state_root
from .config import Profile, repo_root, resolve_runtime_config
from .bootstrap import run_bootstrap
from .bsipa import check_bsipa_health
from .checker import check_lock
@@ -14,8 +14,9 @@ from .github import fetch_releases
from .installer import apply_plan, disable_plugin, uninstall_plugin
from .instances import get_instance, list_instances
from .models import load_lockfile, load_registry
from .models import Lockfile, Registry
from .operations import enable_disabled_plugin
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 .updates import check_updates
@@ -26,79 +27,6 @@ def _json(data: Any) -> None:
print(json.dumps(data, indent=2, sort_keys=True))
def installed_plugins_report(
*,
installed_state: dict[str, Any],
registry: Registry,
lockfile: Lockfile,
) -> 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()
)
for plugin_id, plugin_state, status in sorted(state_plugins):
registry_plugin = registry.get(plugin_id)
locked = locked_by_id.get(plugin_id)
files = plugin_state.get("files", [])
plugins.append(
{
"id": plugin_id,
"name": registry_plugin.name if registry_plugin else plugin_id,
"version": locked.tag if locked and locked.tag else "(not locked)",
"asset": locked.asset if locked and locked.asset else "(unknown)",
"repo": (locked.repo if locked and locked.repo else None)
or (registry_plugin.repo if registry_plugin else None)
or "(unknown)",
"installedAt": plugin_state.get("installedAt", "(unknown)"),
"disabledAt": plugin_state.get("disabledAt"),
"status": status,
"fileCount": len(files),
"files": files,
}
)
return {
"instance": installed_state.get("instance", lockfile.instance),
"beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version),
"plugins": plugins,
}
def print_installed_plugins(report: dict[str, Any]) -> None:
plugins = report["plugins"]
print(f"{report['instance']} managed plugins ({len(plugins)})")
if not plugins:
print("No plugins have been installed by plugin-helper yet.")
return
headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed")
rows = [
(
f"{plugin['name']} ({plugin['id']})",
plugin["status"],
plugin["version"],
plugin["asset"],
str(plugin["fileCount"]),
plugin["disabledAt"] or plugin["installedAt"],
)
for plugin in plugins
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
header = " ".join(label.ljust(widths[index]) for index, label in enumerate(headers))
print(header)
print(" ".join("-" * width for width in widths))
for row in rows:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
def print_updates(report: dict[str, Any]) -> None:
plugins = report["plugins"]
summary = report["summary"]
@@ -135,6 +63,8 @@ def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = Fal
default = argparse.SUPPRESS if suppress_default else None
parser.add_argument("--instances-root", default=default, help="BSManager instances root")
parser.add_argument("--state-dir", default=default, help="plugin-helper state directory")
parser.add_argument("--profile", default=default, help="Configured profile id from plugin-helper.local.toml")
parser.add_argument("--config", default=default, help="Path to plugin-helper TOML config")
def build_parser() -> argparse.ArgumentParser:
@@ -295,120 +225,101 @@ def _common_parent() -> argparse.ArgumentParser:
return parent
def _ask_choice(
def _menu_profiles(
*,
title: str,
choices: list[tuple[str, str] | tuple[str, str, str]],
input_func: Callable[[str], str] | None = None,
) -> str | None:
ask = input_func or input
print()
print(title)
for index, choice in enumerate(choices, start=1):
label = choice[1]
print(f" {index}. {label}")
if len(choice) > 2:
print(f" {choice[2]}")
print(" q. Quit")
while True:
answer = ask("> ").strip().lower()
if answer in {"q", "quit", "exit"}:
return None
if answer.isdigit():
index = int(answer)
if 1 <= index <= len(choices):
return choices[index - 1][0]
print("Choose a listed number, or q to quit.")
def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str], str] | None = None) -> int:
ask = input_func or input
instances = list_instances(inst_roots)
if not instances:
print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}")
return 1
instances_by_choice = {str(index): item for index, item in enumerate(instances, start=1)}
instance_choices = [(str(index), f"{item.name} {item.path}") for index, item in enumerate(instances, start=1)]
action_choices = [
("installed", "Show managed installs", "Lists plugins recorded in plugin-helper state with locked versions and files."),
("updates", "Check locked plugin updates", "Looks at GitHub releases for newer assets matching locked plugins."),
("scan", "Scan installed files", "Counts files currently present in Plugins/, Libs/, and IPA/Pending/."),
("check", "Check lockfile and assets", "Validates registry entries, lockfile data, local assets, and SHA-256 values."),
("bootstrap", "Bootstrap BSIPA", "Fetches the locked BSIPA archive, installs it, runs IPA.exe -n, and records bootstrap files."),
("bootstrap-check", "Check BSIPA bootstrap", "Verifies recorded bootstrap state and the latest BSIPA log evidence."),
("plan", "Create install plan", "Writes a dry-run JSON plan for locked plugin files before anything is applied."),
("apply", "Apply a plan by path", "Installs exactly the file changes from a previously generated plan JSON."),
("disable", "Disable managed plugin", "Removes one recorded plugin from the game while keeping helper state and assets."),
("enable", "Enable managed plugin", "Reinstalls one disabled locked plugin from local assets."),
("backup-userdata", "Back up UserData", "Copies UserData and AppData into the adjacent backups repo."),
("restore-userdata", "Restore UserData", "Restores UserData and AppData from the backups repo into an instance."),
("change", "Choose another version", "Returns to the Beat Saber version picker."),
profiles: tuple[Profile, ...],
selected_profile: Profile | None,
instances_roots: list[Path],
state_root: Path,
use_config_profiles: bool,
) -> list[Profile]:
if selected_profile:
return [
Profile(
id=selected_profile.id if len(instances_roots) == 1 else f"{selected_profile.id}-{index}",
label=selected_profile.label,
instances_root=root,
state_dir=state_root,
)
for index, root in enumerate(instances_roots, start=1)
]
if use_config_profiles and profiles:
return list(profiles)
return [
Profile(
id=f"root-{index}",
label=str(root),
instances_root=root,
state_dir=state_root,
)
for index, root in enumerate(instances_roots, start=1)
]
selected_instance_key = _ask_choice(
title="Choose Beat Saber version",
choices=instance_choices,
input_func=ask,
def _run_menu(
*,
runtime: Any,
explicit_instances_root: bool,
explicit_state_dir: bool,
) -> int:
try:
from .tui import InstallationChoice, PluginHelperTui
except ImportError as exc:
print(f"Textual is required for the menu TUI: {exc}")
print("Install project dependencies, for example: python -m pip install -e .")
return 1
profiles = _menu_profiles(
profiles=runtime.profiles,
selected_profile=runtime.selected_profile,
instances_roots=runtime.instances_roots,
state_root=runtime.state_root,
use_config_profiles=runtime.config_loaded and not explicit_instances_root and not explicit_state_dir,
)
if selected_instance_key is None:
return 0
selected_instance = instances_by_choice[selected_instance_key]
while True:
selected_action = _ask_choice(
title=f"Choose action for {selected_instance.name}",
choices=action_choices,
input_func=ask,
)
if selected_action is None:
return 0
if selected_action == "change":
selected_instance_key = _ask_choice(
title="Choose Beat Saber version",
choices=instance_choices,
input_func=ask,
choices: list[InstallationChoice] = []
for profile in profiles:
for instance in list_instances(profile.instances_root):
choices.append(
InstallationChoice(
profile_id=profile.id,
profile_label=profile.label,
instance_name=instance.name,
instance_path=instance.path,
state_root=profile.state_dir,
)
)
if selected_instance_key is None:
return 0
selected_instance = instances_by_choice[selected_instance_key]
continue
if not choices:
searched = ", ".join(str(profile.instances_root) for profile in profiles)
print(f"No Beat Saber instances found under {searched}")
return 1
command = [
"--instances-root",
str(selected_instance.path.parent),
"--state-dir",
str(st_root),
selected_action,
]
if selected_action == "apply":
plan_path = ask("Plan path> ").strip()
if not plan_path:
print("No plan path entered.")
continue
command.append(plan_path)
elif selected_action in {"disable", "enable"}:
plugin_id = ask("Plugin id> ").strip()
if not plugin_id:
print("No plugin id entered.")
continue
command.extend(["--instance", selected_instance.name, plugin_id])
else:
command.extend(["--instance", selected_instance.name])
print()
status = run(command)
print(f"Command exited with status {status}")
setup_hint = None
if not runtime.config_loaded and not runtime.selected_profile and not explicit_instances_root and not explicit_state_dir:
setup_hint = (
f"No {runtime.config_path.name} found; using default roots and default state. "
f"Copy plugin-helper.toml.example to {runtime.config_path.name} to map each install to a state dir."
)
app = PluginHelperTui(choices=choices, repo_root=repo_root(), setup_hint=setup_hint)
result = app.run()
return int(result or 0)
def run(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
inst_roots = instances_roots(getattr(args, "instances_root", None))
st_root = state_root(getattr(args, "state_dir", None))
try:
explicit_instances_root = getattr(args, "instances_root", None) is not None
explicit_state_dir = getattr(args, "state_dir", None) is not None
runtime = resolve_runtime_config(
instances_root_value=getattr(args, "instances_root", None),
state_dir_value=getattr(args, "state_dir", None),
profile_id=getattr(args, "profile", None),
config_path=getattr(args, "config", None),
)
inst_roots = runtime.instances_roots
st_root = runtime.state_root
if args.command == "instances":
found = list_instances(inst_roots)
if not found:
@@ -427,7 +338,11 @@ def run(argv: list[str] | None = None) -> int:
return 0
if args.command == "menu":
return _run_menu(inst_roots, st_root)
return _run_menu(
runtime=runtime,
explicit_instances_root=explicit_instances_root,
explicit_state_dir=explicit_state_dir,
)
if args.command == "scan":
instance = get_instance(inst_roots, args.instance)
@@ -617,30 +532,16 @@ def run(argv: list[str] | None = None) -> int:
if args.command == "enable":
instance = get_instance(inst_roots, args.instance)
installed_state = load_installed_state(st_root, args.instance)
if args.plugin not in installed_state.get("disabledPlugins", {}):
raise KeyError(f"plugin is not recorded as disabled: {args.plugin}")
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()
lockfile = load_lockfile(lock_path)
if not any(plugin.id == args.plugin for plugin in lockfile.plugins):
raise KeyError(f"plugin is disabled but not locked for this instance: {args.plugin}")
plan, path = create_plan(
result = enable_disabled_plugin(
instance=args.instance,
instance_path=instance.path,
beat_saber_version=lockfile.beat_saber_version,
registry=load_registry(registry_path),
lockfile=lockfile,
state_root=st_root,
repo_root=root,
selected={args.plugin},
plugin_id=args.plugin,
registry=args.registry,
lockfile=args.lockfile,
)
result = apply_plan(plan, st_root)
print(f"Enabled: {args.plugin}")
print(f"Plan: {path}")
print(f"Plan: {result['planPath']}")
print(f"Applied: {len(result['applied'])}")
print(f"State: {result['statePath']}")
return 0