Add plugin helper with agent skill for updating plugins

This commit is contained in:
pleb
2026-06-14 10:26:22 -07:00
parent a9881ebec4
commit caaa4a6558
23 changed files with 1604 additions and 0 deletions
@@ -0,0 +1,174 @@
---
name: install-beatsaber-plugin
description: Install or update a Beat Saber plugin in the plugin-helper repo by using the local helper workflow. Use when the user asks to add, install, update, bump, lock, or manage a Beat Saber plugin release for a BSManager instance. The user must provide an explicit GitHub release URL in the prompt; if no release URL is present, ask for one and do not infer, search for, or substitute a different repository.
---
# Install Beat Saber Plugin
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances. Do not manually copy release files into the game instance except to undo your own mistaken install before rerunning the helper.
## Hard Guardrail
Require an explicit GitHub release URL from the user's prompt before selecting any release or repository.
- If the prompt does not contain a GitHub release URL, stop and ask the user for it.
- Do not search the web to discover a repository or "correct" release URL.
- Do not substitute a similar repo, fork, project, or package name.
- If the provided URL is a general releases page, use that repo's release API and choose the latest non-draft, non-prerelease release unless the user asks for a specific tag/version.
- If the provided URL is a tag URL, use that exact tag.
Accepted URL shapes include:
```text
https://github.com/<owner>/<repo>/releases
https://github.com/<owner>/<repo>/releases/tag/<tag>
https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
```
## Workflow
1. Confirm the workspace is the `plugin-helper` repo.
```bash
test -f pyproject.toml && test -d src/plugin_helper && test -d registry && test -d locks
```
2. Read the local helper behavior before changing files.
Inspect at least:
```bash
sed -n '1,220p' README.md
sed -n '1,260p' src/plugin_helper/cli.py
sed -n '1,260p' src/plugin_helper/planner.py
sed -n '1,220p' src/plugin_helper/models.py
sed -n '1,220p' registry/plugins.toml
sed -n '1,220p' locks/<instance>.lock.toml
```
3. Determine the instance.
Prefer the instance the user names. If omitted and the working context clearly points at one lockfile, use that instance. Otherwise run:
```bash
PYTHONPATH=src python -m plugin_helper instances
```
4. Snapshot first when requested.
If the user asks for a one-time or pre-helper snapshot, archive the instance's `Plugins/` directory before any install:
```bash
mkdir -p "$HOME/archive/beatsaber"
tar -C "<instance-root>/<instance>" -czf "$HOME/archive/beatsaber/<instance>-Plugins-pre-helper-<timestamp>.tar.gz" Plugins
sha256sum "$HOME/archive/beatsaber/<instance>-Plugins-pre-helper-<timestamp>.tar.gz"
```
Report the archive path and hash.
5. Resolve the release from the user-provided URL only.
For GitHub URLs, derive `<owner>/<repo>` and optional `<tag>` from the URL. Query the GitHub API directly for metadata:
```bash
curl -sS https://api.github.com/repos/<owner>/<repo>/releases
curl -sS https://api.github.com/repos/<owner>/<repo>/releases/tags/<tag>
```
Pick the asset that matches the Beat Saber instance/version. Prefer an exact versioned asset such as `1.40.8.zip` over broad or source archives. If multiple plausible assets remain, ask the user.
6. Inspect the asset before selecting an install strategy.
Download to the helper state directory:
```bash
mkdir -p .state/instances/<instance>/downloads
curl -L --fail -o .state/instances/<instance>/downloads/<asset-name> "<browser_download_url>"
sha256sum .state/instances/<instance>/downloads/<asset-name>
```
Match the checksum against GitHub's `digest` when available. Inspect zip contents:
```bash
unzip -l .state/instances/<instance>/downloads/<asset-name>
```
Strategy guide:
- `dll-to-plugins`: asset is a single `.dll` that belongs in `Plugins/`.
- `bsipa-zip`: zip top-level paths are only `IPA/`, `Libs/`, or `Plugins/`.
- `root-zip`: zip contains valid game-root paths outside the BSIPA top-level set.
- `zip-to-pending`: only when the release is intended for `IPA/Pending/`.
- `manual`: do not use for installable releases.
7. Update the registry and lockfile.
Add or update exactly one `[[plugins]]` entry in `registry/plugins.toml` with:
```toml
[[plugins]]
id = "<stable-plugin-id>"
name = "<human name>"
repo = "<owner>/<repo>"
asset_patterns = ["<asset-name-or-pattern>"]
install_strategy = "<strategy>"
category = "<category-if-obvious>"
```
Add or update the matching `[[plugins]]` entry in `locks/<instance>.lock.toml` with:
```toml
[[plugins]]
id = "<stable-plugin-id>"
repo = "<owner>/<repo>"
tag = "<tag>"
asset = "<asset-name>"
sha256 = "<asset-sha256>"
```
Preserve unrelated registry and lockfile content. Do not invent dependency versions unless they are explicitly stated by the provided release notes or existing local metadata.
8. Use the helper to validate, plan, and apply.
Always pass `--state-dir .state` so the helper uses the repo-local downloaded asset:
```bash
PYTHONPATH=src python -m plugin_helper --state-dir .state check --instance <instance>
PYTHONPATH=src python -m plugin_helper --state-dir .state plan --instance <instance> --plugin <plugin-id>
PYTHONPATH=src python -m plugin_helper --state-dir .state apply <generated-plan-path>
```
Before applying, read or summarize the generated plan enough to confirm it changes only the intended plugin files.
9. Verify the result.
Confirm the installed file hashes match the plan or archive members:
```bash
PYTHONPATH=src python -m plugin_helper --state-dir .state state --instance <instance>
PYTHONPATH=src python -m plugin_helper --state-dir .state check --instance <instance>
PYTHONPATH=src python -m unittest discover -s tests
```
Use `PYTHONPATH=src`; plain `python -m unittest` may fail in this source-layout repo.
10. Final response.
Include:
- release URL/tag/asset used
- snapshot path and hash, if created
- files changed in the repo and helper state
- live instance files changed
- backup path created by the helper
- validation commands and results
## Mistake Recovery
If you installed from the wrong release or repo during the current task:
1. Restore affected live files from the helper backup created by that mistaken apply.
2. Remove mistaken downloaded assets and mistaken plan files from `.state`.
3. Correct the registry and lockfile to the user-provided release URL.
4. Rerun `check`, `plan`, and `apply` with `--state-dir .state`.
5. Keep or report only the backup relevant to the final correct apply unless the user asks for full audit history.
@@ -0,0 +1,4 @@
interface:
display_name: "Install Beat Saber Plugin"
short_description: "Update Beat Saber plugins via helper"
default_prompt: "Use $install-beatsaber-plugin to install or update a Beat Saber plugin from this release URL: <url>."
+8
View File
@@ -0,0 +1,8 @@
/.state/
/.pytest_cache/
/build/
/dist/
/*.egg-info/
/src/*.egg-info/
/__pycache__/
*.pyc
+37
View File
@@ -0,0 +1,37 @@
# plugin-helper
`plugin-helper` is an early Python CLI for managing Beat Saber plugins in a mounted Windows BSManager install.
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
- generate a machine-readable install plan from local release assets
- apply exactly that plan with backups and install state
- uninstall only files recorded in install state
- back up `UserData` separately
Default BSManager instance root:
```text
/home/pleb/Windows/Users/pleb/BSManager/BSInstances
```
Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`.
## Quick Start
```sh
python -m plugin_helper instances
python -m plugin_helper scan --instance 1.40.8
python -m plugin_helper plan --instance 1.40.8 --state-dir .state
```
Install assets are currently expected to already exist locally, usually under:
```text
.state/instances/<instance>/downloads/
```
Future milestones will add GitHub release discovery and download.
+80
View File
@@ -0,0 +1,80 @@
# plugin-helper Roadmap
This roadmap tracks ideas that are useful but not part of the first safe CLI slice.
## Current Direction
The initial tool should stay conservative:
- Python owns instance discovery, dry-run plans, activation, install state, uninstall, and `UserData` backups.
- Release assets are selected through registry and lockfile data.
- Mutating operations apply an explicit plan and record exact file hashes.
- Nix packages `plugin-helper`, but does not directly manage the mutable Beat Saber tree.
This works well while Beat Saber is still launched from a Windows install or a mounted Windows filesystem.
## 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.
The core idea:
```text
Nix flake / plugin set
fetch exact GitHub release assets
verify hashes
unpack and normalize Plugins/, Libs/, IPA/Pending/
produce /nix/store/...-beatsaber-plugins-<game-version>/
plugin-helper
run nix build .#pluginSets.<game-version>
compare the resulting tree to the target Beat Saber instance
create a normal dry-run plan
copy or link files into the instance
record activation state
```
In that model, the plugin folder effectively gets a reproducible lock:
- `flake.lock` pins Nix inputs.
- A plugin-set definition pins plugin repositories, tags, release assets, and hashes.
- The generated Nix output is a canonical, immutable plugin tree for one Beat Saber version.
- `plugin-helper` remains the safety layer around activation and rollback.
## Why Wait For Proton
For the current dual-boot Windows path, a pure Nix-store plugin tree is awkward:
- Windows cannot use `/nix/store` paths directly.
- Linux symlinks inside a mounted Windows filesystem may not behave the way native Windows Beat Saber expects.
- Some plugins may create or expect colocated mutable files.
When running through Proton on Linux, Nix-store outputs and symlink activation become much more practical. Even then, `copy` mode should remain available for plugins that expect writable colocated files.
## Activation Modes
A future Nix-backed planner should support at least these activation modes:
- `copy`: materialize files into the Beat Saber instance. Best compatibility, including mounted Windows trees.
- `symlink`: link plugin files from the Nix output. Best reproducibility and cleanup on Linux/Proton.
- `materialize`: link immutable files where safe and copy known-mutable files.
All modes should still produce the same kind of explicit plan before applying.
## Proposed Milestones
1. Keep the Python safety harness stable: scan, plan, apply, uninstall, and backups.
2. Model one real plugin end to end with the current TOML lockfile and local asset planning.
3. Add a Nix function that fetches and unpacks one locked plugin asset into a normalized tree.
4. Generate a full plugin-set derivation for one Beat Saber version.
5. Teach `plugin-helper plan` to compare a Nix output tree against an instance.
6. Add `--activation-mode copy|symlink|materialize`.
7. Move compatibility and dependency metadata toward shared data that both Python and Nix can consume.
## Open Questions
- Should the human-edited source of truth be TOML, Nix, or TOML that generates Nix?
- How should plugin-specific unpack rules be represented without making Nix expressions too noisy?
- Which plugin files are known to need mutability after install?
- Should the Nix output include BSIPA itself, or continue assuming BSIPA is provided by the game instance manager?
- How should updates be proposed: Python querying GitHub, Nix update scripts, or both?
+47
View File
@@ -0,0 +1,47 @@
{
description = "Beat Saber plugin-helper CLI";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
python = pkgs.python313;
in
{
packages.${system}.default = python.pkgs.buildPythonApplication {
pname = "plugin-helper";
version = "0.1.0";
src = ./.;
pyproject = true;
build-system = with python.pkgs; [
setuptools
wheel
];
nativeCheckInputs = [ ];
checkPhase = ''
runHook preCheck
python -m unittest discover -s tests
runHook postCheck
'';
};
apps.${system}.default = {
type = "app";
program = "${self.packages.${system}.default}/bin/plugin-helper";
};
devShells.${system}.default = pkgs.mkShell {
packages = [
python
pkgs.ruff
];
shellHook = ''
export PYTHONPATH="$PWD/src''${PYTHONPATH:+:$PYTHONPATH}"
'';
};
};
}
+18
View File
@@ -0,0 +1,18 @@
beat_saber_version = "1.40.8"
instance = "1.40.8"
# Add locked plugin selections here as release assets become managed.
#
# [[plugins]]
# id = "songcore"
# repo = "Kylemc1413/SongCore"
# tag = "v4.3.0"
# asset = "SongCore-4.3.0.zip"
# sha256 = "..."
[[plugins]]
id = "accsaber-reloaded"
repo = "not-dexter/accsaber-reloaded-plugin"
tag = "v1.1.3"
asset = "1.40.8.zip"
sha256 = "c1b1687e8378ee7f550edcbd0811fa71f8ddbaa9f167a5d1b48d4cbf9e808af7"
+19
View File
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "plugin-helper"
version = "0.1.0"
description = "A safe CLI for managing Beat Saber plugins in mounted BSManager instances."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [{ name = "plugin-helper contributors" }]
dependencies = []
[project.scripts]
plugin-helper = "plugin_helper.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
+23
View File
@@ -0,0 +1,23 @@
# Human-maintained plugin registry.
#
# Example:
# [[plugins]]
# id = "songcore"
# name = "SongCore"
# repo = "Kylemc1413/SongCore"
# asset_patterns = ["*SongCore*.zip"]
# install_strategy = "bsipa-zip"
# category = "library"
#
# [[plugins.dependencies]]
# id = "bs-utils"
# constraint = ">=1.0"
# required = true
[[plugins]]
id = "accsaber-reloaded"
name = "AccSaber Reloaded"
repo = "not-dexter/accsaber-reloaded-plugin"
asset_patterns = ["1.40.8.zip"]
install_strategy = "bsipa-zip"
category = "leaderboard"
+3
View File
@@ -0,0 +1,3 @@
"""Beat Saber plugin helper."""
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from .fsutil import sha256_file
from .models import Lockfile, Registry
from .planner import _find_asset
def check_lock(
*,
instance: str,
registry: Registry,
lockfile: Lockfile,
state_root: Path,
repo_root: Path,
) -> dict[str, Any]:
entries: list[dict[str, Any]] = []
summary = {"ok": 0, "warnings": 0, "errors": 0}
for locked in lockfile.plugins:
registry_plugin = registry.get(locked.id)
strategy = locked.install_strategy or (registry_plugin.install_strategy if registry_plugin else "manual")
messages: list[dict[str, str]] = []
if not registry_plugin:
messages.append({"level": "warning", "message": "missing registry entry"})
if strategy == "manual":
messages.append({"level": "error", "message": "manual install strategy cannot be applied"})
if not locked.asset:
messages.append({"level": "error", "message": "missing asset"})
else:
asset_path = _find_asset(locked.asset, state_root, instance, repo_root)
if not asset_path:
messages.append({"level": "error", "message": "asset not found in downloads or repo assets"})
elif locked.sha256 and sha256_file(asset_path) != locked.sha256:
messages.append({"level": "error", "message": "asset sha256 mismatch"})
if any(message["level"] == "error" for message in messages):
status = "error"
summary["errors"] += 1
elif messages:
status = "warning"
summary["warnings"] += 1
else:
status = "ok"
summary["ok"] += 1
entries.append(
{
"id": locked.id,
"repo": locked.repo or (registry_plugin.repo if registry_plugin else None),
"tag": locked.tag,
"asset": locked.asset,
"installStrategy": strategy,
"status": status,
"messages": messages,
}
)
return {
"instance": instance,
"beatSaberVersion": lockfile.beat_saber_version,
"summary": summary,
"plugins": entries,
}
+245
View File
@@ -0,0 +1,245 @@
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from .config import instances_root, repo_root, state_root
from .checker import check_lock
from .installer import apply_plan, uninstall_plugin
from .instances import get_instance, list_instances
from .models import load_lockfile, load_registry
from .planner import create_plan
from .scanner import scan_instance
from .state import load_installed_state
from .userdata import backup_userdata
def _json(data: Any) -> None:
print(json.dumps(data, indent=2, sort_keys=True))
def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None:
default = argparse.SUPPRESS if suppress_default else None
parser.add_argument("--instances-root", default=default, help="BSManager instances root")
parser.add_argument("--state-dir", default=default, help="plugin-helper state directory")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="plugin-helper")
_add_common(parser)
subcommands = parser.add_subparsers(dest="command", required=True)
subcommands.add_parser(
"instances",
help="List discovered BSManager Beat Saber instances",
parents=[_common_parent()],
)
scan = subcommands.add_parser(
"scan",
help="Inspect installed Plugins, Libs, and IPA/Pending files",
parents=[_common_parent()],
)
scan.add_argument("--instance", required=True)
scan.add_argument("--hashes", action="store_true", help="Include sha256 hashes")
scan.add_argument("--json", action="store_true", help="Print full JSON scan output")
state = subcommands.add_parser(
"state",
help="Show recorded plugin-helper install state",
parents=[_common_parent()],
)
state.add_argument("--instance", required=True)
check = subcommands.add_parser(
"check",
help="Validate local registry, lockfile, and release asset readiness",
parents=[_common_parent()],
)
check.add_argument("--instance", required=True)
check.add_argument("--registry", default="registry/plugins.toml")
check.add_argument("--lockfile")
check.add_argument("--json", action="store_true", help="Print full JSON check output")
plan = subcommands.add_parser(
"plan",
help="Create a dry-run install plan from registry and lockfile",
parents=[_common_parent()],
)
plan.add_argument("--instance", required=True)
plan.add_argument("--registry", default="registry/plugins.toml")
plan.add_argument("--lockfile")
plan.add_argument("--plugin", "--update", action="append", help="Plan only this locked plugin id; repeatable")
apply = subcommands.add_parser(
"apply",
help="Apply a previously generated plan",
parents=[_common_parent()],
)
apply.add_argument("plan")
uninstall = subcommands.add_parser(
"uninstall",
help="Remove files recorded for a managed plugin",
parents=[_common_parent()],
)
uninstall.add_argument("--instance", required=True)
uninstall.add_argument("plugin")
uninstall.add_argument("--force", action="store_true", help="Delete even when current file hashes differ")
backup = subcommands.add_parser(
"backup-userdata",
help="Create a timestamped UserData backup archive",
parents=[_common_parent()],
)
backup.add_argument("--instance", required=True)
return parser
def _common_parent() -> argparse.ArgumentParser:
parent = argparse.ArgumentParser(add_help=False)
_add_common(parent, suppress_default=True)
return parent
def run(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
inst_root = instances_root(getattr(args, "instances_root", None))
st_root = state_root(getattr(args, "state_dir", None))
try:
if args.command == "instances":
found = list_instances(inst_root)
if not found:
print(f"No Beat Saber instances found under {inst_root}")
return 1
for item in found:
flags = []
if item.has_plugins:
flags.append("Plugins")
if item.has_libs:
flags.append("Libs")
if item.has_userdata:
flags.append("UserData")
suffix = f" ({', '.join(flags)})" if flags else ""
print(f"{item.name}\t{item.path}{suffix}")
return 0
if args.command == "scan":
instance = get_instance(inst_root, args.instance)
result = scan_instance(instance.path, include_hashes=args.hashes)
if args.json:
_json(result)
else:
counts = result["counts"]
print(f"{instance.name}: {counts['files']} files")
print(f" Plugins: {counts['plugins']}")
print(f" Libs: {counts['libs']}")
print(f" IPA/Pending: {counts['pending']}")
return 0
if args.command == "state":
_json(load_installed_state(st_root, args.instance))
return 0
if args.command == "check":
root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve()
result = check_lock(
instance=args.instance,
registry=load_registry(registry_path),
lockfile=load_lockfile(lock_path),
state_root=st_root,
repo_root=root,
)
if args.json:
_json(result)
else:
summary = result["summary"]
print(
f"{args.instance}: {summary['ok']} ok, "
f"{summary['warnings']} warnings, {summary['errors']} errors"
)
for plugin in result["plugins"]:
if plugin["status"] == "ok":
continue
print(f" {plugin['id']}: {plugin['status']}")
for message in plugin["messages"]:
print(f" {message['level']}: {message['message']}")
return 2 if result["summary"]["errors"] else 0
if args.command == "plan":
instance = get_instance(inst_root, args.instance)
root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve()
registry = load_registry(registry_path)
lockfile = load_lockfile(lock_path)
selected = set(args.plugin) if args.plugin else None
plan, path = create_plan(
instance=args.instance,
instance_path=instance.path,
beat_saber_version=lockfile.beat_saber_version,
registry=registry,
lockfile=lockfile,
state_root=st_root,
repo_root=root,
selected=selected,
)
print(f"Wrote plan: {path}")
print(f"Changes: {len(plan['changes'])}")
for warning in plan["warnings"]:
print(f"Warning: {warning}", file=sys.stderr)
return 0
if args.command == "apply":
with Path(args.plan).open("r", encoding="utf-8") as handle:
plan = json.load(handle)
result = apply_plan(plan, st_root)
print(f"Applied {len(result['applied'])} file changes")
print(f"State: {result['statePath']}")
return 0
if args.command == "uninstall":
instance = get_instance(inst_root, args.instance)
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
print(f"Removed: {len(result['removed'])}")
if result["skipped"]:
print("Skipped:")
for item in result["skipped"]:
print(f" {item['path']}: {item['reason']}")
return 0 if result["stateUpdated"] else 2
if args.command == "backup-userdata":
instance = get_instance(inst_root, args.instance)
result = backup_userdata(args.instance, instance.path, st_root)
manifest = result["manifest"]
print(f"Archive: {result['archive']}")
print(f"Files: {manifest['fileCount']}")
print(f"Bytes: {manifest['totalSize']}")
return 0
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
parser.error(f"unknown command: {args.command}")
return 2
def main() -> None:
raise SystemExit(run())
if __name__ == "__main__":
main()
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import os
from pathlib import Path
DEFAULT_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances")
def instances_root(value: str | None = None) -> Path:
raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
return Path(raw).expanduser() if raw else DEFAULT_INSTANCES_ROOT
def state_root(value: str | None = None) -> Path:
if value:
return Path(value).expanduser()
xdg_state = os.environ.get("XDG_STATE_HOME")
base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state"
return base / "plugin-helper"
def repo_root() -> Path:
return Path(__file__).resolve().parents[2]
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import hashlib
import json
import os
import tempfile
from pathlib import Path
from typing import Any
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def atomic_write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, sort_keys=True)
handle.write("\n")
Path(tmp_name).replace(path)
except Exception:
Path(tmp_name).unlink(missing_ok=True)
raise
def read_json(path: Path, default: Any) -> Any:
if not path.exists():
return default
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def ensure_relative(path: str) -> Path:
if "\\" in path:
raise ValueError(f"unsafe relative path: {path}")
rel = Path(path)
if rel.is_absolute() or ".." in rel.parts or any(part.endswith(":") for part in rel.parts):
raise ValueError(f"unsafe relative path: {path}")
return rel
def ensure_inside(root: Path, target: Path) -> Path:
root_resolved = root.resolve()
target_resolved = target.resolve(strict=False)
try:
target_resolved.relative_to(root_resolved)
except ValueError as exc:
raise ValueError(f"target escapes instance root: {target}") from exc
return target_resolved
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from zipfile import ZipFile
from .fsutil import ensure_inside, ensure_relative, sha256_bytes, sha256_file
from .state import backups_dir, load_installed_state, save_installed_state
def _timestamp() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def _backup_existing(instance_path: Path, backup_root: Path, rel_target: str) -> str | None:
target = ensure_inside(instance_path, instance_path / ensure_relative(rel_target))
if not target.exists():
return None
backup_path = backup_root / rel_target
backup_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(target, backup_path)
return str(backup_path)
def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]:
instance = plan["instance"]
instance_path = Path(plan["instancePath"])
if not instance_path.is_dir():
raise FileNotFoundError(f"instance path does not exist: {instance_path}")
backup_root = backups_dir(state_root, instance) / f"apply-{_timestamp()}"
installed_state = load_installed_state(state_root, instance)
installed_state.setdefault("beatSaberVersion", plan.get("beatSaberVersion"))
installed_state.setdefault("plugins", {})
applied: list[dict[str, Any]] = []
for change in plan.get("changes", []):
source = Path(change["source"])
if sha256_file(source) != change["sourceSha256"]:
raise ValueError(f"source hash changed: {source}")
rel_target = ensure_relative(change["target"]).as_posix()
target = ensure_inside(instance_path, instance_path / rel_target)
backup = _backup_existing(instance_path, backup_root, rel_target)
target.parent.mkdir(parents=True, exist_ok=True)
if change["action"] == "copy":
shutil.copy2(source, target)
elif change["action"] == "extract":
with ZipFile(source) as archive:
data = archive.read(change["archiveMember"])
if sha256_bytes(data) != change["sha256"]:
raise ValueError(f"archive member hash changed: {change['archiveMember']}")
target.write_bytes(data)
else:
raise ValueError(f"unsupported action: {change['action']}")
actual_sha = sha256_file(target)
if actual_sha != change["sha256"]:
raise ValueError(f"installed file hash mismatch: {rel_target}")
plugin_state = installed_state["plugins"].setdefault(
change["plugin"],
{
"installedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"files": [],
},
)
plugin_state["files"] = [
item for item in plugin_state.get("files", []) if item.get("path") != rel_target
]
plugin_state["files"].append(
{
"path": rel_target,
"sha256": actual_sha,
"size": target.stat().st_size,
}
)
applied.append({"path": rel_target, "plugin": change["plugin"], "backup": backup})
save_installed_state(state_root, instance, installed_state)
return {"applied": applied, "statePath": str(state_root / "instances" / instance / "installed.json")}
def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugin_id: str, force: bool = False) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance)
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}")
removed: list[str] = []
skipped: list[dict[str, str]] = []
for item in plugin_state.get("files", []):
rel_path = ensure_relative(item["path"]).as_posix()
target = ensure_inside(instance_path, instance_path / rel_path)
if not target.exists():
removed.append(rel_path)
continue
current_sha = sha256_file(target)
if current_sha != item.get("sha256") and not force:
skipped.append({"path": rel_path, "reason": "hash mismatch"})
continue
target.unlink()
removed.append(rel_path)
if skipped and not force:
return {"removed": removed, "skipped": skipped, "stateUpdated": False}
installed_state.get("plugins", {}).pop(plugin_id, None)
save_installed_state(state_root, instance, installed_state)
return {"removed": removed, "skipped": skipped, "stateUpdated": True}
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Instance:
name: str
path: Path
has_plugins: bool
has_libs: bool
has_userdata: bool
def looks_like_instance(path: Path) -> bool:
return (
(path / "Beat Saber_Data").is_dir()
or (path / "Beat Saber.exe").exists()
or (path / "Plugins").is_dir()
or (path / "UserData").is_dir()
)
def list_instances(root: Path) -> list[Instance]:
if not root.exists():
return []
instances: list[Instance] = []
for child in sorted(root.iterdir(), key=lambda item: item.name):
if not child.is_dir() or not looks_like_instance(child):
continue
instances.append(
Instance(
name=child.name,
path=child,
has_plugins=(child / "Plugins").is_dir(),
has_libs=(child / "Libs").is_dir(),
has_userdata=(child / "UserData").is_dir(),
)
)
return instances
def get_instance(root: Path, name: str) -> Instance:
path = root / name
if not path.is_dir() or not looks_like_instance(path):
raise FileNotFoundError(f"Beat Saber instance not found: {path}")
return Instance(
name=name,
path=path,
has_plugins=(path / "Plugins").is_dir(),
has_libs=(path / "Libs").is_dir(),
has_userdata=(path / "UserData").is_dir(),
)
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
VALID_STRATEGIES = {"dll-to-plugins", "zip-to-pending", "bsipa-zip", "root-zip", "manual"}
@dataclass(frozen=True)
class Dependency:
id: str
constraint: str | None = None
required: bool = True
@dataclass(frozen=True)
class RegistryPlugin:
id: str
name: str
repo: str | None
asset_patterns: tuple[str, ...] = ()
install_strategy: str = "manual"
category: str | None = None
dependencies: tuple[Dependency, ...] = ()
@dataclass(frozen=True)
class Registry:
plugins: dict[str, RegistryPlugin] = field(default_factory=dict)
def get(self, plugin_id: str) -> RegistryPlugin | None:
return self.plugins.get(plugin_id)
@dataclass(frozen=True)
class LockedPlugin:
id: str
repo: str | None
tag: str | None
asset: str | None
sha256: str | None
install_strategy: str | None = None
reason: str | None = None
@dataclass(frozen=True)
class Lockfile:
beat_saber_version: str
instance: str
plugins: tuple[LockedPlugin, ...]
def _load_toml(path: Path) -> dict[str, Any]:
with path.open("rb") as handle:
return tomllib.load(handle)
def load_registry(path: Path) -> Registry:
if not path.exists():
return Registry()
data = _load_toml(path)
plugins: dict[str, RegistryPlugin] = {}
for item in data.get("plugins", []):
dependencies = tuple(
Dependency(
id=dep["id"],
constraint=dep.get("constraint"),
required=dep.get("required", True),
)
for dep in item.get("dependencies", [])
)
strategy = item.get("install_strategy", "manual")
if strategy not in VALID_STRATEGIES:
raise ValueError(f"{path}: invalid install_strategy for {item['id']}: {strategy}")
plugin = RegistryPlugin(
id=item["id"],
name=item.get("name", item["id"]),
repo=item.get("repo"),
asset_patterns=tuple(item.get("asset_patterns", [])),
install_strategy=strategy,
category=item.get("category"),
dependencies=dependencies,
)
plugins[plugin.id] = plugin
return Registry(plugins)
def load_lockfile(path: Path) -> Lockfile:
data = _load_toml(path)
plugins = tuple(
LockedPlugin(
id=item["id"],
repo=item.get("repo"),
tag=item.get("tag"),
asset=item.get("asset"),
sha256=item.get("sha256"),
install_strategy=item.get("install_strategy"),
reason=item.get("reason"),
)
for item in data.get("plugins", [])
)
return Lockfile(
beat_saber_version=data["beat_saber_version"],
instance=data.get("instance", data["beat_saber_version"]),
plugins=plugins,
)
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
import fnmatch
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from zipfile import ZipFile
from .fsutil import ensure_relative, sha256_bytes, sha256_file
from .models import Lockfile, Registry, VALID_STRATEGIES
from .state import downloads_dir, plans_dir
ALLOWED_BSIPA_TOP_LEVEL = {"IPA", "Libs", "Plugins"}
def _now_slug() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def _find_asset(asset: str, state_root: Path, instance: str, repo_root: Path) -> Path | None:
candidates = [
Path(asset).expanduser(),
downloads_dir(state_root, instance) / asset,
repo_root / "assets" / asset,
repo_root / "locks" / "assets" / asset,
]
for candidate in candidates:
if candidate.exists() and candidate.is_file():
return candidate
return None
def _zip_members(asset_path: Path) -> list[tuple[str, int, str]]:
result: list[tuple[str, int, str]] = []
with ZipFile(asset_path) as archive:
for info in archive.infolist():
if info.is_dir():
continue
rel = ensure_relative(info.filename).as_posix()
data = archive.read(info)
result.append((rel, len(data), sha256_bytes(data)))
return result
def _target_for_member(strategy: str, member: str) -> str:
rel = ensure_relative(member).as_posix()
if strategy == "zip-to-pending":
return f"IPA/Pending/{rel}"
if strategy == "root-zip":
return rel
if strategy == "bsipa-zip":
top = rel.split("/", 1)[0]
if top not in ALLOWED_BSIPA_TOP_LEVEL:
raise ValueError(f"bsipa-zip member has unsupported top-level path: {rel}")
return rel
raise ValueError(f"unsupported zip strategy: {strategy}")
def _asset_matches_patterns(name: str, patterns: tuple[str, ...]) -> bool:
return not patterns or any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
def create_plan(
*,
instance: str,
instance_path: Path,
beat_saber_version: str,
registry: Registry,
lockfile: Lockfile,
state_root: Path,
repo_root: Path,
selected: set[str] | None = None,
) -> tuple[dict[str, Any], Path]:
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
changes: list[dict[str, Any]] = []
warnings: list[str] = []
for locked in lockfile.plugins:
if locked.id not in selected_ids:
continue
registry_plugin = registry.get(locked.id)
strategy = locked.install_strategy or (registry_plugin.install_strategy if registry_plugin else "manual")
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")
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")
asset_path = _find_asset(locked.asset, state_root, instance, repo_root)
if not asset_path:
raise FileNotFoundError(
f"{locked.id}: asset not found: {locked.asset}; put it in {downloads_dir(state_root, instance)}"
)
asset_sha = sha256_file(asset_path)
if locked.sha256 and locked.sha256 != asset_sha:
raise ValueError(f"{locked.id}: asset sha256 mismatch for {asset_path}")
if strategy == "dll-to-plugins":
if asset_path.suffix.lower() != ".dll":
raise ValueError(f"{locked.id}: dll-to-plugins expects a .dll asset")
changes.append(
{
"plugin": locked.id,
"action": "copy",
"source": str(asset_path),
"sourceSha256": asset_sha,
"target": f"Plugins/{asset_path.name}",
"size": asset_path.stat().st_size,
"sha256": asset_sha,
}
)
continue
if asset_path.suffix.lower() != ".zip":
raise ValueError(f"{locked.id}: {strategy} expects a .zip asset")
for member, size, member_sha in _zip_members(asset_path):
changes.append(
{
"plugin": locked.id,
"action": "extract",
"source": str(asset_path),
"sourceSha256": asset_sha,
"archiveMember": member,
"target": _target_for_member(strategy, member),
"size": size,
"sha256": member_sha,
}
)
plan = {
"schemaVersion": 1,
"createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"instance": instance,
"instancePath": str(instance_path),
"beatSaberVersion": beat_saber_version,
"warnings": warnings,
"changes": changes,
}
plan_path = plans_dir(state_root, instance) / f"plan-{_now_slug()}.json"
with plan_path.open("w", encoding="utf-8") as handle:
json.dump(plan, handle, indent=2, sort_keys=True)
handle.write("\n")
return plan, plan_path
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from .fsutil import sha256_file
SCAN_DIRS = ("Plugins", "Libs", "IPA/Pending")
def scan_instance(instance_path: Path, include_hashes: bool = False) -> dict[str, Any]:
files: list[dict[str, Any]] = []
for dirname in SCAN_DIRS:
root = instance_path / dirname
if not root.exists():
continue
for path in sorted(item for item in root.rglob("*") if item.is_file()):
rel = path.relative_to(instance_path).as_posix()
entry: dict[str, Any] = {"path": rel, "size": path.stat().st_size}
if include_hashes:
entry["sha256"] = sha256_file(path)
files.append(entry)
return {
"instancePath": str(instance_path),
"files": files,
"counts": {
"files": len(files),
"plugins": sum(1 for item in files if item["path"].startswith("Plugins/")),
"libs": sum(1 for item in files if item["path"].startswith("Libs/")),
"pending": sum(1 for item in files if item["path"].startswith("IPA/Pending/")),
},
}
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .fsutil import atomic_write_json, read_json
def instance_state_dir(state_root: Path, instance: str) -> Path:
return state_root / "instances" / instance
def installed_state_path(state_root: Path, instance: str) -> Path:
return instance_state_dir(state_root, instance) / "installed.json"
def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
return read_json(
installed_state_path(state_root, instance),
{"instance": instance, "plugins": {}},
)
def save_installed_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
state.setdefault("instance", instance)
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
atomic_write_json(installed_state_path(state_root, instance), state)
def plans_dir(state_root: Path, instance: str) -> Path:
path = instance_state_dir(state_root, instance) / "plans"
path.mkdir(parents=True, exist_ok=True)
return path
def downloads_dir(state_root: Path, instance: str) -> Path:
path = instance_state_dir(state_root, instance) / "downloads"
path.mkdir(parents=True, exist_ok=True)
return path
def backups_dir(state_root: Path, instance: str) -> Path:
path = instance_state_dir(state_root, instance) / "backups"
path.mkdir(parents=True, exist_ok=True)
return path
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import json
import tarfile
from datetime import datetime, timezone
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Any
from .fsutil import sha256_file
from .state import backups_dir
def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]:
source = instance_path / "UserData"
if not source.is_dir():
raise FileNotFoundError(f"UserData directory not found: {source}")
created_at = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
destination = backups_dir(state_root, instance) / f"userdata-{created_at}.tar.gz"
files: list[dict[str, Any]] = []
total_size = 0
for path in sorted(item for item in source.rglob("*") if item.is_file()):
rel = path.relative_to(instance_path).as_posix()
size = path.stat().st_size
total_size += size
files.append({"path": rel, "size": size, "sha256": sha256_file(path)})
manifest = {
"createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"instance": instance,
"source": str(source),
"fileCount": len(files),
"totalSize": total_size,
"files": files,
}
destination.parent.mkdir(parents=True, exist_ok=True)
with tarfile.open(destination, "w:gz") as archive:
archive.add(source, arcname="UserData")
with NamedTemporaryFile("w", encoding="utf-8", suffix=".json") as handle:
json.dump(manifest, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
archive.add(handle.name, arcname="manifest.json")
return {"archive": str(destination), "manifest": manifest}
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from zipfile import ZipFile
from plugin_helper.checker import check_lock
from plugin_helper.fsutil import sha256_file
from plugin_helper.installer import apply_plan, uninstall_plugin
from plugin_helper.instances import get_instance, list_instances
from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin
from plugin_helper.planner import create_plan
from plugin_helper.scanner import scan_instance
from plugin_helper.state import downloads_dir, load_installed_state
from plugin_helper.userdata import backup_userdata
class PluginHelperTests(unittest.TestCase):
def test_instances_and_scan(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
inst = root / "1.40.8"
(inst / "Beat Saber_Data").mkdir(parents=True)
(inst / "Plugins").mkdir()
(inst / "Libs").mkdir()
(inst / "UserData").mkdir()
(inst / "Plugins" / "Example.dll").write_bytes(b"dll")
(root / "not-an-instance").mkdir()
instances = list_instances(root)
self.assertEqual([item.name for item in instances], ["1.40.8"])
self.assertEqual(get_instance(root, "1.40.8").path, inst)
scan = scan_instance(inst, include_hashes=True)
self.assertEqual(scan["counts"]["plugins"], 1)
self.assertEqual(scan["files"][0]["path"], "Plugins/Example.dll")
self.assertIn("sha256", scan["files"][0])
def test_plan_apply_and_uninstall_dll(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
asset = downloads_dir(state, "1.40.8") / "Example.dll"
asset.write_bytes(b"managed dll")
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("*.dll",),
install_strategy="dll-to-plugins",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo="owner/example",
tag="v1.0.0",
asset="Example.dll",
sha256=sha256_file(asset),
),
),
)
plan, plan_path = create_plan(
instance="1.40.8",
instance_path=instance,
beat_saber_version="1.40.8",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
)
self.assertTrue(plan_path.exists())
self.assertEqual(plan["changes"][0]["target"], "Plugins/Example.dll")
result = apply_plan(plan, state)
self.assertEqual(len(result["applied"]), 1)
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
installed = load_installed_state(state, "1.40.8")
self.assertIn("example", installed["plugins"])
removed = uninstall_plugin("1.40.8", instance, state, "example")
self.assertEqual(removed["removed"], ["Plugins/Example.dll"])
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
def test_zip_to_pending_targets_ipa_pending(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
asset = downloads_dir(state, "1.40.8") / "Example.zip"
with ZipFile(asset, "w") as archive:
archive.writestr("Plugins/Example.dll", b"dll")
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="zip-to-pending",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo=None,
tag=None,
asset="Example.zip",
sha256=sha256_file(asset),
),
),
)
plan, _ = create_plan(
instance="1.40.8",
instance_path=instance,
beat_saber_version="1.40.8",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
)
self.assertEqual(plan["changes"][0]["target"], "IPA/Pending/Plugins/Example.dll")
apply_plan(plan, state)
self.assertEqual((instance / "IPA" / "Pending" / "Plugins" / "Example.dll").read_bytes(), b"dll")
def test_zip_member_cannot_escape_instance(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
asset = downloads_dir(state, "1.40.8") / "Bad.zip"
with ZipFile(asset, "w") as archive:
archive.writestr("../Bad.dll", b"dll")
registry = Registry(
{
"bad": RegistryPlugin(
id="bad",
name="Bad",
repo=None,
install_strategy="zip-to-pending",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="bad",
repo=None,
tag=None,
asset="Bad.zip",
sha256=sha256_file(asset),
),
),
)
with self.assertRaises(ValueError):
create_plan(
instance="1.40.8",
instance_path=instance,
beat_saber_version="1.40.8",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
)
def test_userdata_backup_contains_manifest(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instance = root / "1.40.8"
state = root / "state"
(instance / "UserData").mkdir(parents=True)
(instance / "UserData" / "settings.json").write_text("{}", encoding="utf-8")
result = backup_userdata("1.40.8", instance, state)
self.assertTrue(Path(result["archive"]).exists())
self.assertEqual(result["manifest"]["fileCount"], 1)
def test_check_reports_missing_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
state = work / "state"
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="dll-to-plugins",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo=None,
tag="v1.0.0",
asset="Missing.dll",
sha256=None,
),
),
)
result = check_lock(
instance="1.40.8",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
)
self.assertEqual(result["summary"]["errors"], 1)
self.assertEqual(result["plugins"][0]["status"], "error")
if __name__ == "__main__":
unittest.main()