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:
```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:
+1 -5
View File
@@ -1,10 +1,6 @@
**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.
- 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.
- 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.
- 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.
+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.
+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,
progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
install_id: str | None = None,
) -> dict[str, Any]:
tell = progress or (lambda _message: None)
use_native = is_windows() if native is None else native
@@ -240,6 +241,7 @@ def run_bootstrap(
repo_root=repo_root,
selected={BSIPA_PLUGIN_ID},
require_bootstrap=False,
install_id=install_id,
)
if not plan["changes"]:
raise ValueError("BSIPA bootstrap plan has no changes")
@@ -292,12 +294,14 @@ def run_bootstrap(
"delta": delta,
"health": {},
}
if install_id:
state["installId"] = install_id
if not use_native:
state["proton"] = str(proton or _default_proton())
save_bootstrap_state(state_root, instance, state)
state["health"] = check_bsipa_health(instance_path, state_root, instance)
save_bootstrap_state(state_root, instance, state)
state["statePath"] = str(bootstrap_state_path(state_root, instance))
save_bootstrap_state(state_root, instance, state, install_id=install_id)
state["health"] = check_bsipa_health(instance_path, state_root, instance, install_id=install_id)
save_bootstrap_state(state_root, instance, state, install_id=install_id)
state["statePath"] = str(bootstrap_state_path(state_root, instance, install_id=install_id))
if completed["timedOut"]:
raise TimeoutError(f"IPA.exe -n timed out after {ipa_timeout_seconds}s; state written to {state['statePath']}")
if completed["returncode"] != 0:
@@ -319,11 +323,12 @@ def ensure_healthy_bootstrap(
native: bool | None = None,
progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
install_id: str | None = None,
) -> None:
if not planning_requires_bootstrap(lockfile.plugins, selected_ids):
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"]:
return
@@ -341,8 +346,9 @@ def ensure_healthy_bootstrap(
native=native,
progress=progress,
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"]:
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]:
state = load_bootstrap_state(state_root, instance)
def check_bsipa_health(
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] = []
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")
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 {
"ok": not 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),
"logSha256": sha256_file(log_path) if log_path.is_file() else None,
"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 .reports import installed_plugins_report, print_installed_plugins
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 .updates import check_updates
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['warnings']} warnings, {summary['errors']} errors"
)
if not plugins:
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)))
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:
default = argparse.SUPPRESS if suppress_default else None
parser.add_argument("--config", default=default, help="plugin-helper config TOML path")
@@ -277,12 +292,13 @@ def _run_menu(
return 1
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"
for instance in list_instances(root):
install_id = _installation_id_for(runtime, instance.name, instance.path)
choices.append(
InstallationChoice(
install_id=f"root-{index}",
install_id=install_id,
install_label=install_label,
instance_name=instance.name,
instance_path=instance.path,
@@ -366,17 +382,19 @@ def run(argv: list[str] | None = None) -> int:
return 0
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
if args.command == "installed":
instance, install_id = _get_installation(runtime, 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()
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),
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
if args.command == "update":
instance = get_instance(inst_roots, args.instance)
instance, install_id = _get_installation(runtime, 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"
@@ -456,6 +474,7 @@ def run(argv: list[str] | None = None) -> int:
include_prerelease=args.include_prerelease,
dry_run=args.dry_run,
apply=args.apply,
install_id=install_id,
)
if args.json:
_json(result)
@@ -480,7 +499,7 @@ def run(argv: list[str] | None = None) -> int:
return 0 if not result["refused"] else 2
if args.command == "bootstrap":
instance = get_instance(inst_roots, args.instance)
instance, install_id = _get_installation(runtime, 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"
@@ -498,6 +517,7 @@ def run(argv: list[str] | None = None) -> int:
proton=Path(args.proton).expanduser() if args.proton else None,
native=True if args.native else None,
progress=lambda message: print(f" {message}", flush=True),
install_id=install_id,
)
if args.json:
_json(result)
@@ -517,8 +537,8 @@ def run(argv: list[str] | None = None) -> int:
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)
instance, install_id = _get_installation(runtime, args.instance)
result = check_bsipa_health(instance.path, st_root, args.instance, install_id=install_id)
if args.json:
_json(result)
else:
@@ -530,7 +550,7 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["ok"] else 2
if args.command == "plan":
instance = get_instance(inst_roots, args.instance)
instance, install_id = _get_installation(runtime, 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"
@@ -548,6 +568,7 @@ def run(argv: list[str] | None = None) -> int:
state_root=st_root,
repo_root=root,
selected=selected,
install_id=install_id,
)
print(f"Wrote plan: {path}")
print(f"Changes: {len(plan['changes'])}")
@@ -564,8 +585,8 @@ def run(argv: list[str] | None = None) -> int:
return 0
if args.command == "uninstall":
instance = get_instance(inst_roots, args.instance)
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
instance, install_id = _get_installation(runtime, args.instance)
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force, install_id=install_id)
print(f"Removed: {len(result['removed'])}")
if result["skipped"]:
print("Skipped:")
@@ -574,8 +595,8 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["stateUpdated"] else 2
if args.command == "disable":
instance = get_instance(inst_roots, args.instance)
result = disable_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
instance, install_id = _get_installation(runtime, args.instance)
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"Removed: {len(result['removed'])}")
if result["skipped"]:
@@ -585,7 +606,7 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["stateUpdated"] else 2
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))
result = enable_disabled_plugin(
instance=args.instance,
@@ -595,6 +616,7 @@ def run(argv: list[str] | None = None) -> int:
registry=args.registry,
lockfile=args.lockfile,
progress=progress,
install_id=install_id,
)
print(f"Enabled: {args.plugin}")
print(f"Plan: {result['planPath']}")
@@ -603,7 +625,8 @@ def run(argv: list[str] | None = None) -> int:
return 0
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:
_json(result)
else:
@@ -613,7 +636,7 @@ def run(argv: list[str] | None = None) -> int:
return 0
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))
result = restore_known_good_set(
instance=args.instance,
@@ -622,6 +645,7 @@ def run(argv: list[str] | None = None) -> int:
registry=args.registry,
lockfile=args.lockfile,
progress=progress,
install_id=install_id,
)
if args.json:
_json(result)
+11 -1
View File
@@ -189,7 +189,17 @@ def resolve_runtime_config(
) -> RuntimeConfig:
repo = root or repo_root()
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 = (
_resolve_path_list(instances_root_value, repo)
+28 -11
View File
@@ -7,7 +7,7 @@ from typing import Any
from zipfile import ZipFile
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:
@@ -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]:
instance = plan["instance"]
install_id = plan.get("installId")
instance_path = Path(plan["instancePath"])
if not instance_path.is_dir():
raise FileNotFoundError(f"instance path does not exist: {instance_path}")
backup_root = backups_dir(state_root, instance) / f"apply-{_timestamp()}"
installed_state = load_installed_state(state_root, instance)
backup_root = backups_dir(state_root, instance, install_id=install_id) / f"apply-{_timestamp()}"
installed_state = load_installed_state(state_root, instance, install_id=install_id)
installed_state.setdefault("beatSaberVersion", plan.get("beatSaberVersion"))
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)
applied.append({"path": rel_target, "plugin": change["plugin"], "backup": backup})
save_installed_state(state_root, instance, installed_state)
return {"applied": applied, "statePath": str(state_root / "instances" / instance / "installed.json")}
save_installed_state(state_root, instance, installed_state, install_id=install_id)
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]:
installed_state = load_installed_state(state_root, instance)
def uninstall_plugin(
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)
if not plugin_state:
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}
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}
def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_id: str, force: bool = False) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance)
def disable_plugin(
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)
if not plugin_state:
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")
installed_state.setdefault("disabledPlugins", {})[plugin_id] = disabled_state
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}
+19 -9
View File
@@ -39,8 +39,9 @@ def enable_disabled_plugin(
lockfile: str | None = None,
repo: Path | None = None,
progress: Callable[[str], None] | None = None,
install_id: str | None = None,
) -> 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", {}):
raise KeyError(f"plugin is already enabled: {plugin_id}")
@@ -65,6 +66,7 @@ def enable_disabled_plugin(
repo_root=root,
selected={plugin_id},
require_bootstrap=False,
install_id=install_id,
)
ensure_healthy_bootstrap(
@@ -77,6 +79,7 @@ def enable_disabled_plugin(
repo_root=root,
selected_ids={plugin_id},
progress=progress,
install_id=install_id,
)
tell(f"Applying {plugin_id}")
@@ -94,6 +97,7 @@ def enable_disabled_plugins(
lockfile: str | None = None,
repo: Path | None = None,
progress: Callable[[str], None] | None = None,
install_id: str | None = None,
) -> dict[str, Any]:
if not plugin_ids:
return {"enabled": [], "errors": []}
@@ -104,7 +108,7 @@ def enable_disabled_plugins(
lockfile=lockfile,
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", {})
locked_ids = {plugin.id for plugin in loaded_lockfile.plugins}
@@ -136,6 +140,7 @@ def enable_disabled_plugins(
repo_root=root,
selected=selected_ids,
require_bootstrap=False,
install_id=install_id,
)
ensure_healthy_bootstrap(
@@ -148,6 +153,7 @@ def enable_disabled_plugins(
repo_root=root,
selected_ids=selected_ids,
progress=progress,
install_id=install_id,
)
tell(f"Applying {len(plannable_ids)} plugins")
@@ -163,16 +169,18 @@ def enable_disabled_plugins(
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."""
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", {}))
known_good = {
"instance": instance,
"beatSaberVersion": installed_state.get("beatSaberVersion"),
"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
@@ -185,14 +193,15 @@ def restore_known_good_set(
lockfile: str | None = None,
repo: Path | None = None,
progress: Callable[[str], None] | None = None,
install_id: str | None = None,
) -> dict[str, Any]:
"""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", []))
if not known_good:
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", {}))
to_disable = sorted(currently_enabled - known_good_ids)
@@ -200,7 +209,7 @@ def restore_known_good_set(
disable_errors: list[dict[str, str]] = []
for plugin_id in to_disable:
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"]:
disabled.append(plugin_id)
else:
@@ -209,7 +218,7 @@ def restore_known_good_set(
except Exception as 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", {}))
to_enable = sorted(known_good_ids - currently_enabled)
enable_result = enable_disabled_plugins(
@@ -221,6 +230,7 @@ def restore_known_good_set(
lockfile=lockfile,
repo=repo,
progress=progress,
install_id=install_id,
)
return {
+10 -3
View File
@@ -10,7 +10,11 @@ from zipfile import ZipFile
from .fsutil import ensure_relative, sha256_bytes, sha256_file
from .models import Lockfile, Registry, VALID_STRATEGIES
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"}
@@ -96,6 +100,7 @@ def create_plan(
repo_root: Path,
selected: set[str] | None = None,
require_bootstrap: bool = True,
install_id: str | None = None,
) -> tuple[dict[str, Any], Path]:
selected_ids = (
_expand_required_dependencies(selected, registry, lockfile)
@@ -106,7 +111,7 @@ def create_plan(
warnings: list[str] = []
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"]:
raise ValueError(bootstrap_health_error(health))
@@ -175,7 +180,9 @@ def create_plan(
"warnings": warnings,
"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:
json.dump(plan, handle, indent=2, sort_keys=True)
handle.write("\n")
+87 -33
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import hashlib
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -7,49 +9,97 @@ from typing import Any
from .fsutil import atomic_write_json, read_json
def instance_state_dir(state_root: Path, instance: str) -> Path:
return state_root / "instances" / instance
def _slug(value: str) -> str:
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:
return instance_state_dir(state_root, instance) / "installed.json"
def bootstrap_state_path(state_root: Path, instance: str) -> Path:
return instance_state_dir(state_root, instance) / "bootstrap.json"
def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
return read_json(
installed_state_path(state_root, instance),
{"instance": instance, "plugins": {}},
def installation_id(
*,
profile_id: str | None,
root: Path | None,
instance: str,
instance_path: Path,
) -> str:
resolved_root = (
root.expanduser().resolve(strict=False)
if root
else instance_path.parent.expanduser().resolve(strict=False)
)
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]:
return read_json(bootstrap_state_path(state_root, instance), {})
def instance_state_dir(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
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)
if install_id:
state.setdefault("installId", install_id)
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)
if install_id:
state.setdefault("installId", install_id)
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:
path = instance_state_dir(state_root, instance) / "plans"
def plans_dir(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
path = instance_state_dir(state_root, instance, install_id=install_id) / "plans"
path.mkdir(parents=True, exist_ok=True)
return 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)
return path
@@ -60,21 +110,25 @@ def plugin_downloads_dir(state_root: Path, instance: str, plugin_id: str) -> Pat
return path
def backups_dir(state_root: Path, instance: str) -> Path:
path = instance_state_dir(state_root, instance) / "backups"
def backups_dir(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
path = instance_state_dir(state_root, instance, install_id=install_id) / "backups"
path.mkdir(parents=True, exist_ok=True)
return path
def known_good_state_path(state_root: Path, instance: str) -> Path:
return instance_state_dir(state_root, instance) / "known-good.json"
def load_known_good_state(state_root: Path, instance: str, *, install_id: str | None = None) -> dict[str, Any]:
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]:
return read_json(known_good_state_path(state_root, instance), {})
def save_known_good_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
def save_known_good_state(
state_root: Path,
instance: str,
state: dict[str, Any],
*,
install_id: str | None = None,
) -> None:
state.setdefault("instance", instance)
if install_id:
state.setdefault("installId", install_id)
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,
)
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)
@@ -133,6 +133,7 @@ class PluginHelperTui(App[int]):
target.state_root,
plugin_id,
False,
install_id=target.install_id,
)
if not result["stateUpdated"]:
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,
repo=self.repo_root,
progress=self._operation_progress,
install_id=target.install_id,
)
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
else:
@@ -190,6 +192,7 @@ class PluginHelperTui(App[int]):
target.state_root,
plugin_id,
False,
install_id=target.install_id,
)
if result["stateUpdated"]:
changed += 1
@@ -225,6 +228,7 @@ class PluginHelperTui(App[int]):
plugin_ids=plugin_ids,
repo=self.repo_root,
progress=self._operation_progress,
install_id=target.install_id,
)
except Exception as 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:
return
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"])
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:
return
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.")
return
self._busy = True
@@ -261,6 +269,7 @@ class PluginHelperTui(App[int]):
state_root=target.state_root,
repo=self.repo_root,
progress=self._operation_progress,
install_id=target.install_id,
)
except Exception as exc:
self._set_status(f"Could not restore known-good set: {exc}")
@@ -298,7 +307,7 @@ class PluginHelperTui(App[int]):
choice.install_label,
choice.instance_name,
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:
self._set_status(self.setup_hint)
@@ -322,7 +331,7 @@ class PluginHelperTui(App[int]):
try:
lockfile = load_lockfile(self.repo_root / "locks" / f"{target.instance_name}.lock.toml")
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"),
lockfile=lockfile,
)
+2
View File
@@ -91,6 +91,7 @@ def run_update(
apply: bool = False,
download_asset: DownloadAsset = _download_asset,
apply_plan_func: ApplyPlan = apply_plan,
install_id: str | None = None,
) -> dict[str, Any]:
if not selected:
raise ValueError("update requires at least one --plugin")
@@ -224,6 +225,7 @@ def run_update(
state_root=state_root,
repo_root=repo_root,
selected=set(replacements),
install_id=install_id,
)
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"
if not source.is_dir():
raise FileNotFoundError(f"UserData directory not found: {source}")
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]] = []
total_size = 0
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,
"files": files,
}
if install_id:
manifest["installId"] = install_id
destination.parent.mkdir(parents=True, exist_ok=True)
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.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, 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.update_runner import run_update
from plugin_helper.updates import check_updates
@@ -863,10 +870,112 @@ sha256 = "{sha256_file(asset)}"
self.assertEqual(status, 0)
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.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:
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
@@ -1006,7 +1115,7 @@ instance = "1.40.8"
apply_plan(plan, state)
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:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
@@ -1014,7 +1123,7 @@ instance = "1.40.8"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
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(
instance="1.40.8",
@@ -2336,6 +2445,7 @@ sha256 = "{sha256_file(asset)}"
state,
"1.40.8",
{"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins},
install_id="test",
)
choice = InstallationChoice(
install_id="test",
@@ -2405,6 +2515,7 @@ instance = "1.40.8"
state,
"1.40.8",
{"instance": "1.40.8", "plugins": plugins_state, "disabledPlugins": {}},
install_id="test",
)
choice = InstallationChoice(
install_id="test",
@@ -2440,6 +2551,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2)
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:
with tempfile.TemporaryDirectory() as tmp:
@@ -2464,7 +2577,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause()
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.assertIn("example", updated["disabledPlugins"])
@@ -2498,7 +2611,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause()
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.assertNotIn("example", updated["disabledPlugins"])
@@ -2513,7 +2626,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause()
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.assertNotIn("example", updated.get("disabledPlugins", {}))
@@ -2526,7 +2639,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause()
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.assertIn("example", updated["disabledPlugins"])
self.assertIn("Disabled 1 plugins", app.status_message)
@@ -2540,7 +2653,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause()
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.assertNotIn("example", updated["disabledPlugins"])
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
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.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.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"})
async def test_restore_known_good_without_saved_set_reports_status(self) -> None:
@@ -2599,7 +2712,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause()
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.assertNotIn("example", updated.get("disabledPlugins", {}))
self.assertIn("hash mismatch", app.status_message)