From 1f35f6b0785bc30553827587a80fb7a409897542 Mon Sep 17 00:00:00 2001 From: pleb Date: Mon, 29 Jun 2026 10:56:57 -0700 Subject: [PATCH] Add BeatMods parsing and userdata restore --- .gitignore | 1 + AGENTS.md | 18 ++-- README.md | 60 ++++++++++++- docs/DESIGN.md | 34 ++++++-- docs/ROADMAP.md | 8 +- docs/SMOKETEST.md | 10 +-- src/plugin_helper/beatmods.py | 75 ++++++++++++++++ src/plugin_helper/cli.py | 36 +++++++- src/plugin_helper/userdata.py | 72 +++++++++++++++- tests/test_plugin_helper.py | 156 +++++++++++++++++++++++++++++++++- 10 files changed, 448 insertions(+), 22 deletions(-) create mode 100644 src/plugin_helper/beatmods.py diff --git a/.gitignore b/.gitignore index 420f743..1c26636 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.state/ +/.state-*/ /.pytest_cache/ /build/ /dist/ diff --git a/AGENTS.md b/AGENTS.md index 78e3e54..148499d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,24 +13,30 @@ Guidance for coding agents working in this repo. investigating launch behavior, inherited Steam arguments, instance layout, or Proton environment details unless the user explicitly asks for BSManager code changes. -- Prefer repo-local state with `--state-dir .state` for planned installs unless - the task explicitly targets the user's live default state. +- Prefer repo-local state for planned installs unless the task explicitly + targets the user's live default state. Use `--state-dir .state` for the local + Linux install and `--state-dir .state-windows` for the mounted Windows install + when both roots contain the same instance name. ## Workflow Rules - Run commands from the repo root with `PYTHONPATH=src`. - For human-style inspection, prefer the menu with repo-local state: `PYTHONPATH=src python -m plugin_helper --state-dir .state menu`. -- When targeting the local Linux BSManager install rather than the Windows - mirror, pass `--instances-root /home/pleb/.local/share/BSManager/BSInstances` - or choose that path explicitly in the menu. +- When targeting the local Linux BSManager install, pass + `--instances-root /home/pleb/.local/share/BSManager/BSInstances` and normally + `--state-dir .state`. +- When targeting the mounted Windows BSManager install, pass + `--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances` and + normally `--state-dir .state-windows`. - Use the helper commands instead of manually copying plugin files into an instance. - Treat BSIPA as a bootstrap phase: - `bootstrap` installs the locked BSIPA archive and records generated files. - ordinary plugin plans should depend on healthy bootstrap state. - Be careful with duplicate instance names across Windows and local roots. Use - the menu or pass `--instances-root` explicitly when targeting one install. + the menu or pass `--instances-root` explicitly when targeting one install, and + keep install/bootstrap state separate per target root. ## Validation diff --git a/README.md b/README.md index 88a8867..2580b58 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,40 @@ Default BSManager instance roots: Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. To search multiple explicit roots, separate them with `:`. +## Managing Multiple Installs + +The helper is intended to manage both the local Linux BSManager install and the +mounted Windows install. Lockfiles and registry entries are shared by Beat Saber +version, but install state is target-specific. When the same instance name +exists under both roots, such as `1.44.1`, use an explicit `--instances-root` +and a separate state directory for each target. + +Suggested repo-local convention: + +```text +.state/ local Linux BSManager state +.state-windows/ mounted Windows BSManager state +``` + +Examples: + +```sh +PYTHONPATH=src python -m plugin_helper \ + --instances-root /home/pleb/.local/share/BSManager/BSInstances \ + --state-dir .state \ + installed --instance 1.44.1 + +PYTHONPATH=src python -m plugin_helper \ + --instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances \ + --state-dir .state-windows \ + installed --instance 1.44.1 +``` + +Do not reuse the same state directory for both targets when their instance names +match. The current state layout is keyed by instance name, so sharing one state +directory would mix bootstrap records, generated plans, backups, and installed +file records for different game trees. + ## Commands For normal use, run the menu from the repo root. Use repo-local state so the @@ -33,7 +67,8 @@ PYTHONPATH=src python -m plugin_helper --state-dir .state menu The individual subcommands are mostly for automation and debugging. If you use them, pass `--state-dir .state` unless you intentionally want the default live -state outside this repo. +state outside this repo or are intentionally targeting the Windows install with +`.state-windows`. Install assets are currently expected to already exist locally, usually under: @@ -41,6 +76,13 @@ Install assets are currently expected to already exist locally, usually under: .state/instances//downloads// ``` +For a second target-specific state directory, copy or re-download the same +locked assets under that state root before planning. For example: + +```text +.state-windows/instances//downloads// +``` + ## Beat Saber Data Backups `backup-userdata` copies the mounted Windows `UserData` folder and Beat Saber @@ -68,6 +110,22 @@ the latest backup run. Use for a `UserData`-only sync, or `--backup-root ` to choose a different destination. +`restore-userdata` copies those backup trees back into a target instance. It +moves the current `UserData` and AppData trees aside first as +`.pre-restore-` snapshots. On Linux BSManager installs, +AppData is restored into the BSManager SharedContent Proton prefix unless +`--appdata-path` is provided. If `backup-descriptor.json` is present, its +`instance` field must match `--instance`. + +Example restore into the local Linux instance: + +```sh +PYTHONPATH=src python -m plugin_helper \ + --instances-root /home/pleb/.local/share/BSManager/BSInstances \ + restore-userdata \ + --instance 1.44.1 +``` + The backup intentionally omits bulky/generated data: - `UserData/BeatLeader/Replays` diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 5937fae..3b4f940 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,16 +1,23 @@ # plugin-helper Design -`plugin-helper` is a Python CLI for managing Beat Saber plugins in a mounted Windows BSManager install. It installs from individual GitHub releases, keeps per-game-version plugin selections pinned, records exact filesystem changes, and leaves compatibility judgment visible enough for an agent to help when upstream packaging is inconsistent. +`plugin-helper` is a Python CLI for managing Beat Saber plugins in BSManager +installs. It installs from pinned release artifacts, keeps per-game-version +plugin selections locked, records exact filesystem changes, and leaves +compatibility judgment visible enough for an agent to help when upstream +packaging is inconsistent. -The initial target is the Linux side of `incineroar`, after the Windows partition has been mounted manually. The current Beat Saber instances live under: +The current targets are the local Linux BSManager install and the mounted +Windows BSManager install: ```text /home/pleb/Windows/Users/pleb/BSManager/BSInstances +/home/pleb/.local/share/BSManager/BSInstances ``` ## Goals -- Manage plugins for one BSManager Beat Saber instance at a time, such as `1.40.8`. +- Manage plugins for one BSManager Beat Saber instance at a time, such as + `1.44.1`, while supporting the same instance name under multiple roots. - Pull plugin releases directly from configured GitHub repositories. - Determine candidate updates while respecting the pinned Beat Saber version. - Support selective updates and explicit pins. @@ -25,8 +32,10 @@ The initial target is the Linux side of `incineroar`, after the Windows partitio - Replacing BSManager as a GUI or Beat Saber instance manager. - Downloading or downgrading Beat Saber versions. - Running `nixos-rebuild switch`. -- Mutating the Windows partition unless the user has mounted it and explicitly runs an apply command. -- Treating Nix as the plugin installer. Nix should package `plugin-helper`; the CLI should manage the mutable mounted game tree. +- Mutating the Windows partition unless the user has mounted it and explicitly + runs an apply command targeting that root. +- Treating Nix as the plugin installer. Nix should package `plugin-helper`; the + CLI should manage mutable game trees. ## Core Model @@ -82,6 +91,21 @@ Runtime state should not need to live inside the repository. By default, keep mu For early development, a `--state-dir` option is useful so plans and manifests can be kept in the repo while the format settles. +When managing both local Linux and mounted Windows installs, install state must +be separated by target root as well as by instance name. The current state +layout is keyed by instance name, so two `1.44.1` installs should not share one +state directory. A practical repo-local convention is: + +```text +.state/ local Linux BSManager state +.state-windows/ mounted Windows BSManager state +``` + +The registry and lockfile remain shared for a Beat Saber version. Downloads may +be copied or re-fetched into each target-specific state directory, but generated +plans, bootstrap records, backups, and `installed.json` belong to one target +game tree. + ## Registry The registry describes plugin sources and install behavior. It should be human-editable because many Beat Saber plugins have small packaging differences. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6b3768a..f7d7696 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,7 +15,13 @@ The initial tool should stay conservative: - Mutating operations apply an explicit plan and record exact file hashes. - Nix packages `plugin-helper`, but does not directly manage the mutable Beat Saber tree. -This works well while Beat Saber is still launched from a Windows install or a mounted Windows filesystem. +This works well while Beat Saber is launched from either the local Linux +BSManager install or a mounted Windows BSManager filesystem. + +For the near term, the Python state model should treat target roots as distinct +installations even when they share an instance name. Lockfiles can stay keyed by +Beat Saber version, but bootstrap state, generated plans, backups, and +`installed.json` need to stay target-specific. ## Future: Nix-Orchestrated Plugin Sets diff --git a/docs/SMOKETEST.md b/docs/SMOKETEST.md index 2d3d930..70076a3 100644 --- a/docs/SMOKETEST.md +++ b/docs/SMOKETEST.md @@ -3,10 +3,10 @@ Use this workflow after installing or removing a plugin batch. It is adapted from the Setlist repo's working Proton/BSManager smoketest notes. -The routine smoketest should be short: about 10 seconds wall time for launch and -log check, followed by immediate teardown. If expected plugin log lines do not -appear inside that window, treat that as a failure to investigate instead of -stretching the timeout. +The routine smoketest should be short: about 20 seconds wall time for launch, +menu/UI initialization, and log check, followed by immediate teardown. If +expected plugin log lines do not appear inside that window, treat that as a +failure to investigate instead of repeatedly stretching the timeout. ## Preconditions @@ -47,7 +47,7 @@ export SteamEnv=1 export OXR_PARALLEL_VIEWS=1 ( - sleep 10 + sleep 20 pkill -TERM -f "[B]eat Saber.exe" || pkill -TERM -f "[B]eat Saber" || true sleep 2 pkill -KILL -f "[B]eat Saber.exe" || pkill -KILL -f "[B]eat Saber" || true diff --git a/src/plugin_helper/beatmods.py b/src/plugin_helper/beatmods.py new file mode 100644 index 0000000..ad50e98 --- /dev/null +++ b/src/plugin_helper/beatmods.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class BeatModsEntry: + name: str + mod_id: int | None + git_url: str | None + category: str | None + version_id: int | None + mod_version: str | None + zip_hash: str | None + dependencies: tuple[int, ...] + raw: dict[str, Any] + + +def extract_mods(payload: Any) -> list[dict[str, Any]]: + """Return the list of BeatMods records from either current or legacy shapes.""" + if isinstance(payload, dict) and isinstance(payload.get("mods"), list): + payload = payload["mods"] + if not isinstance(payload, list): + raise ValueError("BeatMods payload must be a list or an object with a mods list") + return [item for item in payload if isinstance(item, dict)] + + +def normalize_entry(entry: dict[str, Any]) -> BeatModsEntry: + """Normalize BeatMods' nested {mod, latest} response and older flat records.""" + mod = entry.get("mod") if isinstance(entry.get("mod"), dict) else entry + latest = entry.get("latest") if isinstance(entry.get("latest"), dict) else entry + dependencies = latest.get("dependencies", ()) + + return BeatModsEntry( + name=str(mod.get("name") or ""), + mod_id=_int_or_none(mod.get("id")), + git_url=_str_or_none(mod.get("gitUrl")), + category=_str_or_none(mod.get("category")), + version_id=_int_or_none(latest.get("id")), + mod_version=_str_or_none(latest.get("modVersion")), + zip_hash=_str_or_none(latest.get("zipHash")), + dependencies=tuple(_dependency_id(dep) for dep in dependencies if _dependency_id(dep) is not None), + raw=entry, + ) + + +def normalize_mods(payload: Any) -> list[BeatModsEntry]: + return [normalize_entry(entry) for entry in extract_mods(payload)] + + +def by_version_id(entries: list[BeatModsEntry]) -> dict[int, BeatModsEntry]: + return {entry.version_id: entry for entry in entries if entry.version_id is not None} + + +def _dependency_id(dependency: Any) -> int | None: + if isinstance(dependency, dict): + return _int_or_none(dependency.get("id")) + return _int_or_none(dependency) + + +def _int_or_none(value: Any) -> int | None: + if isinstance(value, bool) or value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _str_or_none(value: Any) -> str | None: + if value is None: + return None + text = str(value) + return text if text else None diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index 9caaa1a..d711494 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -19,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 sync_windows_data_repo +from .userdata import restore_windows_data_repo, sync_windows_data_repo def _json(data: Any) -> None: @@ -246,6 +246,16 @@ def build_parser() -> argparse.ArgumentParser: backup.add_argument("--appdata-path", help="Override Beat Saber Windows AppData path") backup.add_argument("--no-appdata", action="store_true", help="Only copy UserData") + restore = subcommands.add_parser( + "restore-userdata", + help="Restore UserData and AppData from the backups repo into an instance", + parents=[_common_parent()], + ) + restore.add_argument("--instance", required=True) + restore.add_argument("--backup-root", default="../backups/beat-saber", help="Backup directory") + restore.add_argument("--appdata-path", help="Override Beat Saber AppData destination path") + restore.add_argument("--no-appdata", action="store_true", help="Only restore UserData") + return parser @@ -300,7 +310,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."), - ("backup-userdata", "Back up UserData", "Creates a timestamped archive of UserData before risky changes."), + ("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."), ] @@ -574,6 +585,27 @@ def run(argv: list[str] | None = None) -> int: print(f" {item['source']} -> {item['destination']}") return 0 + if args.command == "restore-userdata": + 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).resolve() + result = restore_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["restored"]: + print(f"{item['label']}: {item['fileCount']} files") + print(f" {item['source']} -> {item['destination']}") + if item["snapshot"]: + print(f" previous: {item['snapshot']}") + return 0 + except Exception as exc: print(f"error: {exc}", file=sys.stderr) return 2 diff --git a/src/plugin_helper/userdata.py b/src/plugin_helper/userdata.py index faeb7c8..ab570b3 100644 --- a/src/plugin_helper/userdata.py +++ b/src/plugin_helper/userdata.py @@ -84,6 +84,19 @@ def infer_windows_appdata_path(instance_path: Path) -> Path: return profile / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber" +def infer_proton_appdata_path() -> Path: + return ( + Path.home() + / ".local/share/BSManager/SharedContent/compatdata/pfx/drive_c/users/steamuser/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber" + ) + + +def infer_appdata_path(instance_path: Path) -> Path: + if "Users" in instance_path.resolve().parts: + return infer_windows_appdata_path(instance_path) + return infer_proton_appdata_path() + + def sync_windows_data_repo( *, instance: str, @@ -96,7 +109,7 @@ def sync_windows_data_repo( ("UserData", instance_path / "UserData", backup_root / "UserData"), ] if include_appdata: - appdata = appdata_path or infer_windows_appdata_path(instance_path) + appdata = appdata_path or infer_appdata_path(instance_path) sources.append(("AppData", appdata, backup_root / "AppData")) for label, source, _ in sources: @@ -145,6 +158,63 @@ def sync_windows_data_repo( } +def restore_windows_data_repo( + *, + instance: str, + instance_path: Path, + backup_root: Path, + appdata_path: Path | None = None, + include_appdata: bool = True, +) -> dict[str, Any]: + descriptor_path = backup_root / "backup-descriptor.json" + if descriptor_path.is_file(): + descriptor = json.loads(descriptor_path.read_text(encoding="utf-8")) + descriptor_instance = descriptor.get("instance") + if descriptor_instance and descriptor_instance != instance: + raise ValueError( + f"backup descriptor instance {descriptor_instance!r} does not match requested instance {instance!r}" + ) + + created_at = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + restores: list[tuple[str, Path, Path]] = [ + ("UserData", backup_root / "UserData", instance_path / "UserData"), + ] + if include_appdata: + appdata = appdata_path or infer_appdata_path(instance_path) + restores.append(("AppData", backup_root / "AppData", appdata)) + + for label, source, _ in restores: + if not source.is_dir(): + raise FileNotFoundError(f"{label} backup not found: {source}") + + restored: list[dict[str, Any]] = [] + snapshots: list[dict[str, Any]] = [] + for label, source, destination in restores: + snapshot: Path | None = None + if destination.exists(): + snapshot = destination.parent / f"{destination.name}.pre-restore-{created_at}" + destination.rename(snapshot) + snapshots.append({"label": label, "path": str(snapshot)}) + + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source, destination, symlinks=True) + restored.append( + { + "label": label, + "source": str(source), + "destination": str(destination), + "fileCount": sum(1 for item in destination.rglob("*") if item.is_file()), + "snapshot": str(snapshot) if snapshot else None, + } + ) + + return { + "backupRoot": str(backup_root), + "restored": restored, + "snapshots": snapshots, + } + + def _ignore_backup_paths(source_root: Path, skipped: list[str]) -> Callable[[str, list[str]], set[str]]: def ignore(current_dir: str, names: list[str]) -> set[str]: ignored: set[str] = set() diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 1b8eeb8..ede277e 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -10,6 +10,7 @@ from unittest.mock import patch from zipfile import ZipFile from plugin_helper.bootstrap import _run_ipa +from plugin_helper.beatmods import by_version_id, normalize_mods from plugin_helper.checker import check_lock from plugin_helper.cli import installed_plugins_report, run from plugin_helper.fsutil import sha256_file @@ -20,10 +21,67 @@ from plugin_helper.planner import create_plan from plugin_helper.scanner import scan_bootstrap_files, scan_instance from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state from plugin_helper.updates import check_updates -from plugin_helper.userdata import backup_userdata, infer_windows_appdata_path, sync_windows_data_repo +from plugin_helper.userdata import ( + backup_userdata, + infer_appdata_path, + infer_proton_appdata_path, + infer_windows_appdata_path, + restore_windows_data_repo, + sync_windows_data_repo, +) class PluginHelperTests(unittest.TestCase): + def test_normalize_beatmods_current_nested_response(self) -> None: + payload = { + "mods": [ + { + "mod": { + "id": 10, + "name": "Example", + "gitUrl": "https://github.com/example/mod", + "category": "library", + }, + "latest": { + "id": 1234, + "modVersion": "1.2.3", + "zipHash": "abc123", + "dependencies": [2561, {"id": "2567"}], + }, + } + ] + } + + entries = normalize_mods(payload) + + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0].name, "Example") + self.assertEqual(entries[0].mod_id, 10) + self.assertEqual(entries[0].version_id, 1234) + self.assertEqual(entries[0].dependencies, (2561, 2567)) + self.assertEqual(by_version_id(entries)[1234].zip_hash, "abc123") + + def test_normalize_beatmods_legacy_flat_response(self) -> None: + entries = normalize_mods( + [ + { + "id": "12", + "name": "FlatExample", + "gitUrl": "", + "category": "mods", + "modVersion": "2.0.0", + "zipHash": "def456", + "dependencies": [{"id": 44}, "45", None], + } + ] + ) + + self.assertEqual(entries[0].name, "FlatExample") + self.assertEqual(entries[0].mod_id, 12) + self.assertEqual(entries[0].version_id, 12) + self.assertIsNone(entries[0].git_url) + self.assertEqual(entries[0].dependencies, (44, 45)) + def test_instances_and_scan(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -480,6 +538,102 @@ class PluginHelperTests(unittest.TestCase): self.assertIn("BeatLeader/Replays", descriptor["skipped"]) self.assertIn("*.log", descriptor["excludePatterns"]) + def test_infer_proton_appdata_path(self) -> None: + self.assertEqual( + infer_proton_appdata_path(), + Path.home() + / ".local/share/BSManager/SharedContent/compatdata/pfx/drive_c/users/steamuser/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber", + ) + + def test_infer_appdata_path_uses_windows_or_proton(self) -> None: + windows_instance = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances/1.44.1") + linux_instance = Path("/home/pleb/.local/share/BSManager/BSInstances/1.44.1") + + self.assertEqual(infer_appdata_path(windows_instance), infer_windows_appdata_path(windows_instance)) + self.assertEqual(infer_appdata_path(linux_instance), infer_proton_appdata_path()) + + def test_restore_windows_data_repo_roundtrip(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + instance = root / "Users" / "pleb" / "BSManager" / "BSInstances" / "1.44.1" + appdata = root / "Users" / "pleb" / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber" + backup_repo = root / "backup" + (instance / "UserData").mkdir(parents=True) + (instance / "UserData" / "settings.json").write_text('{"saved": true}', encoding="utf-8") + appdata.mkdir(parents=True) + (appdata / "settings.cfg").write_text("settings", encoding="utf-8") + + sync_windows_data_repo( + instance="1.44.1", + instance_path=instance, + backup_root=backup_repo, + ) + + (instance / "UserData" / "settings.json").write_text('{"saved": false}', encoding="utf-8") + (appdata / "settings.cfg").write_text("changed", encoding="utf-8") + + result = restore_windows_data_repo( + instance="1.44.1", + instance_path=instance, + backup_root=backup_repo, + ) + + self.assertEqual((instance / "UserData" / "settings.json").read_text(), '{"saved": true}') + self.assertEqual((appdata / "settings.cfg").read_text(), "settings") + self.assertEqual(len(result["restored"]), 2) + self.assertTrue(all(item["snapshot"] for item in result["restored"])) + + def test_restore_windows_data_repo_rejects_instance_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + instance = root / "1.44.1" + backup_repo = root / "backup" + (instance / "UserData").mkdir(parents=True) + (instance / "UserData" / "settings.json").write_text("{}", encoding="utf-8") + (backup_repo / "UserData").mkdir(parents=True) + (backup_repo / "UserData" / "settings.json").write_text("{}", encoding="utf-8") + (backup_repo / "backup-descriptor.json").write_text( + json.dumps({"instance": "1.40.8"}) + "\n", + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "does not match"): + restore_windows_data_repo( + instance="1.44.1", + instance_path=instance, + backup_root=backup_repo, + include_appdata=False, + ) + + def test_restore_userdata_cli(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + instances_root = root / "instances" + instance = instances_root / "1.44.1" + backup_repo = root / "backup" + (instance / "UserData").mkdir(parents=True) + (instance / "UserData" / "settings.json").write_text('{"restored": true}', encoding="utf-8") + (backup_repo / "UserData").mkdir(parents=True) + (backup_repo / "UserData" / "settings.json").write_text('{"restored": true}', encoding="utf-8") + + status = run( + [ + "--instances-root", + str(instances_root), + "--state-dir", + str(root / "state"), + "restore-userdata", + "--instance", + "1.44.1", + "--backup-root", + str(backup_repo), + "--no-appdata", + ] + ) + + self.assertEqual(status, 0) + self.assertEqual((instance / "UserData" / "settings.json").read_text(), '{"restored": true}') + def test_check_reports_missing_asset(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp)