Add profile-aware plugin TUI
This commit is contained in:
+98
-197
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user