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
|
||||
|
||||
+110
-3
@@ -1,13 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
WINDOWS_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances")
|
||||
LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances"
|
||||
DEFAULT_INSTANCES_ROOTS = (WINDOWS_INSTANCES_ROOT, LOCAL_INSTANCES_ROOT)
|
||||
DEFAULT_INSTANCES_ROOT = WINDOWS_INSTANCES_ROOT
|
||||
LOCAL_CONFIG_NAME = "plugin-helper.local.toml"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Profile:
|
||||
id: str
|
||||
label: str
|
||||
instances_root: Path
|
||||
state_dir: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeConfig:
|
||||
instances_roots: list[Path]
|
||||
state_root: Path
|
||||
profiles: tuple[Profile, ...]
|
||||
selected_profile: Profile | None
|
||||
config_path: Path
|
||||
config_loaded: bool
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def instances_root(value: str | None = None) -> Path:
|
||||
@@ -23,11 +49,92 @@ def instances_roots(value: str | None = None) -> list[Path]:
|
||||
|
||||
def state_root(value: str | None = None) -> Path:
|
||||
if value:
|
||||
return Path(value).expanduser()
|
||||
return _resolve_path(value, repo_root())
|
||||
xdg_state = os.environ.get("XDG_STATE_HOME")
|
||||
base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state"
|
||||
return base / "plugin-helper"
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
def default_config_path(root: Path | None = None) -> Path:
|
||||
return (root or repo_root()) / LOCAL_CONFIG_NAME
|
||||
|
||||
|
||||
def _resolve_path(value: str | Path, base: Path) -> Path:
|
||||
path = Path(value).expanduser()
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (base / path).resolve()
|
||||
|
||||
|
||||
def load_profiles(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[tuple[Profile, ...], Path, bool]:
|
||||
repo = root or repo_root()
|
||||
path = _resolve_path(config_path, repo) if config_path else default_config_path(repo)
|
||||
if not path.exists():
|
||||
if config_path:
|
||||
raise FileNotFoundError(f"plugin-helper config not found: {path}")
|
||||
return (), path, False
|
||||
|
||||
with path.open("rb") as handle:
|
||||
data: dict[str, Any] = tomllib.load(handle)
|
||||
|
||||
profiles: list[Profile] = []
|
||||
seen: set[str] = set()
|
||||
base = path.parent
|
||||
for item in data.get("profiles", []):
|
||||
profile_id = item["id"]
|
||||
if profile_id in seen:
|
||||
raise ValueError(f"{path}: duplicate profile id: {profile_id}")
|
||||
seen.add(profile_id)
|
||||
profiles.append(
|
||||
Profile(
|
||||
id=profile_id,
|
||||
label=item.get("label", profile_id),
|
||||
instances_root=_resolve_path(item["instances_root"], base),
|
||||
state_dir=_resolve_path(item["state_dir"], base),
|
||||
)
|
||||
)
|
||||
return tuple(profiles), path, True
|
||||
|
||||
|
||||
def profile_by_id(profiles: tuple[Profile, ...], profile_id: str) -> Profile:
|
||||
for profile in profiles:
|
||||
if profile.id == profile_id:
|
||||
return profile
|
||||
available = ", ".join(profile.id for profile in profiles) or "(none)"
|
||||
raise KeyError(f"unknown profile: {profile_id}; available profiles: {available}")
|
||||
|
||||
|
||||
def resolve_runtime_config(
|
||||
*,
|
||||
instances_root_value: str | None = None,
|
||||
state_dir_value: str | None = None,
|
||||
profile_id: str | None = None,
|
||||
config_path: str | Path | None = None,
|
||||
root: Path | None = None,
|
||||
) -> RuntimeConfig:
|
||||
repo = root or repo_root()
|
||||
profiles, loaded_path, loaded = load_profiles(config_path, root=repo)
|
||||
selected = profile_by_id(profiles, profile_id) if profile_id else None
|
||||
|
||||
if instances_root_value:
|
||||
resolved_instances = instances_roots(instances_root_value)
|
||||
elif selected:
|
||||
resolved_instances = [selected.instances_root]
|
||||
else:
|
||||
resolved_instances = instances_roots(None)
|
||||
|
||||
if state_dir_value:
|
||||
resolved_state = _resolve_path(state_dir_value, repo)
|
||||
elif selected:
|
||||
resolved_state = selected.state_dir
|
||||
else:
|
||||
resolved_state = state_root(None)
|
||||
|
||||
return RuntimeConfig(
|
||||
instances_roots=resolved_instances,
|
||||
state_root=resolved_state,
|
||||
profiles=profiles,
|
||||
selected_profile=selected,
|
||||
config_path=loaded_path,
|
||||
config_loaded=loaded,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import repo_root
|
||||
from .installer import apply_plan
|
||||
from .models import load_lockfile, load_registry
|
||||
from .planner import create_plan
|
||||
from .state import load_installed_state
|
||||
|
||||
|
||||
def enable_disabled_plugin(
|
||||
*,
|
||||
instance: str,
|
||||
instance_path: Path,
|
||||
state_root: Path,
|
||||
plugin_id: str,
|
||||
registry: str = "registry/plugins.toml",
|
||||
lockfile: str | None = None,
|
||||
repo: Path | 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}")
|
||||
|
||||
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)
|
||||
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}")
|
||||
|
||||
plan, path = create_plan(
|
||||
instance=instance,
|
||||
instance_path=instance_path,
|
||||
beat_saber_version=loaded_lockfile.beat_saber_version,
|
||||
registry=load_registry(registry_path),
|
||||
lockfile=loaded_lockfile,
|
||||
state_root=state_root,
|
||||
repo_root=root,
|
||||
selected={plugin_id},
|
||||
)
|
||||
result = apply_plan(plan, state_root)
|
||||
return {"planPath": str(path), **result}
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .models import Lockfile, Registry
|
||||
|
||||
|
||||
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)))
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.widgets import DataTable, Footer, Header, Static
|
||||
|
||||
from .installer import disable_plugin
|
||||
from .models import load_lockfile, load_registry
|
||||
from .operations import enable_disabled_plugin
|
||||
from .reports import installed_plugins_report
|
||||
from .state import load_installed_state
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstallationChoice:
|
||||
profile_id: str
|
||||
profile_label: str
|
||||
instance_name: str
|
||||
instance_path: Path
|
||||
state_root: Path
|
||||
|
||||
|
||||
class PluginHelperTui(App[int]):
|
||||
CSS = """
|
||||
#title {
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
#status {
|
||||
padding: 0 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
BINDINGS = [
|
||||
Binding("enter", "select", "Select", priority=True),
|
||||
Binding("space", "toggle_plugin", "Toggle", priority=True),
|
||||
Binding("d", "disable_all_plugins", "Disable all", priority=True),
|
||||
Binding("e", "enable_all_plugins", "Enable all", priority=True),
|
||||
Binding("r", "refresh", "Refresh"),
|
||||
Binding("b", "back", "Back"),
|
||||
Binding("q", "quit", "Quit"),
|
||||
Binding("ctrl+q", "quit", "Quit", show=False, priority=True),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
choices: list[InstallationChoice],
|
||||
repo_root: Path,
|
||||
setup_hint: str | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.choices = choices
|
||||
self.repo_root = repo_root
|
||||
self.setup_hint = setup_hint
|
||||
self.mode = "installations"
|
||||
self.selected_installation: InstallationChoice | None = None
|
||||
self.plugin_rows: list[dict[str, Any]] = []
|
||||
self.status_message = ""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(show_clock=False)
|
||||
yield Static("", id="title")
|
||||
yield DataTable(id="table")
|
||||
yield Static("", id="status")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
table = self.query_one(DataTable)
|
||||
table.cursor_type = "row"
|
||||
self._show_installations()
|
||||
|
||||
def action_select(self) -> None:
|
||||
if self.mode != "installations":
|
||||
return
|
||||
index = self._cursor_index(len(self.choices))
|
||||
if index is None:
|
||||
return
|
||||
self.selected_installation = self.choices[index]
|
||||
self._show_plugins()
|
||||
|
||||
def action_back(self) -> None:
|
||||
if self.mode == "plugins":
|
||||
self._show_installations()
|
||||
|
||||
def action_refresh(self) -> None:
|
||||
if self.mode == "plugins":
|
||||
self._show_plugins()
|
||||
else:
|
||||
self._show_installations()
|
||||
|
||||
def action_toggle_plugin(self) -> None:
|
||||
if self.mode != "plugins" or self.selected_installation is None:
|
||||
return
|
||||
index = self._cursor_index(len(self.plugin_rows))
|
||||
if index is None:
|
||||
return
|
||||
plugin = self.plugin_rows[index]
|
||||
plugin_id = plugin["id"]
|
||||
target = self.selected_installation
|
||||
try:
|
||||
if plugin["status"] == "enabled":
|
||||
result = disable_plugin(
|
||||
target.instance_name,
|
||||
target.instance_path,
|
||||
target.state_root,
|
||||
plugin_id,
|
||||
force=False,
|
||||
)
|
||||
if not result["stateUpdated"]:
|
||||
self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}")
|
||||
return
|
||||
self._set_status(f"Disabled {plugin_id}; removed {len(result['removed'])} files.")
|
||||
elif plugin["status"] == "disabled":
|
||||
result = enable_disabled_plugin(
|
||||
instance=target.instance_name,
|
||||
instance_path=target.instance_path,
|
||||
state_root=target.state_root,
|
||||
plugin_id=plugin_id,
|
||||
repo=self.repo_root,
|
||||
)
|
||||
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
|
||||
else:
|
||||
self._set_status(f"Cannot toggle {plugin_id}: unknown status {plugin['status']}.")
|
||||
return
|
||||
except Exception as exc:
|
||||
self._set_status(f"Could not toggle {plugin_id}: {exc}")
|
||||
return
|
||||
self._show_plugins(preserve_status=True)
|
||||
|
||||
def action_disable_all_plugins(self) -> None:
|
||||
if self.mode != "plugins" or self.selected_installation is None:
|
||||
return
|
||||
target = self.selected_installation
|
||||
enabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "enabled"]
|
||||
changed = 0
|
||||
errors: list[str] = []
|
||||
for plugin in enabled:
|
||||
plugin_id = plugin["id"]
|
||||
try:
|
||||
result = disable_plugin(
|
||||
target.instance_name,
|
||||
target.instance_path,
|
||||
target.state_root,
|
||||
plugin_id,
|
||||
force=False,
|
||||
)
|
||||
if result["stateUpdated"]:
|
||||
changed += 1
|
||||
else:
|
||||
errors.append(f"{plugin_id}: {self._format_skipped(result['skipped'])}")
|
||||
except Exception as exc:
|
||||
errors.append(f"{plugin_id}: {exc}")
|
||||
self._set_bulk_status("Disabled", changed, errors)
|
||||
self._show_plugins(preserve_status=True)
|
||||
|
||||
def action_enable_all_plugins(self) -> None:
|
||||
if self.mode != "plugins" or self.selected_installation is None:
|
||||
return
|
||||
target = self.selected_installation
|
||||
disabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "disabled"]
|
||||
changed = 0
|
||||
errors: list[str] = []
|
||||
for plugin in disabled:
|
||||
plugin_id = plugin["id"]
|
||||
try:
|
||||
enable_disabled_plugin(
|
||||
instance=target.instance_name,
|
||||
instance_path=target.instance_path,
|
||||
state_root=target.state_root,
|
||||
plugin_id=plugin_id,
|
||||
repo=self.repo_root,
|
||||
)
|
||||
changed += 1
|
||||
except Exception as exc:
|
||||
errors.append(f"{plugin_id}: {exc}")
|
||||
self._set_bulk_status("Enabled", changed, errors)
|
||||
self._show_plugins(preserve_status=True)
|
||||
|
||||
def _show_installations(self) -> None:
|
||||
self.mode = "installations"
|
||||
self.plugin_rows = []
|
||||
self._set_title("Choose Beat Saber Installation")
|
||||
table = self.query_one(DataTable)
|
||||
table.clear(columns=True)
|
||||
table.add_columns("Profile", "Version", "Instance Path", "State Dir")
|
||||
for choice in self.choices:
|
||||
table.add_row(
|
||||
choice.profile_label,
|
||||
choice.instance_name,
|
||||
str(choice.instance_path),
|
||||
str(choice.state_root),
|
||||
)
|
||||
if self.setup_hint:
|
||||
self._set_status(self.setup_hint)
|
||||
else:
|
||||
self._set_status("Enter selects an installation. q quits.")
|
||||
|
||||
def _show_plugins(self, *, preserve_status: bool = False) -> None:
|
||||
if self.selected_installation is None:
|
||||
self._show_installations()
|
||||
return
|
||||
target = self.selected_installation
|
||||
self.mode = "plugins"
|
||||
self._set_title(f"{target.profile_label} / {target.instance_name}")
|
||||
table = self.query_one(DataTable)
|
||||
table.clear(columns=True)
|
||||
table.add_columns("Status", "Name", "ID", "Version", "Files", "Asset")
|
||||
|
||||
try:
|
||||
lockfile = load_lockfile(self.repo_root / "locks" / f"{target.instance_name}.lock.toml")
|
||||
report = installed_plugins_report(
|
||||
installed_state=load_installed_state(target.state_root, target.instance_name),
|
||||
registry=load_registry(self.repo_root / "registry" / "plugins.toml"),
|
||||
lockfile=lockfile,
|
||||
)
|
||||
self.plugin_rows = report["plugins"]
|
||||
except Exception as exc:
|
||||
self.plugin_rows = []
|
||||
if not preserve_status:
|
||||
self._set_status(f"Could not load plugins: {exc}")
|
||||
return
|
||||
|
||||
for plugin in self.plugin_rows:
|
||||
table.add_row(
|
||||
self._status_marker(plugin["status"]),
|
||||
plugin["name"],
|
||||
plugin["id"],
|
||||
plugin["version"],
|
||||
str(plugin["fileCount"]),
|
||||
plugin["asset"],
|
||||
)
|
||||
if not preserve_status:
|
||||
if self.plugin_rows:
|
||||
self._set_status("Space toggles selected. d disables all. e enables all. b returns to installations.")
|
||||
else:
|
||||
self._set_status("No managed plugins recorded for this installation.")
|
||||
|
||||
def _cursor_index(self, row_count: int) -> int | None:
|
||||
table = self.query_one(DataTable)
|
||||
row = table.cursor_coordinate.row
|
||||
if 0 <= row < row_count:
|
||||
return row
|
||||
return None
|
||||
|
||||
def _set_title(self, message: str) -> None:
|
||||
self.query_one("#title", Static).update(message)
|
||||
|
||||
def _set_status(self, message: str) -> None:
|
||||
self.status_message = message
|
||||
self.query_one("#status", Static).update(message)
|
||||
|
||||
def _set_bulk_status(self, verb: str, changed: int, errors: list[str]) -> None:
|
||||
if errors:
|
||||
preview = "; ".join(errors[:3])
|
||||
suffix = f"; {len(errors) - 3} more" if len(errors) > 3 else ""
|
||||
self._set_status(f"{verb} {changed} plugins; {len(errors)} failed: {preview}{suffix}")
|
||||
else:
|
||||
self._set_status(f"{verb} {changed} plugins.")
|
||||
|
||||
@staticmethod
|
||||
def _status_marker(status: str) -> Text:
|
||||
return Text("[x]" if status == "enabled" else "[ ]", no_wrap=True)
|
||||
|
||||
@staticmethod
|
||||
def _format_skipped(skipped: list[dict[str, str]]) -> str:
|
||||
if not skipped:
|
||||
return "no files changed"
|
||||
return "; ".join(f"{item['path']} {item['reason']}" for item in skipped)
|
||||
Reference in New Issue
Block a user