feat: add plugin disable and enable commands
This commit is contained in:
@@ -11,7 +11,7 @@ from .bootstrap import run_bootstrap
|
||||
from .bsipa import check_bsipa_health
|
||||
from .checker import check_lock
|
||||
from .github import fetch_releases
|
||||
from .installer import apply_plan, uninstall_plugin
|
||||
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
|
||||
@@ -34,7 +34,15 @@ def installed_plugins_report(
|
||||
) -> dict[str, Any]:
|
||||
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
|
||||
plugins: list[dict[str, Any]] = []
|
||||
for plugin_id, plugin_state in sorted(installed_state.get("plugins", {}).items()):
|
||||
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", [])
|
||||
@@ -48,6 +56,8 @@ def installed_plugins_report(
|
||||
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,
|
||||
}
|
||||
@@ -66,14 +76,15 @@ def print_installed_plugins(report: dict[str, Any]) -> None:
|
||||
print("No plugins have been installed by plugin-helper yet.")
|
||||
return
|
||||
|
||||
headers = ("Plugin", "Version", "Asset", "Files", "Installed")
|
||||
headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed")
|
||||
rows = [
|
||||
(
|
||||
f"{plugin['name']} ({plugin['id']})",
|
||||
plugin["status"],
|
||||
plugin["version"],
|
||||
plugin["asset"],
|
||||
str(plugin["fileCount"]),
|
||||
plugin["installedAt"],
|
||||
plugin["disabledAt"] or plugin["installedAt"],
|
||||
)
|
||||
for plugin in plugins
|
||||
]
|
||||
@@ -236,6 +247,25 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
uninstall.add_argument("plugin")
|
||||
uninstall.add_argument("--force", action="store_true", help="Delete even when current file hashes differ")
|
||||
|
||||
disable = subcommands.add_parser(
|
||||
"disable",
|
||||
help="Remove a managed plugin's files from the game but keep state/assets for re-enable",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
disable.add_argument("--instance", required=True)
|
||||
disable.add_argument("plugin")
|
||||
disable.add_argument("--force", action="store_true", help="Disable even when current file hashes differ")
|
||||
|
||||
enable = subcommands.add_parser(
|
||||
"enable",
|
||||
help="Reinstall a disabled locked plugin from local assets",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
enable.add_argument("--instance", required=True)
|
||||
enable.add_argument("--registry", default="registry/plugins.toml")
|
||||
enable.add_argument("--lockfile")
|
||||
enable.add_argument("plugin")
|
||||
|
||||
backup = subcommands.add_parser(
|
||||
"backup-userdata",
|
||||
help="Copy UserData and Windows AppData into the adjacent backups repo",
|
||||
@@ -310,6 +340,8 @@ def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str],
|
||||
("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."),
|
||||
@@ -356,6 +388,12 @@ def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str],
|
||||
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])
|
||||
|
||||
@@ -566,6 +604,47 @@ def run(argv: list[str] | None = None) -> int:
|
||||
print(f" {item['path']}: {item['reason']}")
|
||||
return 0 if result["stateUpdated"] else 2
|
||||
|
||||
if args.command == "disable":
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
result = disable_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
|
||||
print(f"Disabled: {args.plugin}")
|
||||
print(f"Removed: {len(result['removed'])}")
|
||||
if result["skipped"]:
|
||||
print("Skipped:")
|
||||
for item in result["skipped"]:
|
||||
print(f" {item['path']}: {item['reason']}")
|
||||
return 0 if result["stateUpdated"] else 2
|
||||
|
||||
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(
|
||||
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},
|
||||
)
|
||||
result = apply_plan(plan, st_root)
|
||||
print(f"Enabled: {args.plugin}")
|
||||
print(f"Plan: {path}")
|
||||
print(f"Applied: {len(result['applied'])}")
|
||||
print(f"State: {result['statePath']}")
|
||||
return 0
|
||||
|
||||
if args.command == "backup-userdata":
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
root = repo_root()
|
||||
|
||||
@@ -77,6 +77,7 @@ def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]:
|
||||
"size": target.stat().st_size,
|
||||
}
|
||||
)
|
||||
installed_state.setdefault("disabledPlugins", {}).pop(change["plugin"], None)
|
||||
applied.append({"path": rel_target, "plugin": change["plugin"], "backup": backup})
|
||||
|
||||
save_installed_state(state_root, instance, installed_state)
|
||||
@@ -110,3 +111,37 @@ def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugi
|
||||
installed_state.get("plugins", {}).pop(plugin_id, None)
|
||||
save_installed_state(state_root, instance, installed_state)
|
||||
return {"removed": removed, "skipped": skipped, "stateUpdated": True}
|
||||
|
||||
|
||||
def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_id: str, force: bool = False) -> dict[str, Any]:
|
||||
installed_state = load_installed_state(state_root, instance)
|
||||
plugin_state = installed_state.get("plugins", {}).get(plugin_id)
|
||||
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}")
|
||||
|
||||
removed: list[str] = []
|
||||
skipped: list[dict[str, str]] = []
|
||||
for item in plugin_state.get("files", []):
|
||||
rel_path = ensure_relative(item["path"]).as_posix()
|
||||
target = ensure_inside(instance_path, instance_path / rel_path)
|
||||
if not target.exists():
|
||||
removed.append(rel_path)
|
||||
continue
|
||||
current_sha = sha256_file(target)
|
||||
if current_sha != item.get("sha256") and not force:
|
||||
skipped.append({"path": rel_path, "reason": "hash mismatch"})
|
||||
continue
|
||||
target.unlink()
|
||||
removed.append(rel_path)
|
||||
|
||||
if skipped and not force:
|
||||
return {"removed": removed, "skipped": skipped, "stateUpdated": False}
|
||||
|
||||
disabled_state = dict(plugin_state)
|
||||
disabled_state["disabledAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
installed_state.setdefault("disabledPlugins", {})[plugin_id] = disabled_state
|
||||
installed_state.get("plugins", {}).pop(plugin_id, None)
|
||||
save_installed_state(state_root, instance, installed_state)
|
||||
return {"removed": removed, "skipped": skipped, "stateUpdated": True}
|
||||
|
||||
Reference in New Issue
Block a user