Compare commits
3 Commits
1858f76766
...
545d407116
| Author | SHA1 | Date | |
|---|---|---|---|
| 545d407116 | |||
| ea1e2ad61e | |||
| 53ad6e2c0d |
@@ -6,10 +6,10 @@ The first implementation focuses on safe local workflows:
|
||||
|
||||
- discover BSManager instances
|
||||
- scan existing `Plugins/` and `Libs/` files
|
||||
- read checked-in registry and per-version lockfiles
|
||||
- read the checked-in plugin catalog and per-version locks
|
||||
- generate a machine-readable install plan from local release assets
|
||||
- apply exactly that plan and record install state
|
||||
- uninstall only files recorded in install state
|
||||
- apply exactly that plan and record managed install state
|
||||
- uninstall only files recorded in managed install state
|
||||
|
||||
Default BSManager instance root:
|
||||
|
||||
@@ -106,9 +106,10 @@ py -m plugin_helper
|
||||
py -m plugin_helper --config plugin-helper.windows.toml --profile windows installed --instance 1.44.1
|
||||
```
|
||||
|
||||
On native Windows, `bootstrap` runs `IPA.exe -n` directly instead of through
|
||||
Proton. `bootstrap-check` accepts a recorded native bootstrap without requiring
|
||||
`Logs/_latest.log` when `IPA.exe -n` completed successfully.
|
||||
On native Windows, `bootstrap` runs `IPA.exe "Beat Saber.exe" -n` directly
|
||||
instead of through Proton. `bootstrap-check` accepts a recorded native bootstrap
|
||||
without requiring `Logs/_latest.log` when `IPA.exe "Beat Saber.exe" -n`
|
||||
completed successfully.
|
||||
|
||||
When no config file is present on Windows, defaults are
|
||||
`~/BSManager/BSInstances` and `%LOCALAPPDATA%/plugin-helper`.
|
||||
@@ -125,12 +126,12 @@ That is equivalent to `PYTHONPATH=src python -m plugin_helper menu` when run
|
||||
from an interactive terminal.
|
||||
|
||||
The menu reads `plugin-helper.local.toml` when present, shows each discovered
|
||||
Beat Saber install with its resolved state directory, and lets you toggle
|
||||
managed plugins with arrow keys and Space. In the plugin table, use `d` to
|
||||
disable all currently enabled managed plugins (except `bsipa`) and `e` to enable all
|
||||
currently disabled managed plugins. With a single Beat Saber installation, the
|
||||
menu opens the plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when
|
||||
needed.
|
||||
Beat Saber install with its resolved state directory, and lists plugins from
|
||||
that instance's version lock. Managed install state decides whether each row is
|
||||
currently checked or unchecked. In the plugin table, use `d` to disable all
|
||||
currently enabled plugins (except `bsipa`) and `e` to enable all currently
|
||||
unchecked plugins. With a single Beat Saber installation, the menu opens the
|
||||
plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when needed.
|
||||
|
||||
The individual subcommands are mostly for automation and debugging. If you use
|
||||
them, pass `--state-dir` directly only when you intentionally want to override
|
||||
@@ -212,11 +213,11 @@ custom content or non-obvious user choices rather than pure cache data.
|
||||
arguments such as `--no-yeet fpfc` can make the game fail command-line
|
||||
parsing after BSIPA and plugins have already loaded.
|
||||
- BSIPA is managed as a first-class bootstrap phase. The `bootstrap` command
|
||||
applies the locked `bsipa` root archive, runs `IPA.exe -n` (natively on
|
||||
Windows or through Proton on Linux), and records every bootstrap-relevant
|
||||
file under root `IPA.exe*`, `winhttp.dll`, `Libs/`, and `IPA/`, including
|
||||
backups created during patching.
|
||||
- If an instance lockfile includes `bsipa`, ordinary plugin plans require a
|
||||
applies the locked `bsipa` root archive, runs `IPA.exe "Beat Saber.exe" -n`
|
||||
(natively on Windows or through Proton on Linux), and records every
|
||||
bootstrap-relevant file under root `IPA.exe*`, `winhttp.dll`, `Libs/`, and
|
||||
`IPA/`, including backups created during patching.
|
||||
- If an instance version lock includes `bsipa`, ordinary plugin plans require a
|
||||
recorded bootstrap state plus a `Logs/_latest.log` that shows BSIPA startup.
|
||||
Use `bootstrap-check` before planning a batch when you want a quick gate.
|
||||
- Use [`docs/SMOKETEST.md`](docs/SMOKETEST.md) after installing or removing a
|
||||
|
||||
@@ -23,6 +23,39 @@ installations even when they share an instance name. Lockfiles can stay keyed by
|
||||
Beat Saber version, but bootstrap state, generated plans, backups, and
|
||||
`installed.json` need to stay target-specific.
|
||||
|
||||
Asset downloads are currently colocated with target-specific state under the
|
||||
same state root. That is convenient for the first CLI slice, but it makes
|
||||
dual-boot use awkward: pointing Linux at the Windows state root reuses the asset
|
||||
downloads, but also reuses the Windows `installed.json`. A later state layout
|
||||
should separate a reusable asset cache from per-install managed state, for
|
||||
example:
|
||||
|
||||
```text
|
||||
cache/
|
||||
downloads/<beat-saber-version>/<plugin-id>/<asset>
|
||||
|
||||
instances/
|
||||
<install-id>/
|
||||
installed.json
|
||||
bootstrap.json
|
||||
plans/
|
||||
backups/
|
||||
```
|
||||
|
||||
There is no urgent need to migrate the layout before the rest of the helper
|
||||
settles, but new code should avoid assuming that downloads and per-install
|
||||
state must always live together.
|
||||
|
||||
The version lock should eventually include structured source URLs for every
|
||||
asset so the helper can fetch missing downloads itself. The lock already pins
|
||||
the selected repo, tag, asset name, and checksum; adding source fields would
|
||||
make the fetch path explicit for both GitHub release assets and BeatMods CDN
|
||||
fallbacks. Hashes should remain useful audit metadata and a warning signal, but
|
||||
the UX needs a recovery path for replaced upstream assets: report the mismatch,
|
||||
show the expected and actual hashes, and let the user intentionally refresh or
|
||||
re-lock after inspection instead of treating every mismatch as an unrecoverable
|
||||
dead end.
|
||||
|
||||
## Future: Nix-Orchestrated Plugin Sets
|
||||
|
||||
Once Beat Saber is running on Linux through Steam Proton, it may make sense to let Nix orchestrate the plugin payload itself.
|
||||
|
||||
@@ -48,13 +48,13 @@ Windows instances.
|
||||
### Done
|
||||
|
||||
- Add native Windows bootstrap support.
|
||||
- `bootstrap` auto-detects Windows and runs `IPA.exe -n` directly.
|
||||
- `bootstrap` auto-detects Windows and runs `IPA.exe "Beat Saber.exe" -n` directly.
|
||||
- `--native` forces native mode; `--proton` remains for Linux/Proton installs.
|
||||
- Timeout cleanup uses `process.terminate()` / `process.kill()` on Windows
|
||||
instead of POSIX process groups.
|
||||
- Bootstrap state records `bootstrapMode: "native"` or `"proton"`.
|
||||
- `bootstrap-check` accepts recorded native Windows bootstrap state without
|
||||
`Logs/_latest.log` when `IPA.exe -n` completed successfully
|
||||
`Logs/_latest.log` when `IPA.exe "Beat Saber.exe" -n` completed successfully
|
||||
(`ipaExitCode == 0`, not timed out).
|
||||
- Add Windows-aware default paths when no config is present:
|
||||
`~/BSManager/BSInstances` and `%LOCALAPPDATA%/plugin-helper`.
|
||||
|
||||
@@ -10,5 +10,6 @@ dependencies = [
|
||||
{ id = "songcore", constraint = ">=3.16.0" },
|
||||
{ id = "sirautil", constraint = ">=3.3.1" },
|
||||
{ id = "system-io-compression", constraint = ">=4.6.0" },
|
||||
{ id = "system-io-compression-filesystem", constraint = ">=4.7.3056" },
|
||||
{ id = "bsipa", constraint = ">=4.3.7" },
|
||||
]
|
||||
|
||||
@@ -18,6 +18,9 @@ from .scanner import scan_bootstrap_files
|
||||
from .state import bootstrap_state_path, plugin_downloads_dir, save_bootstrap_state
|
||||
|
||||
|
||||
DEFAULT_IPA_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
@@ -112,7 +115,7 @@ def _fetch_github_release_asset(locked: LockedPlugin, destination: Path) -> dict
|
||||
def fetch_locked_bsipa_archive(lockfile: Lockfile, state_root: Path) -> dict[str, Any]:
|
||||
locked = next((plugin for plugin in lockfile.plugins if plugin.id == BSIPA_PLUGIN_ID), None)
|
||||
if not locked:
|
||||
raise ValueError("lockfile does not include a bsipa entry")
|
||||
raise ValueError("version lock does not include a bsipa entry")
|
||||
if not locked.asset:
|
||||
raise ValueError("locked BSIPA entry has no asset")
|
||||
destination = plugin_downloads_dir(state_root, lockfile.instance, BSIPA_PLUGIN_ID) / locked.asset
|
||||
@@ -137,11 +140,12 @@ def build_bootstrap_command(
|
||||
proton: Path | None = None,
|
||||
native: bool | None = None,
|
||||
) -> list[str]:
|
||||
beat_saber_exe = ipa.with_name("Beat Saber.exe")
|
||||
use_native = is_windows() if native is None else native
|
||||
if use_native:
|
||||
return [str(ipa), "-n"]
|
||||
return [str(ipa), str(beat_saber_exe), "-n"]
|
||||
proton_path = proton or _default_proton()
|
||||
return [str(proton_path), "run", str(ipa), "-n"]
|
||||
return [str(proton_path), "run", str(ipa), str(beat_saber_exe), "-n"]
|
||||
|
||||
|
||||
def _terminate_process(process: subprocess.Popen[str]) -> None:
|
||||
@@ -210,7 +214,7 @@ def run_bootstrap(
|
||||
proton: Path | None = None,
|
||||
native: bool | None = None,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
ipa_timeout_seconds: int = 120,
|
||||
ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
|
||||
) -> dict[str, Any]:
|
||||
tell = progress or (lambda _message: None)
|
||||
use_native = is_windows() if native is None else native
|
||||
@@ -246,15 +250,18 @@ def run_bootstrap(
|
||||
ipa = instance_path / "IPA.exe"
|
||||
if not ipa.is_file():
|
||||
raise FileNotFoundError(f"BSIPA archive did not install IPA.exe: {ipa}")
|
||||
beat_saber_exe = instance_path / "Beat Saber.exe"
|
||||
if not beat_saber_exe.is_file():
|
||||
raise FileNotFoundError(f"Beat Saber executable not found: {beat_saber_exe}")
|
||||
|
||||
command = build_bootstrap_command(ipa, proton=proton, native=use_native)
|
||||
if use_native:
|
||||
tell(f"Running IPA.exe -n natively; timeout {ipa_timeout_seconds}s")
|
||||
tell(f"Running IPA.exe \"Beat Saber.exe\" -n natively; timeout {ipa_timeout_seconds}s")
|
||||
else:
|
||||
proton_path = proton or _default_proton()
|
||||
if not proton_path.is_file():
|
||||
raise FileNotFoundError(f"Proton executable not found: {proton_path}")
|
||||
tell(f"Running IPA.exe -n through Proton; timeout {ipa_timeout_seconds}s")
|
||||
tell(f"Running IPA.exe \"Beat Saber.exe\" -n through Proton; timeout {ipa_timeout_seconds}s")
|
||||
|
||||
completed = _run_ipa(
|
||||
command=command,
|
||||
@@ -262,7 +269,7 @@ def run_bootstrap(
|
||||
timeout_seconds=ipa_timeout_seconds,
|
||||
native=use_native,
|
||||
)
|
||||
tell("Scanning bootstrap files after IPA.exe -n")
|
||||
tell("Scanning bootstrap files after IPA.exe \"Beat Saber.exe\" -n")
|
||||
after = scan_bootstrap_files(instance_path)
|
||||
delta = _files_delta(before, after)
|
||||
state: dict[str, Any] = {
|
||||
@@ -311,7 +318,7 @@ def ensure_healthy_bootstrap(
|
||||
proton: Path | None = None,
|
||||
native: bool | None = None,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
ipa_timeout_seconds: int = 120,
|
||||
ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
if not planning_requires_bootstrap(lockfile.plugins, selected_ids):
|
||||
return
|
||||
|
||||
@@ -24,7 +24,7 @@ def check_lock(
|
||||
messages: list[dict[str, str]] = []
|
||||
|
||||
if not registry_plugin:
|
||||
messages.append({"level": "warning", "message": "missing registry entry"})
|
||||
messages.append({"level": "warning", "message": "missing catalog entry"})
|
||||
if strategy == "manual":
|
||||
messages.append({"level": "error", "message": "manual install strategy cannot be applied"})
|
||||
if not locked.asset:
|
||||
|
||||
@@ -95,14 +95,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
state = subcommands.add_parser(
|
||||
"state",
|
||||
help="Show recorded plugin-helper install state",
|
||||
help="Show recorded plugin-helper managed install state",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
state.add_argument("--instance", required=True)
|
||||
|
||||
installed = subcommands.add_parser(
|
||||
"installed",
|
||||
help="List plugins installed by plugin-helper with locked release versions",
|
||||
help="List plugins from the version lock with managed install state",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
installed.add_argument("--instance", required=True)
|
||||
@@ -112,7 +112,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
check = subcommands.add_parser(
|
||||
"check",
|
||||
help="Validate local registry, lockfile, and release asset readiness",
|
||||
help="Validate local catalog, version lock, and release asset readiness",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
check.add_argument("--instance", required=True)
|
||||
@@ -154,7 +154,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
plan = subcommands.add_parser(
|
||||
"plan",
|
||||
help="Create a dry-run install plan from registry and lockfile",
|
||||
help="Create a dry-run install plan from the catalog and version lock",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
plan.add_argument("--instance", required=True)
|
||||
@@ -189,7 +189,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
enable = subcommands.add_parser(
|
||||
"enable",
|
||||
help="Reinstall a disabled locked plugin from local assets",
|
||||
help="Enable a version-locked plugin from local assets",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
enable.add_argument("--instance", required=True)
|
||||
|
||||
@@ -88,7 +88,7 @@ def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugi
|
||||
installed_state = load_installed_state(state_root, instance)
|
||||
plugin_state = installed_state.get("plugins", {}).get(plugin_id)
|
||||
if not plugin_state:
|
||||
raise KeyError(f"plugin is not recorded in install state: {plugin_id}")
|
||||
raise KeyError(f"plugin is not recorded in managed install state: {plugin_id}")
|
||||
|
||||
removed: list[str] = []
|
||||
skipped: list[dict[str, str]] = []
|
||||
@@ -119,7 +119,7 @@ def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_
|
||||
if not plugin_state:
|
||||
if plugin_id in installed_state.get("disabledPlugins", {}):
|
||||
raise KeyError(f"plugin is already disabled: {plugin_id}")
|
||||
raise KeyError(f"plugin is not recorded in install state: {plugin_id}")
|
||||
raise KeyError(f"plugin is not recorded in managed install state: {plugin_id}")
|
||||
|
||||
removed: list[str] = []
|
||||
skipped: list[dict[str, str]] = []
|
||||
|
||||
@@ -41,8 +41,8 @@ def enable_disabled_plugin(
|
||||
progress: Callable[[str], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
installed_state = load_installed_state(state_root, instance)
|
||||
if plugin_id not in installed_state.get("disabledPlugins", {}):
|
||||
raise KeyError(f"plugin is not recorded as disabled: {plugin_id}")
|
||||
if plugin_id in installed_state.get("plugins", {}):
|
||||
raise KeyError(f"plugin is already enabled: {plugin_id}")
|
||||
|
||||
root, registry_path, _lock_path, loaded_lockfile, loaded_registry = _resolve_paths(
|
||||
instance=instance,
|
||||
@@ -51,7 +51,21 @@ def enable_disabled_plugin(
|
||||
repo=repo,
|
||||
)
|
||||
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
|
||||
raise KeyError(f"plugin is disabled but not locked for this instance: {plugin_id}")
|
||||
raise KeyError(f"plugin is not locked for this instance: {plugin_id}")
|
||||
|
||||
tell = progress or (lambda _message: None)
|
||||
tell(f"Planning {plugin_id} from local assets")
|
||||
plan, path = create_plan(
|
||||
instance=instance,
|
||||
instance_path=instance_path,
|
||||
beat_saber_version=loaded_lockfile.beat_saber_version,
|
||||
registry=loaded_registry,
|
||||
lockfile=loaded_lockfile,
|
||||
state_root=state_root,
|
||||
repo_root=root,
|
||||
selected={plugin_id},
|
||||
require_bootstrap=False,
|
||||
)
|
||||
|
||||
ensure_healthy_bootstrap(
|
||||
instance=instance,
|
||||
@@ -65,16 +79,7 @@ def enable_disabled_plugin(
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
plan, path = create_plan(
|
||||
instance=instance,
|
||||
instance_path=instance_path,
|
||||
beat_saber_version=loaded_lockfile.beat_saber_version,
|
||||
registry=loaded_registry,
|
||||
lockfile=loaded_lockfile,
|
||||
state_root=state_root,
|
||||
repo_root=root,
|
||||
selected={plugin_id},
|
||||
)
|
||||
tell(f"Applying {plugin_id}")
|
||||
result = apply_plan(plan, state_root)
|
||||
return {"planPath": str(path), **result}
|
||||
|
||||
@@ -100,8 +105,38 @@ def enable_disabled_plugins(
|
||||
repo=repo,
|
||||
)
|
||||
installed_state = load_installed_state(state_root, instance)
|
||||
disabled_plugins = installed_state.get("disabledPlugins", {})
|
||||
selected_ids = set(plugin_ids)
|
||||
enabled_plugins = installed_state.get("plugins", {})
|
||||
locked_ids = {plugin.id for plugin in loaded_lockfile.plugins}
|
||||
|
||||
enabled: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
plannable_ids: list[str] = []
|
||||
for plugin_id in plugin_ids:
|
||||
if plugin_id in enabled_plugins:
|
||||
errors.append({"plugin": plugin_id, "error": f"plugin is already enabled: {plugin_id}"})
|
||||
continue
|
||||
if plugin_id not in locked_ids:
|
||||
errors.append({"plugin": plugin_id, "error": f"plugin is not locked for this instance: {plugin_id}"})
|
||||
continue
|
||||
plannable_ids.append(plugin_id)
|
||||
|
||||
if not plannable_ids:
|
||||
return {"enabled": enabled, "errors": errors}
|
||||
|
||||
selected_ids = set(plannable_ids)
|
||||
tell = progress or (lambda _message: None)
|
||||
tell(f"Planning {len(plannable_ids)} plugins from local assets")
|
||||
batch_plan, batch_path = create_plan(
|
||||
instance=instance,
|
||||
instance_path=instance_path,
|
||||
beat_saber_version=loaded_lockfile.beat_saber_version,
|
||||
registry=loaded_registry,
|
||||
lockfile=loaded_lockfile,
|
||||
state_root=state_root,
|
||||
repo_root=root,
|
||||
selected=selected_ids,
|
||||
require_bootstrap=False,
|
||||
)
|
||||
|
||||
ensure_healthy_bootstrap(
|
||||
instance=instance,
|
||||
@@ -115,31 +150,14 @@ def enable_disabled_plugins(
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
enabled: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for plugin_id in plugin_ids:
|
||||
if plugin_id not in disabled_plugins:
|
||||
errors.append({"plugin": plugin_id, "error": f"plugin is not recorded as disabled: {plugin_id}"})
|
||||
continue
|
||||
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
|
||||
errors.append(
|
||||
{"plugin": plugin_id, "error": f"plugin is disabled but not locked for this instance: {plugin_id}"}
|
||||
)
|
||||
continue
|
||||
try:
|
||||
plan, path = create_plan(
|
||||
instance=instance,
|
||||
instance_path=instance_path,
|
||||
beat_saber_version=loaded_lockfile.beat_saber_version,
|
||||
registry=loaded_registry,
|
||||
lockfile=loaded_lockfile,
|
||||
state_root=state_root,
|
||||
repo_root=root,
|
||||
selected={plugin_id},
|
||||
)
|
||||
result = apply_plan(plan, state_root)
|
||||
enabled.append({"plugin": plugin_id, "planPath": str(path), "applied": len(result["applied"])})
|
||||
except Exception as exc:
|
||||
errors.append({"plugin": plugin_id, "error": str(exc)})
|
||||
tell(f"Applying {len(plannable_ids)} plugins")
|
||||
result = apply_plan(batch_plan, state_root)
|
||||
applied_by_plugin: dict[str, int] = {}
|
||||
for item in result["applied"]:
|
||||
plugin_id = item["plugin"]
|
||||
applied_by_plugin[plugin_id] = applied_by_plugin.get(plugin_id, 0) + 1
|
||||
|
||||
for plugin_id in plannable_ids:
|
||||
enabled.append({"plugin": plugin_id, "planPath": str(batch_path), "applied": applied_by_plugin.get(plugin_id, 0)})
|
||||
|
||||
return {"enabled": enabled, "errors": errors}
|
||||
|
||||
@@ -118,11 +118,11 @@ def create_plan(
|
||||
if strategy not in VALID_STRATEGIES:
|
||||
raise ValueError(f"{locked.id}: invalid install strategy: {strategy}")
|
||||
if strategy == "manual":
|
||||
raise ValueError(f"{locked.id}: install_strategy is manual; add a concrete registry rule first")
|
||||
raise ValueError(f"{locked.id}: install_strategy is manual; add a concrete catalog rule first")
|
||||
if not locked.asset:
|
||||
raise ValueError(f"{locked.id}: lock entry has no asset")
|
||||
if registry_plugin and not _asset_matches_patterns(Path(locked.asset).name, registry_plugin.asset_patterns):
|
||||
warnings.append(f"{locked.id}: asset does not match registry patterns")
|
||||
warnings.append(f"{locked.id}: asset does not match catalog patterns")
|
||||
|
||||
asset_path = _find_asset(locked.asset, state_root, instance, repo_root, locked.id)
|
||||
if not asset_path:
|
||||
|
||||
@@ -13,15 +13,24 @@ def installed_plugins_report(
|
||||
) -> dict[str, Any]:
|
||||
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
|
||||
plugins: list[dict[str, Any]] = []
|
||||
state_plugins = [
|
||||
(plugin_id, plugin_state, "enabled")
|
||||
for plugin_id, plugin_state in installed_state.get("plugins", {}).items()
|
||||
]
|
||||
state_plugins.extend(
|
||||
(plugin_id, plugin_state, "disabled")
|
||||
for plugin_id, plugin_state in installed_state.get("disabledPlugins", {}).items()
|
||||
enabled_plugins = installed_state.get("plugins", {})
|
||||
disabled_plugins = installed_state.get("disabledPlugins", {})
|
||||
plugin_ids = list(locked_by_id)
|
||||
plugin_ids.extend(
|
||||
sorted(
|
||||
plugin_id
|
||||
for plugin_id in set(enabled_plugins) | set(disabled_plugins)
|
||||
if plugin_id not in locked_by_id
|
||||
)
|
||||
)
|
||||
for plugin_id, plugin_state, status in sorted(state_plugins):
|
||||
|
||||
for plugin_id in plugin_ids:
|
||||
if plugin_id in enabled_plugins:
|
||||
plugin_state = enabled_plugins[plugin_id]
|
||||
status = "enabled"
|
||||
else:
|
||||
plugin_state = disabled_plugins.get(plugin_id, {})
|
||||
status = "disabled"
|
||||
registry_plugin = registry.get(plugin_id)
|
||||
locked = locked_by_id.get(plugin_id)
|
||||
files = plugin_state.get("files", [])
|
||||
@@ -50,9 +59,9 @@ def installed_plugins_report(
|
||||
|
||||
def print_installed_plugins(report: dict[str, Any]) -> None:
|
||||
plugins = report["plugins"]
|
||||
print(f"{report['instance']} managed plugins ({len(plugins)})")
|
||||
print(f"{report['instance']} version-locked plugins ({len(plugins)})")
|
||||
if not plugins:
|
||||
print("No plugins have been installed by plugin-helper yet.")
|
||||
print("No plugins are listed in this instance's version lock.")
|
||||
return
|
||||
|
||||
headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed")
|
||||
|
||||
@@ -140,7 +140,7 @@ class PluginHelperTui(App[int]):
|
||||
state_root=target.state_root,
|
||||
plugin_id=plugin_id,
|
||||
repo=self.repo_root,
|
||||
progress=self._bootstrap_progress,
|
||||
progress=self._operation_progress,
|
||||
)
|
||||
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
|
||||
else:
|
||||
@@ -217,7 +217,7 @@ class PluginHelperTui(App[int]):
|
||||
state_root=target.state_root,
|
||||
plugin_ids=plugin_ids,
|
||||
repo=self.repo_root,
|
||||
progress=self._bootstrap_progress,
|
||||
progress=self._operation_progress,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._set_status(f"Could not enable plugins: {exc}")
|
||||
@@ -229,8 +229,8 @@ class PluginHelperTui(App[int]):
|
||||
self._set_bulk_status("Enabled", changed, errors)
|
||||
self._show_plugins(preserve_status=True)
|
||||
|
||||
def _bootstrap_progress(self, message: str) -> None:
|
||||
self.call_from_thread(self._set_status, f"Bootstrapping: {message}")
|
||||
def _operation_progress(self, message: str) -> None:
|
||||
self.call_from_thread(self._set_status, message)
|
||||
|
||||
def _show_installations(self) -> None:
|
||||
self.mode = "installations"
|
||||
@@ -297,7 +297,7 @@ class PluginHelperTui(App[int]):
|
||||
f"Space toggles selected. d disables all. e enables all.{back_hint}"
|
||||
)
|
||||
else:
|
||||
self._set_status("No managed plugins recorded for this installation.")
|
||||
self._set_status("No version-locked plugins for this installation.")
|
||||
|
||||
def _cursor_index(self, row_count: int) -> int | None:
|
||||
table = self.query_one(DataTable)
|
||||
|
||||
+108
-6
@@ -319,14 +319,17 @@ state_dir = "config-state"
|
||||
|
||||
def test_build_bootstrap_command_native(self) -> None:
|
||||
ipa = Path("C:/Games/Beat Saber/IPA.exe")
|
||||
self.assertEqual(build_bootstrap_command(ipa, native=True), [str(ipa), "-n"])
|
||||
self.assertEqual(
|
||||
build_bootstrap_command(ipa, native=True),
|
||||
[str(ipa), str(ipa.with_name("Beat Saber.exe")), "-n"],
|
||||
)
|
||||
|
||||
def test_build_bootstrap_command_proton(self) -> None:
|
||||
ipa = Path("/tmp/1.44.1/IPA.exe")
|
||||
proton = Path("/tmp/proton")
|
||||
self.assertEqual(
|
||||
build_bootstrap_command(ipa, proton=proton, native=False),
|
||||
[str(proton), "run", str(ipa), "-n"],
|
||||
[str(proton), "run", str(ipa), str(ipa.with_name("Beat Saber.exe")), "-n"],
|
||||
)
|
||||
|
||||
def test_profile_config_resolves_windows_paths(self) -> None:
|
||||
@@ -547,7 +550,7 @@ state_dir = ".state"
|
||||
check_health.assert_not_called()
|
||||
run_bootstrap.assert_not_called()
|
||||
|
||||
def test_enable_disabled_plugin_bootstraps_before_plan(self) -> None:
|
||||
def test_enable_disabled_plugin_plans_before_bootstrap(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
instance = work / "instances" / "1.44.1"
|
||||
@@ -628,7 +631,55 @@ install_strategy = "dll-to-plugins"
|
||||
lockfile=str(lock_path),
|
||||
repo=work,
|
||||
)
|
||||
self.assertEqual(call_order, ["ensure", "plan", "apply"])
|
||||
self.assertEqual(call_order, ["plan", "ensure", "apply"])
|
||||
|
||||
def test_enable_locked_plugin_missing_asset_does_not_bootstrap(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
instance = work / "instances" / "1.44.1"
|
||||
state = work / "state"
|
||||
instance.mkdir(parents=True)
|
||||
(instance / "Beat Saber_Data").mkdir()
|
||||
(instance / "Plugins").mkdir()
|
||||
(work / "locks").mkdir()
|
||||
(work / "registry").mkdir()
|
||||
lock_path = work / "locks" / "1.44.1.lock.toml"
|
||||
lock_path.write_text(
|
||||
"""
|
||||
beat_saber_version = "1.44.1"
|
||||
instance = "1.44.1"
|
||||
|
||||
[[plugins]]
|
||||
id = "example"
|
||||
tag = "v1.0.0"
|
||||
asset = "Missing.dll"
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
registry_path = work / "registry" / "plugins.toml"
|
||||
registry_path.write_text(
|
||||
"""
|
||||
[[plugins]]
|
||||
id = "example"
|
||||
name = "Example"
|
||||
install_strategy = "dll-to-plugins"
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch("plugin_helper.operations.ensure_healthy_bootstrap") as ensure:
|
||||
with self.assertRaisesRegex(FileNotFoundError, "asset not found"):
|
||||
enable_disabled_plugin(
|
||||
instance="1.44.1",
|
||||
instance_path=instance,
|
||||
state_root=state,
|
||||
plugin_id="example",
|
||||
registry=str(registry_path),
|
||||
lockfile=str(lock_path),
|
||||
repo=work,
|
||||
)
|
||||
|
||||
ensure.assert_not_called()
|
||||
|
||||
def test_plan_apply_and_uninstall_dll(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -1399,6 +1450,33 @@ sha256 = "{sha256_file(asset)}"
|
||||
self.assertEqual(report["plugins"][0]["asset"], "Example.dll")
|
||||
self.assertEqual(report["plugins"][0]["fileCount"], 1)
|
||||
|
||||
def test_installed_plugins_report_starts_from_version_lock(self) -> None:
|
||||
registry = Registry(
|
||||
{
|
||||
"alpha": RegistryPlugin(id="alpha", name="Alpha", repo="owner/alpha"),
|
||||
"beta": RegistryPlugin(id="beta", name="Beta", repo="owner/beta"),
|
||||
}
|
||||
)
|
||||
lockfile = Lockfile(
|
||||
beat_saber_version="1.40.8",
|
||||
instance="1.40.8",
|
||||
plugins=(
|
||||
LockedPlugin(id="alpha", repo="owner/alpha", tag="v1.0.0", asset="Alpha.dll", sha256="a"),
|
||||
LockedPlugin(id="beta", repo="owner/beta", tag="v2.0.0", asset="Beta.dll", sha256="b"),
|
||||
),
|
||||
)
|
||||
|
||||
report = installed_plugins_report(
|
||||
installed_state={"instance": "1.40.8", "plugins": {}},
|
||||
registry=registry,
|
||||
lockfile=lockfile,
|
||||
)
|
||||
|
||||
self.assertEqual([plugin["id"] for plugin in report["plugins"]], ["alpha", "beta"])
|
||||
self.assertEqual([plugin["status"] for plugin in report["plugins"]], ["disabled", "disabled"])
|
||||
self.assertEqual([plugin["fileCount"] for plugin in report["plugins"]], [0, 0])
|
||||
self.assertEqual(report["plugins"][1]["version"], "v2.0.0")
|
||||
|
||||
def test_update_check_reports_current_matching_asset(self) -> None:
|
||||
registry = Registry(
|
||||
{
|
||||
@@ -1535,7 +1613,13 @@ sha256 = "{sha256_file(asset)}"
|
||||
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new")
|
||||
|
||||
|
||||
def _make_tui_fixture(root: Path, *, disabled: bool = False, hash_mismatch: bool = False) -> tuple[PluginHelperTui, Path, Path]:
|
||||
def _make_tui_fixture(
|
||||
root: Path,
|
||||
*,
|
||||
disabled: bool = False,
|
||||
hash_mismatch: bool = False,
|
||||
recorded: bool = True,
|
||||
) -> tuple[PluginHelperTui, Path, Path]:
|
||||
repo = root / "repo"
|
||||
instance_root = root / "instances"
|
||||
instance = instance_root / "1.40.8"
|
||||
@@ -1574,7 +1658,10 @@ sha256 = "{sha256_file(asset)}"
|
||||
)
|
||||
|
||||
target = instance / "Plugins" / "Example.dll"
|
||||
if disabled:
|
||||
if not recorded:
|
||||
plugins = {}
|
||||
disabled_plugins = {}
|
||||
elif disabled:
|
||||
plugins = {}
|
||||
disabled_plugins = {
|
||||
"example": {
|
||||
@@ -1762,6 +1849,21 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("example", updated["plugins"])
|
||||
self.assertNotIn("example", updated["disabledPlugins"])
|
||||
|
||||
async def test_space_enables_locked_unrecorded_plugin_from_asset(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, instance, state = _make_tui_fixture(Path(tmp), recorded=False)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
self.assertEqual(app.mode, "plugins")
|
||||
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
|
||||
await pilot.press("space")
|
||||
await pilot.pause()
|
||||
|
||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
self.assertIn("example", updated["plugins"])
|
||||
self.assertNotIn("example", updated.get("disabledPlugins", {}))
|
||||
|
||||
async def test_disable_all_disables_enabled_plugins(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, instance, state = _make_tui_fixture(Path(tmp))
|
||||
|
||||
Reference in New Issue
Block a user