Add Beat Saber data backup command
This commit is contained in:
+206
-16
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user