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