Track install state by installation id

This commit is contained in:
pleb
2026-07-09 20:04:10 -07:00
parent 3154d71988
commit 8bbca1a872
16 changed files with 382 additions and 111 deletions
+1 -1
View File
@@ -149,7 +149,7 @@ the configured state directory for one command.
Install assets are currently expected to already exist locally, usually under: Install assets are currently expected to already exist locally, usually under:
```text ```text
<state-dir>/instances/<instance>/downloads/<plugin-id>/ <state-dir>/cache/downloads/<instance>/<plugin-id>/
``` ```
Use `updates` to audit the version lock against obvious public update sources: Use `updates` to audit the version lock against obvious public update sources:
+2 -6
View File
@@ -1,10 +1,6 @@
**Findings** **Findings**
- High: install state is still keyed by instance name, not a concrete installation. [state.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/state.py:10) writes everything under `instances/<instance>`, and the TUI gives every discovered root the same `runtime.state_root` in [cli.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/cli.py:279). That is fine only if Linux and Windows installs never share a state dir. For package-manager ambitions, introduce an `install_id` derived from profile/root/path, and split reusable asset cache from per-install state. - Resolved: install state is now keyed by concrete `install_id`, and reusable plugin assets live under the shared download cache instead of per-install state.
- High: `apply_plan` is not transactional. It backs up and writes files one by one, then saves state at the end in [installer.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/installer.py:39). If extraction/copy fails mid-plan, the game tree can be half-mutated without matching install state. A future package manager wants transactions/generations: stage, apply, record transaction, and have a first-class rollback command.
- Medium-high: installed state records files but not the installed package identity. [installer.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/installer.py:63) stores `path`, `sha256`, and `size`, but not repo/tag/asset/source URL/source hash/reason. The report then uses the current lockfile to describe installed versions, which can drift after a lock update. Record the lock identity into install state at apply time.
- Medium-high: dependency handling is intentionally thin. `Dependency.constraint` exists in [models.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/models.py:10), but [planner.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/planner.py:68) only expands required dependency IDs and does not enforce constraints, conflicts, `loadAfter`, `loadBefore`, BeatMods version-id closures, or “provides” relationships. That is the next big unlock for package-manager behavior. - Medium-high: dependency handling is intentionally thin. `Dependency.constraint` exists in [models.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/models.py:10), but [planner.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/planner.py:68) only expands required dependency IDs and does not enforce constraints, conflicts, `loadAfter`, `loadBefore`, BeatMods version-id closures, or “provides” relationships. That is the next big unlock for package-manager behavior.
@@ -41,4 +37,4 @@ The next shape Id aim for is:
Beat Saber plugins are heterogeneous in packaging, but homogeneous enough in runtime shape (`Plugins`, `Libs`, `IPA/Pending`, root BSIPA files) that this can become a nice small package manager. The trick is to normalize artifacts into a planned file tree before touching the game. Beat Saber plugins are heterogeneous in packaging, but homogeneous enough in runtime shape (`Plugins`, `Libs`, `IPA/Pending`, root BSIPA files) that this can become a nice small package manager. The trick is to normalize artifacts into a planned file tree before touching the game.
Validation: `compileall` passed, and `unittest discover` passed: 75 tests, 1 skipped. Validation: `compileall` passed, and `unittest discover` passed: 75 tests, 1 skipped.
+1 -1
View File
@@ -1,4 +1,4 @@
# plugin-helper Roadmap # Roadmap 1
This roadmap tracks ideas that are useful but not part of the first safe CLI slice. This roadmap tracks ideas that are useful but not part of the first safe CLI slice.
+9
View File
@@ -0,0 +1,9 @@
# Roadmap 2
## Package Manager Features
From a review:
- High: `apply_plan` is not transactional. It backs up and writes files one by one, then saves state at the end in [installer.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/installer.py:39). If extraction/copy fails mid-plan, the game tree can be half-mutated without matching install state. A future package manager wants transactions/generations: stage, apply, record transaction, and have a first-class rollback command.
- Medium-high: installed state records files but not the installed package identity. [installer.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/installer.py:63) stores `path`, `sha256`, and `size`, but not repo/tag/asset/source URL/source hash/reason. The report then uses the current lockfile to describe installed versions, which can drift after a lock update. Record the lock identity into install state at apply time.
+12 -6
View File
@@ -215,6 +215,7 @@ def run_bootstrap(
native: bool | None = None, native: bool | None = None,
progress: Callable[[str], None] | None = None, progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS, ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
install_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
tell = progress or (lambda _message: None) tell = progress or (lambda _message: None)
use_native = is_windows() if native is None else native use_native = is_windows() if native is None else native
@@ -240,6 +241,7 @@ def run_bootstrap(
repo_root=repo_root, repo_root=repo_root,
selected={BSIPA_PLUGIN_ID}, selected={BSIPA_PLUGIN_ID},
require_bootstrap=False, require_bootstrap=False,
install_id=install_id,
) )
if not plan["changes"]: if not plan["changes"]:
raise ValueError("BSIPA bootstrap plan has no changes") raise ValueError("BSIPA bootstrap plan has no changes")
@@ -292,12 +294,14 @@ def run_bootstrap(
"delta": delta, "delta": delta,
"health": {}, "health": {},
} }
if install_id:
state["installId"] = install_id
if not use_native: if not use_native:
state["proton"] = str(proton or _default_proton()) state["proton"] = str(proton or _default_proton())
save_bootstrap_state(state_root, instance, state) save_bootstrap_state(state_root, instance, state, install_id=install_id)
state["health"] = check_bsipa_health(instance_path, state_root, instance) state["health"] = check_bsipa_health(instance_path, state_root, instance, install_id=install_id)
save_bootstrap_state(state_root, instance, state) save_bootstrap_state(state_root, instance, state, install_id=install_id)
state["statePath"] = str(bootstrap_state_path(state_root, instance)) state["statePath"] = str(bootstrap_state_path(state_root, instance, install_id=install_id))
if completed["timedOut"]: if completed["timedOut"]:
raise TimeoutError(f"IPA.exe -n timed out after {ipa_timeout_seconds}s; state written to {state['statePath']}") raise TimeoutError(f"IPA.exe -n timed out after {ipa_timeout_seconds}s; state written to {state['statePath']}")
if completed["returncode"] != 0: if completed["returncode"] != 0:
@@ -319,11 +323,12 @@ def ensure_healthy_bootstrap(
native: bool | None = None, native: bool | None = None,
progress: Callable[[str], None] | None = None, progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS, ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
install_id: str | None = None,
) -> None: ) -> None:
if not planning_requires_bootstrap(lockfile.plugins, selected_ids): if not planning_requires_bootstrap(lockfile.plugins, selected_ids):
return return
health = check_bsipa_health(instance_path, state_root, instance) health = check_bsipa_health(instance_path, state_root, instance, install_id=install_id)
if health["ok"]: if health["ok"]:
return return
@@ -341,8 +346,9 @@ def ensure_healthy_bootstrap(
native=native, native=native,
progress=progress, progress=progress,
ipa_timeout_seconds=ipa_timeout_seconds, ipa_timeout_seconds=ipa_timeout_seconds,
install_id=install_id,
) )
health = check_bsipa_health(instance_path, state_root, instance) health = check_bsipa_health(instance_path, state_root, instance, install_id=install_id)
if not health["ok"]: if not health["ok"]:
raise ValueError(bootstrap_health_error(health)) raise ValueError(bootstrap_health_error(health))
+10 -4
View File
@@ -33,8 +33,14 @@ def _native_bootstrap_satisfied(state: dict[str, Any]) -> bool:
) )
def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) -> dict[str, Any]: def check_bsipa_health(
state = load_bootstrap_state(state_root, instance) instance_path: Path,
state_root: Path,
instance: str,
*,
install_id: str | None = None,
) -> dict[str, Any]:
state = load_bootstrap_state(state_root, instance, install_id=install_id)
messages: list[str] = [] messages: list[str] = []
required = ["IPA.exe", "winhttp.dll"] required = ["IPA.exe", "winhttp.dll"]
@@ -57,12 +63,12 @@ def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) ->
messages.append("Logs/_latest.log does not show BSIPA startup") messages.append("Logs/_latest.log does not show BSIPA startup")
if not state: if not state:
messages.append(f"missing bootstrap state: {bootstrap_state_path(state_root, instance)}") messages.append(f"missing bootstrap state: {bootstrap_state_path(state_root, instance, install_id=install_id)}")
return { return {
"ok": not messages, "ok": not messages,
"messages": messages, "messages": messages,
"statePath": str(bootstrap_state_path(state_root, instance)), "statePath": str(bootstrap_state_path(state_root, instance, install_id=install_id)),
"logPath": str(log_path), "logPath": str(log_path),
"logSha256": sha256_file(log_path) if log_path.is_file() else None, "logSha256": sha256_file(log_path) if log_path.is_file() else None,
"bootstrapRecordedAt": state.get("updatedAt"), "bootstrapRecordedAt": state.get("updatedAt"),
+41 -17
View File
@@ -19,7 +19,7 @@ from .operations import enable_disabled_plugin, restore_known_good_set, save_kno
from .planner import create_plan from .planner import create_plan
from .reports import installed_plugins_report, print_installed_plugins from .reports import installed_plugins_report, print_installed_plugins
from .scanner import scan_instance from .scanner import scan_instance
from .state import load_installed_state from .state import installation_id, load_installed_state
from .update_runner import run_update from .update_runner import run_update
from .updates import check_updates from .updates import check_updates
from .userdata import restore_windows_data_repo, sync_windows_data_repo from .userdata import restore_windows_data_repo, sync_windows_data_repo
@@ -38,6 +38,7 @@ def print_updates(report: dict[str, Any]) -> None:
f"{summary.get('reviews', 0)} review, {summary.get('skipped', 0)} skipped, " f"{summary.get('reviews', 0)} review, {summary.get('skipped', 0)} skipped, "
f"{summary['warnings']} warnings, {summary['errors']} errors" f"{summary['warnings']} warnings, {summary['errors']} errors"
) )
if not plugins: if not plugins:
return return
@@ -63,6 +64,20 @@ def print_updates(report: dict[str, Any]) -> None:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row))) print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
def _installation_id_for(runtime: Any, instance_name: str, instance_path: Path) -> str:
return installation_id(
profile_id=getattr(runtime, "profile_id", None),
root=instance_path.parent,
instance=instance_name,
instance_path=instance_path,
)
def _get_installation(runtime: Any, instance_name: str) -> tuple[Any, str]:
instance = get_instance(runtime.instances_roots, instance_name)
return instance, _installation_id_for(runtime, instance_name, instance.path)
def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None: def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None:
default = argparse.SUPPRESS if suppress_default else None default = argparse.SUPPRESS if suppress_default else None
parser.add_argument("--config", default=default, help="plugin-helper config TOML path") parser.add_argument("--config", default=default, help="plugin-helper config TOML path")
@@ -277,12 +292,13 @@ def _run_menu(
return 1 return 1
choices: list[InstallationChoice] = [] choices: list[InstallationChoice] = []
for index, root in enumerate(runtime.instances_roots, start=1): for root in runtime.instances_roots:
install_label = str(root) if len(runtime.instances_roots) > 1 else "Default" install_label = str(root) if len(runtime.instances_roots) > 1 else "Default"
for instance in list_instances(root): for instance in list_instances(root):
install_id = _installation_id_for(runtime, instance.name, instance.path)
choices.append( choices.append(
InstallationChoice( InstallationChoice(
install_id=f"root-{index}", install_id=install_id,
install_label=install_label, install_label=install_label,
instance_name=instance.name, instance_name=instance.name,
instance_path=instance.path, instance_path=instance.path,
@@ -366,17 +382,19 @@ def run(argv: list[str] | None = None) -> int:
return 0 return 0
if args.command == "state": if args.command == "state":
_json(load_installed_state(st_root, args.instance)) _instance, install_id = _get_installation(runtime, args.instance)
_json(load_installed_state(st_root, args.instance, install_id=install_id))
return 0 return 0
if args.command == "installed": if args.command == "installed":
instance, install_id = _get_installation(runtime, args.instance)
root = repo_root() root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) 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" lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
if not lock_path.is_absolute(): if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve() lock_path = (root / lock_path).resolve()
result = installed_plugins_report( result = installed_plugins_report(
installed_state=load_installed_state(st_root, args.instance), installed_state=load_installed_state(st_root, instance.name, install_id=install_id),
registry=load_registry(registry_path), registry=load_registry(registry_path),
lockfile=load_lockfile(lock_path), lockfile=load_lockfile(lock_path),
) )
@@ -436,7 +454,7 @@ def run(argv: list[str] | None = None) -> int:
return 2 if result["summary"]["errors"] else 0 return 2 if result["summary"]["errors"] else 0
if args.command == "update": if args.command == "update":
instance = get_instance(inst_roots, args.instance) instance, install_id = _get_installation(runtime, args.instance)
root = repo_root() root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) 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" lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
@@ -456,6 +474,7 @@ def run(argv: list[str] | None = None) -> int:
include_prerelease=args.include_prerelease, include_prerelease=args.include_prerelease,
dry_run=args.dry_run, dry_run=args.dry_run,
apply=args.apply, apply=args.apply,
install_id=install_id,
) )
if args.json: if args.json:
_json(result) _json(result)
@@ -480,7 +499,7 @@ def run(argv: list[str] | None = None) -> int:
return 0 if not result["refused"] else 2 return 0 if not result["refused"] else 2
if args.command == "bootstrap": if args.command == "bootstrap":
instance = get_instance(inst_roots, args.instance) instance, install_id = _get_installation(runtime, args.instance)
root = repo_root() root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) 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" lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
@@ -498,6 +517,7 @@ def run(argv: list[str] | None = None) -> int:
proton=Path(args.proton).expanduser() if args.proton else None, proton=Path(args.proton).expanduser() if args.proton else None,
native=True if args.native else None, native=True if args.native else None,
progress=lambda message: print(f" {message}", flush=True), progress=lambda message: print(f" {message}", flush=True),
install_id=install_id,
) )
if args.json: if args.json:
_json(result) _json(result)
@@ -517,8 +537,8 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["health"]["ok"] else 2 return 0 if result["health"]["ok"] else 2
if args.command == "bootstrap-check": if args.command == "bootstrap-check":
instance = get_instance(inst_roots, args.instance) instance, install_id = _get_installation(runtime, args.instance)
result = check_bsipa_health(instance.path, st_root, args.instance) result = check_bsipa_health(instance.path, st_root, args.instance, install_id=install_id)
if args.json: if args.json:
_json(result) _json(result)
else: else:
@@ -530,7 +550,7 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["ok"] else 2 return 0 if result["ok"] else 2
if args.command == "plan": if args.command == "plan":
instance = get_instance(inst_roots, args.instance) instance, install_id = _get_installation(runtime, args.instance)
root = repo_root() root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) 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" lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
@@ -548,6 +568,7 @@ def run(argv: list[str] | None = None) -> int:
state_root=st_root, state_root=st_root,
repo_root=root, repo_root=root,
selected=selected, selected=selected,
install_id=install_id,
) )
print(f"Wrote plan: {path}") print(f"Wrote plan: {path}")
print(f"Changes: {len(plan['changes'])}") print(f"Changes: {len(plan['changes'])}")
@@ -564,8 +585,8 @@ def run(argv: list[str] | None = None) -> int:
return 0 return 0
if args.command == "uninstall": if args.command == "uninstall":
instance = get_instance(inst_roots, args.instance) instance, install_id = _get_installation(runtime, args.instance)
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force) result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force, install_id=install_id)
print(f"Removed: {len(result['removed'])}") print(f"Removed: {len(result['removed'])}")
if result["skipped"]: if result["skipped"]:
print("Skipped:") print("Skipped:")
@@ -574,8 +595,8 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["stateUpdated"] else 2 return 0 if result["stateUpdated"] else 2
if args.command == "disable": if args.command == "disable":
instance = get_instance(inst_roots, args.instance) instance, install_id = _get_installation(runtime, args.instance)
result = disable_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force) result = disable_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force, install_id=install_id)
print(f"Disabled: {args.plugin}") print(f"Disabled: {args.plugin}")
print(f"Removed: {len(result['removed'])}") print(f"Removed: {len(result['removed'])}")
if result["skipped"]: if result["skipped"]:
@@ -585,7 +606,7 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["stateUpdated"] else 2 return 0 if result["stateUpdated"] else 2
if args.command == "enable": if args.command == "enable":
instance = get_instance(inst_roots, args.instance) instance, install_id = _get_installation(runtime, args.instance)
progress = (lambda message: print(f" {message}", flush=True)) progress = (lambda message: print(f" {message}", flush=True))
result = enable_disabled_plugin( result = enable_disabled_plugin(
instance=args.instance, instance=args.instance,
@@ -595,6 +616,7 @@ def run(argv: list[str] | None = None) -> int:
registry=args.registry, registry=args.registry,
lockfile=args.lockfile, lockfile=args.lockfile,
progress=progress, progress=progress,
install_id=install_id,
) )
print(f"Enabled: {args.plugin}") print(f"Enabled: {args.plugin}")
print(f"Plan: {result['planPath']}") print(f"Plan: {result['planPath']}")
@@ -603,7 +625,8 @@ def run(argv: list[str] | None = None) -> int:
return 0 return 0
if args.command == "save-known-good": if args.command == "save-known-good":
result = save_known_good_set(instance=args.instance, state_root=st_root) _instance, install_id = _get_installation(runtime, args.instance)
result = save_known_good_set(instance=args.instance, state_root=st_root, install_id=install_id)
if args.json: if args.json:
_json(result) _json(result)
else: else:
@@ -613,7 +636,7 @@ def run(argv: list[str] | None = None) -> int:
return 0 return 0
if args.command == "restore-known-good": if args.command == "restore-known-good":
instance = get_instance(inst_roots, args.instance) instance, install_id = _get_installation(runtime, args.instance)
progress = (lambda message: print(f" {message}", flush=True)) progress = (lambda message: print(f" {message}", flush=True))
result = restore_known_good_set( result = restore_known_good_set(
instance=args.instance, instance=args.instance,
@@ -622,6 +645,7 @@ def run(argv: list[str] | None = None) -> int:
registry=args.registry, registry=args.registry,
lockfile=args.lockfile, lockfile=args.lockfile,
progress=progress, progress=progress,
install_id=install_id,
) )
if args.json: if args.json:
_json(result) _json(result)
+11 -1
View File
@@ -189,7 +189,17 @@ def resolve_runtime_config(
) -> RuntimeConfig: ) -> RuntimeConfig:
repo = root or repo_root() repo = root or repo_root()
local_config, loaded_path, loaded = load_local_config(config_path_value, root=repo) local_config, loaded_path, loaded = load_local_config(config_path_value, root=repo)
profile = _select_profile(local_config, profile_id) explicit_runtime_paths = bool(
instances_root_value
or state_dir_value
or _env_instances_roots(repo)
or _env_state_root(repo)
)
profile = (
_select_profile(local_config, profile_id)
if profile_id or not explicit_runtime_paths
else None
)
resolved_instances = ( resolved_instances = (
_resolve_path_list(instances_root_value, repo) _resolve_path_list(instances_root_value, repo)
+28 -11
View File
@@ -7,7 +7,7 @@ from typing import Any
from zipfile import ZipFile from zipfile import ZipFile
from .fsutil import ensure_inside, ensure_relative, sha256_bytes, sha256_file from .fsutil import ensure_inside, ensure_relative, sha256_bytes, sha256_file
from .state import backups_dir, load_installed_state, save_installed_state from .state import backups_dir, installed_state_path, load_installed_state, save_installed_state
def _timestamp() -> str: def _timestamp() -> str:
@@ -26,12 +26,13 @@ def _backup_existing(instance_path: Path, backup_root: Path, rel_target: str) ->
def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]: def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]:
instance = plan["instance"] instance = plan["instance"]
install_id = plan.get("installId")
instance_path = Path(plan["instancePath"]) instance_path = Path(plan["instancePath"])
if not instance_path.is_dir(): if not instance_path.is_dir():
raise FileNotFoundError(f"instance path does not exist: {instance_path}") raise FileNotFoundError(f"instance path does not exist: {instance_path}")
backup_root = backups_dir(state_root, instance) / f"apply-{_timestamp()}" backup_root = backups_dir(state_root, instance, install_id=install_id) / f"apply-{_timestamp()}"
installed_state = load_installed_state(state_root, instance) installed_state = load_installed_state(state_root, instance, install_id=install_id)
installed_state.setdefault("beatSaberVersion", plan.get("beatSaberVersion")) installed_state.setdefault("beatSaberVersion", plan.get("beatSaberVersion"))
installed_state.setdefault("plugins", {}) installed_state.setdefault("plugins", {})
@@ -80,12 +81,20 @@ def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]:
installed_state.setdefault("disabledPlugins", {}).pop(change["plugin"], None) installed_state.setdefault("disabledPlugins", {}).pop(change["plugin"], None)
applied.append({"path": rel_target, "plugin": change["plugin"], "backup": backup}) applied.append({"path": rel_target, "plugin": change["plugin"], "backup": backup})
save_installed_state(state_root, instance, installed_state) save_installed_state(state_root, instance, installed_state, install_id=install_id)
return {"applied": applied, "statePath": str(state_root / "instances" / instance / "installed.json")} return {"applied": applied, "statePath": str(installed_state_path(state_root, instance, install_id=install_id))}
def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugin_id: str, force: bool = False) -> dict[str, Any]: def uninstall_plugin(
installed_state = load_installed_state(state_root, instance) instance: str,
instance_path: Path,
state_root: Path,
plugin_id: str,
force: bool = False,
*,
install_id: str | None = None,
) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance, install_id=install_id)
plugin_state = installed_state.get("plugins", {}).get(plugin_id) plugin_state = installed_state.get("plugins", {}).get(plugin_id)
if not plugin_state: if not plugin_state:
raise KeyError(f"plugin is not recorded in managed install state: {plugin_id}") raise KeyError(f"plugin is not recorded in managed install state: {plugin_id}")
@@ -109,12 +118,20 @@ def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugi
return {"removed": removed, "skipped": skipped, "stateUpdated": False} return {"removed": removed, "skipped": skipped, "stateUpdated": False}
installed_state.get("plugins", {}).pop(plugin_id, None) installed_state.get("plugins", {}).pop(plugin_id, None)
save_installed_state(state_root, instance, installed_state) save_installed_state(state_root, instance, installed_state, install_id=install_id)
return {"removed": removed, "skipped": skipped, "stateUpdated": True} 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]: def disable_plugin(
installed_state = load_installed_state(state_root, instance) instance: str,
instance_path: Path,
state_root: Path,
plugin_id: str,
force: bool = False,
*,
install_id: str | None = None,
) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance, install_id=install_id)
plugin_state = installed_state.get("plugins", {}).get(plugin_id) plugin_state = installed_state.get("plugins", {}).get(plugin_id)
if not plugin_state: if not plugin_state:
if plugin_id in installed_state.get("disabledPlugins", {}): if plugin_id in installed_state.get("disabledPlugins", {}):
@@ -143,5 +160,5 @@ def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_
disabled_state["disabledAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") disabled_state["disabledAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
installed_state.setdefault("disabledPlugins", {})[plugin_id] = disabled_state installed_state.setdefault("disabledPlugins", {})[plugin_id] = disabled_state
installed_state.get("plugins", {}).pop(plugin_id, None) installed_state.get("plugins", {}).pop(plugin_id, None)
save_installed_state(state_root, instance, installed_state) save_installed_state(state_root, instance, installed_state, install_id=install_id)
return {"removed": removed, "skipped": skipped, "stateUpdated": True} return {"removed": removed, "skipped": skipped, "stateUpdated": True}
+19 -9
View File
@@ -39,8 +39,9 @@ def enable_disabled_plugin(
lockfile: str | None = None, lockfile: str | None = None,
repo: Path | None = None, repo: Path | None = None,
progress: Callable[[str], None] | None = None, progress: Callable[[str], None] | None = None,
install_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance) installed_state = load_installed_state(state_root, instance, install_id=install_id)
if plugin_id in installed_state.get("plugins", {}): if plugin_id in installed_state.get("plugins", {}):
raise KeyError(f"plugin is already enabled: {plugin_id}") raise KeyError(f"plugin is already enabled: {plugin_id}")
@@ -65,6 +66,7 @@ def enable_disabled_plugin(
repo_root=root, repo_root=root,
selected={plugin_id}, selected={plugin_id},
require_bootstrap=False, require_bootstrap=False,
install_id=install_id,
) )
ensure_healthy_bootstrap( ensure_healthy_bootstrap(
@@ -77,6 +79,7 @@ def enable_disabled_plugin(
repo_root=root, repo_root=root,
selected_ids={plugin_id}, selected_ids={plugin_id},
progress=progress, progress=progress,
install_id=install_id,
) )
tell(f"Applying {plugin_id}") tell(f"Applying {plugin_id}")
@@ -94,6 +97,7 @@ def enable_disabled_plugins(
lockfile: str | None = None, lockfile: str | None = None,
repo: Path | None = None, repo: Path | None = None,
progress: Callable[[str], None] | None = None, progress: Callable[[str], None] | None = None,
install_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not plugin_ids: if not plugin_ids:
return {"enabled": [], "errors": []} return {"enabled": [], "errors": []}
@@ -104,7 +108,7 @@ def enable_disabled_plugins(
lockfile=lockfile, lockfile=lockfile,
repo=repo, repo=repo,
) )
installed_state = load_installed_state(state_root, instance) installed_state = load_installed_state(state_root, instance, install_id=install_id)
enabled_plugins = installed_state.get("plugins", {}) enabled_plugins = installed_state.get("plugins", {})
locked_ids = {plugin.id for plugin in loaded_lockfile.plugins} locked_ids = {plugin.id for plugin in loaded_lockfile.plugins}
@@ -136,6 +140,7 @@ def enable_disabled_plugins(
repo_root=root, repo_root=root,
selected=selected_ids, selected=selected_ids,
require_bootstrap=False, require_bootstrap=False,
install_id=install_id,
) )
ensure_healthy_bootstrap( ensure_healthy_bootstrap(
@@ -148,6 +153,7 @@ def enable_disabled_plugins(
repo_root=root, repo_root=root,
selected_ids=selected_ids, selected_ids=selected_ids,
progress=progress, progress=progress,
install_id=install_id,
) )
tell(f"Applying {len(plannable_ids)} plugins") tell(f"Applying {len(plannable_ids)} plugins")
@@ -163,16 +169,18 @@ def enable_disabled_plugins(
return {"enabled": enabled, "errors": errors} return {"enabled": enabled, "errors": errors}
def save_known_good_set(*, instance: str, state_root: Path) -> dict[str, Any]: def save_known_good_set(*, instance: str, state_root: Path, install_id: str | None = None) -> dict[str, Any]:
"""Record the currently enabled plugin ids as the known-good set for this instance.""" """Record the currently enabled plugin ids as the known-good set for this instance."""
installed_state = load_installed_state(state_root, instance) installed_state = load_installed_state(state_root, instance, install_id=install_id)
plugin_ids = sorted(installed_state.get("plugins", {})) plugin_ids = sorted(installed_state.get("plugins", {}))
known_good = { known_good = {
"instance": instance, "instance": instance,
"beatSaberVersion": installed_state.get("beatSaberVersion"), "beatSaberVersion": installed_state.get("beatSaberVersion"),
"pluginIds": plugin_ids, "pluginIds": plugin_ids,
} }
save_known_good_state(state_root, instance, known_good) if install_id:
known_good["installId"] = install_id
save_known_good_state(state_root, instance, known_good, install_id=install_id)
return known_good return known_good
@@ -185,14 +193,15 @@ def restore_known_good_set(
lockfile: str | None = None, lockfile: str | None = None,
repo: Path | None = None, repo: Path | None = None,
progress: Callable[[str], None] | None = None, progress: Callable[[str], None] | None = None,
install_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Disable plugins outside the saved known-good set and re-enable the ones that are missing.""" """Disable plugins outside the saved known-good set and re-enable the ones that are missing."""
known_good = load_known_good_state(state_root, instance) known_good = load_known_good_state(state_root, instance, install_id=install_id)
known_good_ids = set(known_good.get("pluginIds", [])) known_good_ids = set(known_good.get("pluginIds", []))
if not known_good: if not known_good:
raise KeyError(f"no known-good set is saved for this instance: {instance}") raise KeyError(f"no known-good set is saved for this instance: {instance}")
installed_state = load_installed_state(state_root, instance) installed_state = load_installed_state(state_root, instance, install_id=install_id)
currently_enabled = set(installed_state.get("plugins", {})) currently_enabled = set(installed_state.get("plugins", {}))
to_disable = sorted(currently_enabled - known_good_ids) to_disable = sorted(currently_enabled - known_good_ids)
@@ -200,7 +209,7 @@ def restore_known_good_set(
disable_errors: list[dict[str, str]] = [] disable_errors: list[dict[str, str]] = []
for plugin_id in to_disable: for plugin_id in to_disable:
try: try:
result = disable_plugin(instance, instance_path, state_root, plugin_id, False) result = disable_plugin(instance, instance_path, state_root, plugin_id, False, install_id=install_id)
if result["stateUpdated"]: if result["stateUpdated"]:
disabled.append(plugin_id) disabled.append(plugin_id)
else: else:
@@ -209,7 +218,7 @@ def restore_known_good_set(
except Exception as exc: except Exception as exc:
disable_errors.append({"plugin": plugin_id, "error": str(exc)}) disable_errors.append({"plugin": plugin_id, "error": str(exc)})
installed_state = load_installed_state(state_root, instance) installed_state = load_installed_state(state_root, instance, install_id=install_id)
currently_enabled = set(installed_state.get("plugins", {})) currently_enabled = set(installed_state.get("plugins", {}))
to_enable = sorted(known_good_ids - currently_enabled) to_enable = sorted(known_good_ids - currently_enabled)
enable_result = enable_disabled_plugins( enable_result = enable_disabled_plugins(
@@ -221,6 +230,7 @@ def restore_known_good_set(
lockfile=lockfile, lockfile=lockfile,
repo=repo, repo=repo,
progress=progress, progress=progress,
install_id=install_id,
) )
return { return {
+10 -3
View File
@@ -10,7 +10,11 @@ from zipfile import ZipFile
from .fsutil import ensure_relative, sha256_bytes, sha256_file from .fsutil import ensure_relative, sha256_bytes, sha256_file
from .models import Lockfile, Registry, VALID_STRATEGIES from .models import Lockfile, Registry, VALID_STRATEGIES
from .bsipa import bootstrap_health_error, check_bsipa_health, planning_requires_bootstrap from .bsipa import bootstrap_health_error, check_bsipa_health, planning_requires_bootstrap
from .state import downloads_dir, plans_dir, plugin_downloads_dir from .state import (
downloads_dir,
plans_dir,
plugin_downloads_dir,
)
ALLOWED_BSIPA_TOP_LEVEL = {"IPA", "Libs", "Plugins"} ALLOWED_BSIPA_TOP_LEVEL = {"IPA", "Libs", "Plugins"}
@@ -96,6 +100,7 @@ def create_plan(
repo_root: Path, repo_root: Path,
selected: set[str] | None = None, selected: set[str] | None = None,
require_bootstrap: bool = True, require_bootstrap: bool = True,
install_id: str | None = None,
) -> tuple[dict[str, Any], Path]: ) -> tuple[dict[str, Any], Path]:
selected_ids = ( selected_ids = (
_expand_required_dependencies(selected, registry, lockfile) _expand_required_dependencies(selected, registry, lockfile)
@@ -106,7 +111,7 @@ def create_plan(
warnings: list[str] = [] warnings: list[str] = []
if require_bootstrap and planning_requires_bootstrap(lockfile.plugins, selected_ids): if require_bootstrap and planning_requires_bootstrap(lockfile.plugins, selected_ids):
health = check_bsipa_health(instance_path, state_root, instance) health = check_bsipa_health(instance_path, state_root, instance, install_id=install_id)
if not health["ok"]: if not health["ok"]:
raise ValueError(bootstrap_health_error(health)) raise ValueError(bootstrap_health_error(health))
@@ -175,7 +180,9 @@ def create_plan(
"warnings": warnings, "warnings": warnings,
"changes": changes, "changes": changes,
} }
plan_path = plans_dir(state_root, instance) / f"plan-{_now_slug()}.json" if install_id:
plan["installId"] = install_id
plan_path = plans_dir(state_root, instance, install_id=install_id) / f"plan-{_now_slug()}.json"
with plan_path.open("w", encoding="utf-8") as handle: with plan_path.open("w", encoding="utf-8") as handle:
json.dump(plan, handle, indent=2, sort_keys=True) json.dump(plan, handle, indent=2, sort_keys=True)
handle.write("\n") handle.write("\n")
+87 -33
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -7,49 +9,97 @@ from typing import Any
from .fsutil import atomic_write_json, read_json from .fsutil import atomic_write_json, read_json
def instance_state_dir(state_root: Path, instance: str) -> Path: def _slug(value: str) -> str:
return state_root / "instances" / instance slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip()).strip("-._")
return slug[:48] or "install"
def installed_state_path(state_root: Path, instance: str) -> Path: def installation_id(
return instance_state_dir(state_root, instance) / "installed.json" *,
profile_id: str | None,
root: Path | None,
def bootstrap_state_path(state_root: Path, instance: str) -> Path: instance: str,
return instance_state_dir(state_root, instance) / "bootstrap.json" instance_path: Path,
) -> str:
resolved_root = (
def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]: root.expanduser().resolve(strict=False)
return read_json( if root
installed_state_path(state_root, instance), else instance_path.parent.expanduser().resolve(strict=False)
{"instance": instance, "plugins": {}},
) )
resolved_path = instance_path.expanduser().resolve(strict=False)
profile = profile_id or "default"
digest = hashlib.sha256(
f"{profile}\0{resolved_root}\0{resolved_path}".encode("utf-8")
).hexdigest()[:12]
return f"{_slug(profile)}-{_slug(instance)}-{digest}"
def load_bootstrap_state(state_root: Path, instance: str) -> dict[str, Any]: def instance_state_dir(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
return read_json(bootstrap_state_path(state_root, instance), {}) if install_id:
return state_root / "installs" / install_id
return state_root / "installs" / _slug(instance)
def save_bootstrap_state(state_root: Path, instance: str, state: dict[str, Any]) -> None: def installed_state_path(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
return instance_state_dir(state_root, instance, install_id=install_id) / "installed.json"
def bootstrap_state_path(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
return instance_state_dir(state_root, instance, install_id=install_id) / "bootstrap.json"
def known_good_state_path(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
return instance_state_dir(state_root, instance, install_id=install_id) / "known-good.json"
def load_installed_state(state_root: Path, instance: str, *, install_id: str | None = None) -> dict[str, Any]:
default = {"instance": instance, "plugins": {}}
state = read_json(installed_state_path(state_root, instance, install_id=install_id), default)
if install_id:
state.setdefault("installId", install_id)
return state
def load_bootstrap_state(state_root: Path, instance: str, *, install_id: str | None = None) -> dict[str, Any]:
return read_json(bootstrap_state_path(state_root, instance, install_id=install_id), {})
def save_bootstrap_state(
state_root: Path,
instance: str,
state: dict[str, Any],
*,
install_id: str | None = None,
) -> None:
state.setdefault("instance", instance) state.setdefault("instance", instance)
if install_id:
state.setdefault("installId", install_id)
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
atomic_write_json(bootstrap_state_path(state_root, instance), state) atomic_write_json(bootstrap_state_path(state_root, instance, install_id=install_id), state)
def save_installed_state(state_root: Path, instance: str, state: dict[str, Any]) -> None: def save_installed_state(
state_root: Path,
instance: str,
state: dict[str, Any],
*,
install_id: str | None = None,
) -> None:
state.setdefault("instance", instance) state.setdefault("instance", instance)
if install_id:
state.setdefault("installId", install_id)
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
atomic_write_json(installed_state_path(state_root, instance), state) atomic_write_json(installed_state_path(state_root, instance, install_id=install_id), state)
def plans_dir(state_root: Path, instance: str) -> Path: def plans_dir(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
path = instance_state_dir(state_root, instance) / "plans" path = instance_state_dir(state_root, instance, install_id=install_id) / "plans"
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
return path return path
def downloads_dir(state_root: Path, instance: str) -> Path: def downloads_dir(state_root: Path, instance: str) -> Path:
path = instance_state_dir(state_root, instance) / "downloads" path = state_root / "cache" / "downloads" / instance
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
return path return path
@@ -60,21 +110,25 @@ def plugin_downloads_dir(state_root: Path, instance: str, plugin_id: str) -> Pat
return path return path
def backups_dir(state_root: Path, instance: str) -> Path: def backups_dir(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
path = instance_state_dir(state_root, instance) / "backups" path = instance_state_dir(state_root, instance, install_id=install_id) / "backups"
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
return path return path
def known_good_state_path(state_root: Path, instance: str) -> Path: def load_known_good_state(state_root: Path, instance: str, *, install_id: str | None = None) -> dict[str, Any]:
return instance_state_dir(state_root, instance) / "known-good.json" return read_json(known_good_state_path(state_root, instance, install_id=install_id), {})
def load_known_good_state(state_root: Path, instance: str) -> dict[str, Any]: def save_known_good_state(
return read_json(known_good_state_path(state_root, instance), {}) state_root: Path,
instance: str,
state: dict[str, Any],
def save_known_good_state(state_root: Path, instance: str, state: dict[str, Any]) -> None: *,
install_id: str | None = None,
) -> None:
state.setdefault("instance", instance) state.setdefault("instance", instance)
if install_id:
state.setdefault("installId", install_id)
state["savedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") state["savedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
atomic_write_json(known_good_state_path(state_root, instance), state) atomic_write_json(known_good_state_path(state_root, instance, install_id=install_id), state)
+14 -5
View File
@@ -20,7 +20,7 @@ from .operations import (
save_known_good_set, save_known_good_set,
) )
from .reports import installed_plugins_report from .reports import installed_plugins_report
from .state import load_installed_state, load_known_good_state from .state import instance_state_dir, load_installed_state, load_known_good_state
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -133,6 +133,7 @@ class PluginHelperTui(App[int]):
target.state_root, target.state_root,
plugin_id, plugin_id,
False, False,
install_id=target.install_id,
) )
if not result["stateUpdated"]: if not result["stateUpdated"]:
self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}") self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}")
@@ -148,6 +149,7 @@ class PluginHelperTui(App[int]):
plugin_id=plugin_id, plugin_id=plugin_id,
repo=self.repo_root, repo=self.repo_root,
progress=self._operation_progress, progress=self._operation_progress,
install_id=target.install_id,
) )
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.") self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
else: else:
@@ -190,6 +192,7 @@ class PluginHelperTui(App[int]):
target.state_root, target.state_root,
plugin_id, plugin_id,
False, False,
install_id=target.install_id,
) )
if result["stateUpdated"]: if result["stateUpdated"]:
changed += 1 changed += 1
@@ -225,6 +228,7 @@ class PluginHelperTui(App[int]):
plugin_ids=plugin_ids, plugin_ids=plugin_ids,
repo=self.repo_root, repo=self.repo_root,
progress=self._operation_progress, progress=self._operation_progress,
install_id=target.install_id,
) )
except Exception as exc: except Exception as exc:
self._set_status(f"Could not enable plugins: {exc}") self._set_status(f"Could not enable plugins: {exc}")
@@ -240,7 +244,11 @@ class PluginHelperTui(App[int]):
if self._busy or self.mode != "plugins" or self.selected_installation is None: if self._busy or self.mode != "plugins" or self.selected_installation is None:
return return
target = self.selected_installation target = self.selected_installation
known_good = save_known_good_set(instance=target.instance_name, state_root=target.state_root) known_good = save_known_good_set(
instance=target.instance_name,
state_root=target.state_root,
install_id=target.install_id,
)
count = len(known_good["pluginIds"]) count = len(known_good["pluginIds"])
self._set_status(f"Saved known-good set: {count} enabled plugins.") self._set_status(f"Saved known-good set: {count} enabled plugins.")
@@ -248,7 +256,7 @@ class PluginHelperTui(App[int]):
if self._busy or self.mode != "plugins" or self.selected_installation is None: if self._busy or self.mode != "plugins" or self.selected_installation is None:
return return
target = self.selected_installation target = self.selected_installation
if not load_known_good_state(target.state_root, target.instance_name): if not load_known_good_state(target.state_root, target.instance_name, install_id=target.install_id):
self._set_status("No known-good set saved yet. Press s to save the current selection.") self._set_status("No known-good set saved yet. Press s to save the current selection.")
return return
self._busy = True self._busy = True
@@ -261,6 +269,7 @@ class PluginHelperTui(App[int]):
state_root=target.state_root, state_root=target.state_root,
repo=self.repo_root, repo=self.repo_root,
progress=self._operation_progress, progress=self._operation_progress,
install_id=target.install_id,
) )
except Exception as exc: except Exception as exc:
self._set_status(f"Could not restore known-good set: {exc}") self._set_status(f"Could not restore known-good set: {exc}")
@@ -298,7 +307,7 @@ class PluginHelperTui(App[int]):
choice.install_label, choice.install_label,
choice.instance_name, choice.instance_name,
str(choice.instance_path), str(choice.instance_path),
str(choice.state_root), str(instance_state_dir(choice.state_root, choice.instance_name, install_id=choice.install_id)),
) )
if self.setup_hint: if self.setup_hint:
self._set_status(self.setup_hint) self._set_status(self.setup_hint)
@@ -322,7 +331,7 @@ class PluginHelperTui(App[int]):
try: try:
lockfile = load_lockfile(self.repo_root / "locks" / f"{target.instance_name}.lock.toml") lockfile = load_lockfile(self.repo_root / "locks" / f"{target.instance_name}.lock.toml")
report = installed_plugins_report( report = installed_plugins_report(
installed_state=load_installed_state(target.state_root, target.instance_name), installed_state=load_installed_state(target.state_root, target.instance_name, install_id=target.install_id),
registry=load_registry(self.repo_root / "registry" / "plugins"), registry=load_registry(self.repo_root / "registry" / "plugins"),
lockfile=lockfile, lockfile=lockfile,
) )
+2
View File
@@ -91,6 +91,7 @@ def run_update(
apply: bool = False, apply: bool = False,
download_asset: DownloadAsset = _download_asset, download_asset: DownloadAsset = _download_asset,
apply_plan_func: ApplyPlan = apply_plan, apply_plan_func: ApplyPlan = apply_plan,
install_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not selected: if not selected:
raise ValueError("update requires at least one --plugin") raise ValueError("update requires at least one --plugin")
@@ -224,6 +225,7 @@ def run_update(
state_root=state_root, state_root=state_root,
repo_root=repo_root, repo_root=repo_root,
selected=set(replacements), selected=set(replacements),
install_id=install_id,
) )
result["planPath"] = str(plan_path) result["planPath"] = str(plan_path)
+10 -2
View File
@@ -36,13 +36,19 @@ DEFAULT_BACKUP_EXCLUDES = (
) )
def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]: def backup_userdata(
instance: str,
instance_path: Path,
state_root: Path,
*,
install_id: str | None = None,
) -> dict[str, Any]:
source = instance_path / "UserData" source = instance_path / "UserData"
if not source.is_dir(): if not source.is_dir():
raise FileNotFoundError(f"UserData directory not found: {source}") raise FileNotFoundError(f"UserData directory not found: {source}")
created_at = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") created_at = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
destination = backups_dir(state_root, instance) / f"userdata-{created_at}.tar.gz" destination = backups_dir(state_root, instance, install_id=install_id) / f"userdata-{created_at}.tar.gz"
files: list[dict[str, Any]] = [] files: list[dict[str, Any]] = []
total_size = 0 total_size = 0
for path in sorted(item for item in source.rglob("*") if item.is_file()): for path in sorted(item for item in source.rglob("*") if item.is_file()):
@@ -59,6 +65,8 @@ def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dic
"totalSize": total_size, "totalSize": total_size,
"files": files, "files": files,
} }
if install_id:
manifest["installId"] = install_id
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
manifest_path = destination.parent / f".{destination.name}.manifest.json" manifest_path = destination.parent / f".{destination.name}.manifest.json"
+125 -12
View File
@@ -26,7 +26,14 @@ from plugin_helper.models import Dependency, Lockfile, LockedPlugin, Registry, R
from plugin_helper.operations import enable_disabled_plugin from plugin_helper.operations import enable_disabled_plugin
from plugin_helper.planner import create_plan from plugin_helper.planner import create_plan
from plugin_helper.scanner import scan_bootstrap_files, scan_instance 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, save_installed_state from plugin_helper.state import (
downloads_dir,
installation_id,
load_installed_state,
plugin_downloads_dir,
save_bootstrap_state,
save_installed_state,
)
from plugin_helper.tui import InstallationChoice, PluginHelperTui from plugin_helper.tui import InstallationChoice, PluginHelperTui
from plugin_helper.update_runner import run_update from plugin_helper.update_runner import run_update
from plugin_helper.updates import check_updates from plugin_helper.updates import check_updates
@@ -863,10 +870,112 @@ sha256 = "{sha256_file(asset)}"
self.assertEqual(status, 0) self.assertEqual(status, 0)
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8") install_id = installation_id(
profile_id=None,
root=instance_root,
instance="1.40.8",
instance_path=instance,
)
updated = load_installed_state(state, "1.40.8", install_id=install_id)
self.assertIn("example", updated["plugins"]) self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated["disabledPlugins"]) self.assertNotIn("example", updated["disabledPlugins"])
def test_install_states_are_separate_for_duplicate_instance_names(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
linux_root = work / "linux"
windows_root = work / "windows"
linux_instance = linux_root / "1.40.8"
windows_instance = windows_root / "1.40.8"
state = work / "state"
registry_dir = work / "registry"
locks_dir = work / "locks"
registry_dir.mkdir()
locks_dir.mkdir()
for instance in (linux_instance, windows_instance):
(instance / "Beat Saber_Data").mkdir(parents=True)
(instance / "Plugins").mkdir()
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
self.assertEqual(asset.parent, state / "cache" / "downloads" / "1.40.8" / "example")
(registry_dir / "plugins.toml").write_text(
"""
[[plugins]]
id = "example"
name = "Example"
repo = "owner/example"
asset_patterns = ["*.dll"]
install_strategy = "dll-to-plugins"
""".lstrip(),
encoding="utf-8",
)
(locks_dir / "1.40.8.lock.toml").write_text(
f"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
[[plugins]]
id = "example"
repo = "owner/example"
tag = "v1.0.0"
asset = "Example.dll"
sha256 = "{sha256_file(asset)}"
""".lstrip(),
encoding="utf-8",
)
linux_id = installation_id(
profile_id="shared",
root=linux_root,
instance="1.40.8",
instance_path=linux_instance,
)
windows_id = installation_id(
profile_id="shared",
root=windows_root,
instance="1.40.8",
instance_path=windows_instance,
)
disabled_state = {
"instance": "1.40.8",
"plugins": {},
"disabledPlugins": {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"disabledAt": "2026-06-14T17:20:00Z",
"files": [
{
"path": "Plugins/Example.dll",
"sha256": sha256_file(asset),
"size": asset.stat().st_size,
}
],
}
},
}
save_installed_state(state, "1.40.8", json.loads(json.dumps(disabled_state)), install_id=linux_id)
save_installed_state(state, "1.40.8", json.loads(json.dumps(disabled_state)), install_id=windows_id)
with patch("plugin_helper.operations.repo_root", return_value=work):
result = enable_disabled_plugin(
instance="1.40.8",
instance_path=linux_instance,
state_root=state,
plugin_id="example",
repo=work,
install_id=linux_id,
)
self.assertTrue((linux_instance / "Plugins" / "Example.dll").is_file())
self.assertFalse((windows_instance / "Plugins" / "Example.dll").exists())
self.assertIn(f"installs/{linux_id}/plans", result["planPath"])
linux_state = load_installed_state(state, "1.40.8", install_id=linux_id)
windows_state = load_installed_state(state, "1.40.8", install_id=windows_id)
self.assertIn("example", linux_state["plugins"])
self.assertNotIn("example", linux_state["disabledPlugins"])
self.assertEqual(windows_state["plugins"], {})
self.assertIn("example", windows_state["disabledPlugins"])
def test_save_and_restore_known_good_set(self) -> None: def test_save_and_restore_known_good_set(self) -> None:
from plugin_helper.operations import restore_known_good_set, save_known_good_set from plugin_helper.operations import restore_known_good_set, save_known_good_set
from plugin_helper.state import load_known_good_state, save_installed_state from plugin_helper.state import load_known_good_state, save_installed_state
@@ -1006,7 +1115,7 @@ instance = "1.40.8"
apply_plan(plan, state) apply_plan(plan, state)
self.assertEqual((instance / "IPA" / "Pending" / "Plugins" / "Example.dll").read_bytes(), b"dll") self.assertEqual((instance / "IPA" / "Pending" / "Plugins" / "Example.dll").read_bytes(), b"dll")
def test_plan_still_finds_legacy_flat_downloads(self) -> None: def test_plan_finds_shared_version_downloads(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) work = Path(tmp)
instance = work / "instances" / "1.40.8" instance = work / "instances" / "1.40.8"
@@ -1014,7 +1123,7 @@ instance = "1.40.8"
instance.mkdir(parents=True) instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir() (instance / "Beat Saber_Data").mkdir()
asset = downloads_dir(state, "1.40.8") / "Example.dll" asset = downloads_dir(state, "1.40.8") / "Example.dll"
asset.write_bytes(b"legacy flat download") asset.write_bytes(b"shared version download")
plan, _ = create_plan( plan, _ = create_plan(
instance="1.40.8", instance="1.40.8",
@@ -2336,6 +2445,7 @@ sha256 = "{sha256_file(asset)}"
state, state,
"1.40.8", "1.40.8",
{"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins}, {"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins},
install_id="test",
) )
choice = InstallationChoice( choice = InstallationChoice(
install_id="test", install_id="test",
@@ -2405,6 +2515,7 @@ instance = "1.40.8"
state, state,
"1.40.8", "1.40.8",
{"instance": "1.40.8", "plugins": plugins_state, "disabledPlugins": {}}, {"instance": "1.40.8", "plugins": plugins_state, "disabledPlugins": {}},
install_id="test",
) )
choice = InstallationChoice( choice = InstallationChoice(
install_id="test", install_id="test",
@@ -2440,6 +2551,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
table = app.query_one(DataTable) table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2) self.assertEqual(table.row_count, 2)
self.assertEqual(app.mode, "installations") self.assertEqual(app.mode, "installations")
self.assertEqual(str(table.get_cell_at(Coordinate(0, 3))), "/tmp/state-linux/installs/linux")
self.assertEqual(str(table.get_cell_at(Coordinate(1, 3))), "/tmp/state-windows/installs/windows")
async def test_single_instance_skips_installation_picker(self) -> None: async def test_single_instance_skips_installation_picker(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -2464,7 +2577,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause() await pilot.pause()
self.assertFalse((instance / "Plugins" / "Example.dll").exists()) self.assertFalse((instance / "Plugins" / "Example.dll").exists())
updated = load_installed_state(state, "1.40.8") updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertNotIn("example", updated["plugins"]) self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"]) self.assertIn("example", updated["disabledPlugins"])
@@ -2498,7 +2611,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause() await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8") updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertIn("example", updated["plugins"]) self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated["disabledPlugins"]) self.assertNotIn("example", updated["disabledPlugins"])
@@ -2513,7 +2626,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause() await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8") updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertIn("example", updated["plugins"]) self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated.get("disabledPlugins", {})) self.assertNotIn("example", updated.get("disabledPlugins", {}))
@@ -2526,7 +2639,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause() await pilot.pause()
self.assertFalse((instance / "Plugins" / "Example.dll").exists()) self.assertFalse((instance / "Plugins" / "Example.dll").exists())
updated = load_installed_state(state, "1.40.8") updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertNotIn("example", updated["plugins"]) self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"]) self.assertIn("example", updated["disabledPlugins"])
self.assertIn("Disabled 1 plugins", app.status_message) self.assertIn("Disabled 1 plugins", app.status_message)
@@ -2540,7 +2653,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause() await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8") updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertIn("example", updated["plugins"]) self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated["disabledPlugins"]) self.assertNotIn("example", updated["disabledPlugins"])
self.assertIn("Enabled 1 plugins", app.status_message) self.assertIn("Enabled 1 plugins", app.status_message)
@@ -2555,7 +2668,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
from plugin_helper.state import load_known_good_state from plugin_helper.state import load_known_good_state
known_good = load_known_good_state(state, "1.40.8") known_good = load_known_good_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertEqual(known_good["pluginIds"], ["alpha", "beta"]) self.assertEqual(known_good["pluginIds"], ["alpha", "beta"])
self.assertIn("Saved known-good set: 2 enabled plugins", app.status_message) self.assertIn("Saved known-good set: 2 enabled plugins", app.status_message)
@@ -2577,7 +2690,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("Restored known-good set", app.status_message) self.assertIn("Restored known-good set", app.status_message)
self.assertTrue((instance / "Plugins" / "Beta.dll").exists()) self.assertTrue((instance / "Plugins" / "Beta.dll").exists())
updated = load_installed_state(state, "1.40.8") updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertEqual(set(updated["plugins"]), {"alpha", "beta"}) self.assertEqual(set(updated["plugins"]), {"alpha", "beta"})
async def test_restore_known_good_without_saved_set_reports_status(self) -> None: async def test_restore_known_good_without_saved_set_reports_status(self) -> None:
@@ -2599,7 +2712,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause() await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll") self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
updated = load_installed_state(state, "1.40.8") updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertIn("example", updated["plugins"]) self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated.get("disabledPlugins", {})) self.assertNotIn("example", updated.get("disabledPlugins", {}))
self.assertIn("hash mismatch", app.status_message) self.assertIn("hash mismatch", app.status_message)