Add Beat Saber data backup command

This commit is contained in:
pleb
2026-06-28 14:27:12 -07:00
parent 931c1d4f73
commit 7639fb7270
4 changed files with 639 additions and 30 deletions
+206 -16
View File
@@ -4,9 +4,11 @@ import argparse
import json
import sys
from pathlib import Path
from typing import Any
from typing import Any, Callable
from .config import instances_root, repo_root, state_root
from .config import instances_roots, repo_root, state_root
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
@@ -17,7 +19,7 @@ from .planner import create_plan
from .scanner import scan_instance
from .state import load_installed_state
from .updates import check_updates
from .userdata import backup_userdata
from .userdata import sync_windows_data_repo
def _json(data: Any) -> None:
@@ -135,6 +137,12 @@ def build_parser() -> argparse.ArgumentParser:
parents=[_common_parent()],
)
subcommands.add_parser(
"menu",
help="Open an interactive instance/action menu",
parents=[_common_parent()],
)
scan = subcommands.add_parser(
"scan",
help="Inspect installed Plugins, Libs, and IPA/Pending files",
@@ -171,6 +179,25 @@ def build_parser() -> argparse.ArgumentParser:
check.add_argument("--lockfile")
check.add_argument("--json", action="store_true", help="Print full JSON check output")
bootstrap = subcommands.add_parser(
"bootstrap",
help="Install locked BSIPA, run IPA.exe -n through Proton, and record bootstrap files",
parents=[_common_parent()],
)
bootstrap.add_argument("--instance", required=True)
bootstrap.add_argument("--registry", default="registry/plugins.toml")
bootstrap.add_argument("--lockfile")
bootstrap.add_argument("--proton", help="Path to Proton executable")
bootstrap.add_argument("--json", action="store_true", help="Print full JSON bootstrap output")
bootstrap_check = subcommands.add_parser(
"bootstrap-check",
help="Verify recorded BSIPA bootstrap state and latest IPA log",
parents=[_common_parent()],
)
bootstrap_check.add_argument("--instance", required=True)
bootstrap_check.add_argument("--json", action="store_true", help="Print full JSON bootstrap health output")
updates = subcommands.add_parser(
"updates",
help="Check GitHub for newer matching releases for locked plugins",
@@ -211,10 +238,13 @@ def build_parser() -> argparse.ArgumentParser:
backup = subcommands.add_parser(
"backup-userdata",
help="Create a timestamped UserData backup archive",
help="Copy UserData and Windows AppData into this repo",
parents=[_common_parent()],
)
backup.add_argument("--instance", required=True)
backup.add_argument("--backup-root", default="backups/beat-saber", help="Repo-local backup directory")
backup.add_argument("--appdata-path", help="Override Beat Saber Windows AppData path")
backup.add_argument("--no-appdata", action="store_true", help="Only copy UserData")
return parser
@@ -225,17 +255,115 @@ def _common_parent() -> argparse.ArgumentParser:
return parent
def _ask_choice(
*,
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."),
("backup-userdata", "Back up UserData", "Creates a timestamped archive of UserData before risky changes."),
("change", "Choose another version", "Returns to the Beat Saber version picker."),
]
selected_instance_key = _ask_choice(
title="Choose Beat Saber version",
choices=instance_choices,
input_func=ask,
)
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,
)
if selected_instance_key is None:
return 0
selected_instance = instances_by_choice[selected_instance_key]
continue
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)
else:
command.extend(["--instance", selected_instance.name])
print()
status = run(command)
print(f"Command exited with status {status}")
def run(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
inst_root = instances_root(getattr(args, "instances_root", None))
inst_roots = instances_roots(getattr(args, "instances_root", None))
st_root = state_root(getattr(args, "state_dir", None))
try:
if args.command == "instances":
found = list_instances(inst_root)
found = list_instances(inst_roots)
if not found:
print(f"No Beat Saber instances found under {inst_root}")
print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}")
return 1
for item in found:
flags = []
@@ -249,8 +377,11 @@ def run(argv: list[str] | None = None) -> int:
print(f"{item.name}\t{item.path}{suffix}")
return 0
if args.command == "menu":
return _run_menu(inst_roots, st_root)
if args.command == "scan":
instance = get_instance(inst_root, args.instance)
instance = get_instance(inst_roots, args.instance)
result = scan_instance(instance.path, include_hashes=args.hashes)
if args.json:
_json(result)
@@ -331,8 +462,57 @@ def run(argv: list[str] | None = None) -> int:
print_updates(result)
return 2 if result["summary"]["errors"] else 0
if args.command == "bootstrap":
instance = get_instance(inst_roots, args.instance)
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)
result = run_bootstrap(
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,
proton=Path(args.proton).expanduser() if args.proton else None,
progress=lambda message: print(f" {message}", flush=True),
)
if args.json:
_json(result)
else:
delta = result["delta"]
print(f"Bootstrap state: {result['statePath']}")
print(f"Plan: {result['planPath']}")
print(f"IPA.exe -n exit: {result['ipaExitCode']}")
print(
"Bootstrap files: "
f"{len(delta['created'])} created, {len(delta['mutated'])} mutated, "
f"{len(delta['removed'])} removed"
)
print(f"Health: {'ok' if result['health']['ok'] else 'error'}")
for message in result["health"]["messages"]:
print(f" {message}")
return 0 if result["health"]["ok"] else 2
if args.command == "bootstrap-check":
instance = get_instance(inst_roots, args.instance)
result = check_bsipa_health(instance.path, st_root, args.instance)
if args.json:
_json(result)
else:
print(f"BSIPA bootstrap: {'ok' if result['ok'] else 'error'}")
print(f"State: {result['statePath']}")
print(f"Log: {result['logPath']}")
for message in result["messages"]:
print(f" {message}")
return 0 if result["ok"] else 2
if args.command == "plan":
instance = get_instance(inst_root, args.instance)
instance = get_instance(inst_roots, args.instance)
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"
@@ -366,7 +546,7 @@ def run(argv: list[str] | None = None) -> int:
return 0
if args.command == "uninstall":
instance = get_instance(inst_root, args.instance)
instance = get_instance(inst_roots, args.instance)
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
print(f"Removed: {len(result['removed'])}")
if result["skipped"]:
@@ -376,12 +556,22 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["stateUpdated"] else 2
if args.command == "backup-userdata":
instance = get_instance(inst_root, args.instance)
result = backup_userdata(args.instance, instance.path, st_root)
manifest = result["manifest"]
print(f"Archive: {result['archive']}")
print(f"Files: {manifest['fileCount']}")
print(f"Bytes: {manifest['totalSize']}")
instance = get_instance(inst_roots, args.instance)
root = repo_root()
backup_root = Path(args.backup_root).expanduser()
if not backup_root.is_absolute():
backup_root = root / backup_root
result = sync_windows_data_repo(
instance=args.instance,
instance_path=instance.path,
backup_root=backup_root,
appdata_path=Path(args.appdata_path).expanduser() if args.appdata_path else None,
include_appdata=not args.no_appdata,
)
print(f"Backup root: {result['backupRoot']}")
for item in result["copied"]:
print(f"{item['label']}: {item['fileCount']} files")
print(f" {item['source']} -> {item['destination']}")
return 0
except Exception as exc: