Track install state by installation id
This commit is contained in:
@@ -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,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,4 +1,4 @@
|
||||
# plugin-helper Roadmap
|
||||
# Roadmap 1
|
||||
|
||||
This roadmap tracks ideas that are useful but not part of the first safe CLI slice.
|
||||
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user