Merge branch 'main' of gitea.satstack.dev:pleb/beatsaber-plugin-helper

This commit is contained in:
pleb
2026-07-11 15:14:17 -07:00
35 changed files with 2242 additions and 214 deletions
@@ -7,6 +7,13 @@ description: Build, test-compile, or package Beat Saber PC BSIPA plugin source o
Use this skill to compile PC BSIPA plugin projects on this Linux host, especially from the `plugin-helper` repo. The workflow is adapted from the Setlist repo's Linux/Cursor build notes.
Before building, read the shared policy references:
- [repo-workflow.md](../references/repo-workflow.md) for repo root, `.venv`, `PYTHONPATH=src`, dirty worktree handling, and validation commands.
- [state-and-profiles.md](../references/state-and-profiles.md) for `.state`, profiles, install identity, instance selection, and checkout locations.
- [artifact-policy.md](../references/artifact-policy.md) when handing a built artifact to `plugin-helper`.
- [live-validation.md](../references/live-validation.md) when validating a built plugin in-game.
For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-bsipa-build.md) when you need to configure a project, fix missing references, package artifacts, or explain a failure.
## Core Workflow
@@ -19,17 +26,14 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b
sed -n '1,220p' plugin-helper.local.toml
```
In `plugin-helper`, run commands from repo root. Treat
`plugin-helper.local.toml` as the source of truth for profile
`instances_root` and `state_dir` values, and keep temporary source checkouts
under the chosen profile's `state_dir` such as
`<state_dir>/build/<name>`. Do not disturb unrelated dirty files.
In `plugin-helper`, run commands from repo root. Treat shared profile and
dirty-worktree policy as binding.
2. Resolve source.
For a GitHub PR, clone or reuse a checkout under the selected profile's
`<state_dir>/build`, add/fetch the upstream remote if needed, and check out
the PR head:
For a GitHub PR, clone or reuse a checkout according to the shared source
checkout policy, add/fetch the upstream remote if needed, and check out the
PR head:
```bash
git clone https://github.com/<owner>/<repo>.git <state_dir>/build/<name>
@@ -37,7 +41,8 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b
git -C <state_dir>/build/<name> checkout pr-<pr>
```
If the PR is from a fork and the repo already has a fork remote, preserve it. Never overwrite local source changes without explicit approval.
If the PR is from a fork and the repo already has a fork remote, preserve it.
Never overwrite local source changes without explicit approval.
3. Inspect build shape.
@@ -51,9 +56,11 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b
4. Choose Beat Saber references.
Prefer a BSManager instance matching the plugin or manifest `gameVersion`.
Read `plugin-helper.local.toml` and select the intended profile, then use
that profile's `instances_root` and matching `state_dir` instead of
searching default BSManager paths manually:
If the user did not specify an instance and the plugin does not require a
narrower version, use the latest available BSInstance. Read
`plugin-helper.local.toml` and select the intended profile, then use that
profile's `instances_root` and matching `state_dir` instead of searching
default BSManager paths manually:
```bash
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> instances
@@ -107,7 +114,7 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b
8. Validate.
For skill edits inside this repo, run:
For skill edits inside this repo, run the shared skill validation plus:
```bash
python /home/pleb/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/beatsaber-plugin-builder
@@ -43,7 +43,7 @@ Prefer machine-local configuration in `<Project>.csproj.user`:
```xml
<Project>
<PropertyGroup>
<BeatSaberDir>/path/from/selected/profile/instances_root/1.44.1</BeatSaberDir>
<BeatSaberDir>/path/from/selected/profile/instances_root/selected-instance</BeatSaberDir>
</PropertyGroup>
</Project>
```
@@ -7,32 +7,12 @@ description: Install or update a Beat Saber plugin in the plugin-helper repo by
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation.
## Hard Guardrail
Before acting, read the shared policy references:
For ordinary GitHub-hosted plugins, require an explicit GitHub repository or
release URL from the user's prompt or from a user-provided local planning note
before selecting any release.
- If the user has provided a GitHub repository URL but not a release URL, use
that exact repository's release API and choose the most appropriate
non-draft, non-prerelease release/asset for the target Beat Saber instance.
- If the user has provided no GitHub repository or release URL for the plugin,
stop and ask the user for one.
- Do not search the web to discover a repository or "correct" project 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.
Exception: if the user explicitly asks to bootstrap a Beat Saber version or
install verified mods without providing GitHub URLs, use BeatMods metadata to
identify compatible versions and dependency closure. Still prefer upstream
GitHub release artifacts when BeatMods exposes a `gitUrl` and a matching
release/asset can be found. Use BeatMods CDN artifacts only when the upstream
artifact is inaccessible, no matching upstream release asset exists, the package
is effectively BeatMods-only, or the package is a framework/library dependency
such as .NET assemblies. Record the artifact source plus BeatMods `modVersion`,
version id, `zipHash`, dependencies, and supported game version in the repo
notes/lock data.
- [repo-workflow.md](../references/repo-workflow.md) for repo root, `.venv`, `PYTHONPATH=src`, dirty worktree handling, and validation commands.
- [state-and-profiles.md](../references/state-and-profiles.md) for `.state`, profiles, install identity, instance selection, and checkout locations.
- [artifact-policy.md](../references/artifact-policy.md) for GitHub/BeatMods/private-source/checksum/bootstrap policy.
- [live-validation.md](../references/live-validation.md) for smoke tests, logs, and process cleanup.
## Workflow
@@ -58,10 +38,12 @@ notes/lock data.
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:
Prefer the instance the user names. If omitted, use the latest available
BSInstance unless the current task context clearly points at another
instance:
```bash
PYTHONPATH=src python -m plugin_helper instances
PYTHONPATH=src .venv/bin/python -m plugin_helper instances
```
4. Resolve the release source.
@@ -69,7 +51,7 @@ notes/lock data.
For BeatMods bootstrap or verified packages, query BeatMods with a browser-like user agent:
```bash
PYTHONPATH=src python - <<'PY'
PYTHONPATH=src .venv/bin/python - <<'PY'
import json, urllib.request
from plugin_helper.beatmods import by_version_id, normalize_mods
@@ -104,22 +86,13 @@ notes/lock data.
PY
```
BeatMods dependency entries are mod-version ids. Resolve the selected mod's
dependency closure before downloading. For each resolved package, prefer its
upstream `gitUrl` release artifacts when a matching release asset exists.
Fall back to BeatMods CDN only for inaccessible/missing upstream assets,
BeatMods-only packages, or framework/library dependencies. CDN URLs are:
Follow the artifact policy for GitHub-first sourcing, BeatMods exceptions,
and dependency closure. BeatMods CDN URLs are:
```text
https://beatmods.com/cdn/mod/<zipHash>.zip
```
For BSIPA bootstrap, expect the archive to contain root-relative `IPA/` and
`IPA.exe` files whether sourced from GitHub or BeatMods. Extract it into the
instance root and run `IPA.exe -n` under the same Proton environment used by
the smoketest. This creates/copies the root `winhttp.dll` and root `Libs/`
substrate that IPA needs.
For GitHub URLs, resolve the release from the user-provided repository or
release URL only.
@@ -148,13 +121,7 @@ release URL only.
unzip -l .state/instances/<instance>/downloads/<plugin-id>/<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. Use this for BSIPA/bootstrap archives because `IPA.exe`, `IPA.runtimeconfig*.json`, and root `winhttp.dll` are game-root files.
- `zip-to-pending`: only when the release is intended for `IPA/Pending/`.
- `manual`: do not use for installable releases.
Use the install strategy guide in the artifact policy.
6. Update the registry and lockfile.
@@ -188,9 +155,9 @@ release URL only.
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>
PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state check --instance <instance>
PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state plan --instance <instance> --plugin <plugin-id>
PYTHONPATH=src .venv/bin/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.
@@ -200,41 +167,15 @@ release URL only.
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
PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state state --instance <instance>
PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state check --instance <instance>
PYTHONPATH=src .venv/bin/python -m compileall -q src tests
PYTHONPATH=src .venv/bin/python -m unittest discover -s tests
```
Use `PYTHONPATH=src`; plain `python -m unittest` may fail in this source-layout repo.
After any successful apply that changes a live BSManager instance, always
run the documented live smoketest before the final response unless the user
explicitly says not to. Do not stop at helper check, unit tests, compile
checks, or file-hash verification for live installs. For live Beat Saber
validation, follow `docs/SMOKETEST.md`. Before starting the launch, announce
in agent chat how long the smoketest window will run for, using the current
duration from `docs/SMOKETEST.md` unless the user requested a different
duration. Do not rely on `timeout` to kill the full game process tree.
Prefer the documented foreground Proton launch with a background watchdog
that sleeps for the smoke window, then terminates Beat Saber by process
name. Confirm `Logs/_latest.log` has the expected IPA/plugin lines and
enough menu/UI initialization evidence for the plugin under test. If the
game remains open after the watchdog cleanup, say so and ask the user to
close it manually rather than leaving the turn with Beat Saber running.
For BSIPA/SongCore bootstrap, expected successful log lines include:
```text
Game version <version>
Loading plugins from Plugins and found <n>
Beat Saber IPA (BSIPA): <version>
SongCore (SongCore): <version>
```
Warnings about older mod target game-version metadata can be acceptable when
BeatMods verified that exact package for the target Beat Saber version, but
record them in the tracker or roadmap. Also record when a BeatMods CDN
artifact was used so it can be migrated to upstream GitHub later if possible.
For live Beat Saber validation, follow the live-validation reference. Also
record when a BeatMods CDN artifact was used so it can be migrated to
upstream GitHub later if possible.
9. Final response.
@@ -0,0 +1,86 @@
---
name: beatsaber-plugin-update-auditor
description: Audit Beat Saber plugin-helper locks for available plugin updates and follow-up work. Use when the user asks to check all Beat Saber plugins for updates, compare locked mods against GitHub or BeatMods, review plugin/version compatibility tracking notes, identify PR/local/manual builds that need follow-up, or skip paid/private Patreon and Discord plugin sources during update checks.
---
# Beat Saber Plugin Update Auditor
Use this skill from the `plugin-helper` repo to produce an update audit, not to
blindly update plugins. Keep public, private, and experimental sources distinct.
Before auditing, read the shared policy references:
- [repo-workflow.md](../references/repo-workflow.md) for repo root, `.venv`, `PYTHONPATH=src`, dirty worktree handling, and validation commands.
- [state-and-profiles.md](../references/state-and-profiles.md) for `.state`, profiles, install identity, instance selection, and checkout locations.
- [artifact-policy.md](../references/artifact-policy.md) for GitHub/BeatMods/private-source/checksum policy.
- [live-validation.md](../references/live-validation.md) only when reviewing smoke-test notes or launch failures.
## Workflow
1. Confirm repo context:
```bash
test -f pyproject.toml && test -d src/plugin_helper && test -d locks && test -d registry/plugins
git status --short
```
2. Choose the instance from the user request. If omitted, use the latest
available BSInstance unless the current task context clearly points at
another instance:
```bash
PYTHONPATH=src .venv/bin/python -m plugin_helper instances
```
3. Run the update audit with repo-local state unless the user explicitly targets
another configured profile:
```bash
PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state updates --instance <instance>
PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state updates --instance <instance> --json
```
The command checks ordinary GitHub locks, `beatmods-*` locks via BeatMods
verified metadata, skips `patreon/` and `discord/` sources, and marks local,
PR, manual, failed-trial, or pending-smoke entries as review items.
4. Read the active compatibility tracker before proposing updates for review
items. For the current migration notes, inspect:
```bash
sed -n '1,380p' docs/notes/install-and-verify-plugins-1.44.1.md
sed -n '1,220p' docs/notes/naluluna-mod-assistant.md
```
Follow up on rows marked failed, omitted, currently failed, smoke blocked,
manual resmoke pending, local build, PR build, or local/private. Do not
reinstall failed or omitted plugins unless the notes identify a newer
compatible source.
5. For each public update candidate, inspect source notes before changing the
lock. Follow the shared artifact policy for GitHub-first sourcing and
BeatMods fallback cases.
6. Do not attempt automated public update checks for:
- `patreon/NalulunaModAssistant` or Naluluna bundles
- `discord/BeatSaberPlus` or BeatSaberPlus/ChatPlexSDK bundles
- other paid or closed-source plugin zips
Report them as skipped/manual follow-up only.
7. If the user asks to apply an update, switch to the `beatsaber-plugin-manager`
workflow for download, lock update, planning, apply, and smoketest.
## Output
Summarize in four groups:
- Public updates found: plugin, current version, candidate version, source.
- Current public locks: count only unless the user asks for every row.
- Review follow-ups: local builds, PR builds, failed trials, blocked smokes,
or notes saying compatibility work is pending.
- Skipped private sources: Patreon/Discord paid or closed-source packages.
Mention BeatMods/GitHub API errors separately from "no update found"; those are
unknowns, not proof that the plugin is current.
@@ -0,0 +1,4 @@
interface:
display_name: "Beat Saber Plugin Update Auditor"
short_description: "Audit plugin locks for GitHub and BeatMods updates."
default_prompt: "Check the locked Beat Saber plugins for updates and review items."
@@ -0,0 +1,85 @@
# Beat Saber Artifact Policy
Use this policy for plugin install/update/bootstrap tasks and for audit reports
that discuss candidate artifacts.
## Source Priority
- Prefer upstream GitHub release artifacts for ordinary repository-backed
plugins.
- Use BeatMods primarily as compatibility, dependency, and verification
metadata.
- Use BeatMods CDN artifacts only for inaccessible upstream assets,
BeatMods-only packages, framework/library dependencies, or cases where no
matching upstream release asset exists.
- Skip paid/private Patreon, Discord, and closed-source sources during automated
public update checks. Report them as manual follow-up unless the user provides
the artifact and asks to manage it.
## Repository Guardrail
- For ordinary GitHub-hosted plugins, require an explicit GitHub repository,
release URL, or user-provided local planning note before selecting a release.
- If the user provided a repo URL but no release URL, query that exact repo's
releases and choose the most appropriate non-draft, non-prerelease release for
the target instance.
- If the user provided no repo or release URL for a plugin install/update, stop
and ask for one.
- Do not discover a different repository from web search, substitute a fork, or
infer the "correct" project from a similar package name.
- If the URL is a releases page, use that repo's release API and choose the
latest non-draft, non-prerelease release unless the user asks for a tag.
- If the URL is a tag URL, use that exact tag.
## BeatMods Exceptions
When the user explicitly asks to bootstrap a Beat Saber instance or install
verified mods without GitHub URLs, use BeatMods verified metadata to identify
compatible versions and dependency closure. Still prefer upstream GitHub assets
when BeatMods exposes a `gitUrl` and a matching release asset can be found.
Record the artifact source plus BeatMods `modVersion`, version id, `zipHash`,
dependencies, and supported game version in repo notes or lock data when
BeatMods metadata drives the selection.
BeatMods dependency entries are mod-version ids. Resolve the selected mod's
dependency closure before downloading.
## Checksums And Inspection
- Download artifacts into the selected helper state directory, normally:
```bash
<state_dir>/instances/<instance>/downloads/<plugin-id>/
```
- Record SHA-256 checksums for downloaded or built artifacts.
- Match the checksum against GitHub's `digest` when available.
- For BeatMods CDN artifacts, preserve BeatMods `zipHash` metadata and verify
the downloaded archive against the expected hash when the helper supports it.
- Inspect archive contents before selecting install strategy:
```bash
unzip -l <artifact>
```
## Install 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. Use this for BSIPA/bootstrap archives because `IPA.exe`,
`IPA.runtimeconfig*.json`, and root `winhttp.dll` are game-root files.
- `zip-to-pending`: only when the release is intended for `IPA/Pending/`.
- `manual`: do not use for installable releases.
## BSIPA Bootstrap
Treat BSIPA as a bootstrap phase. `bootstrap` installs the locked BSIPA archive
and records generated files. Ordinary plugin plans should depend on healthy
bootstrap state.
For BSIPA bootstrap archives, expect root-relative `IPA/` and `IPA.exe` files
whether sourced from GitHub or BeatMods. Extract into the instance root and run
`IPA.exe -n` under the same Proton environment used by the smoke test. This
creates or copies root `winhttp.dll` and root `Libs/` substrate files.
@@ -0,0 +1,43 @@
# Beat Saber Live Validation
Use this policy when a task changes or verifies a live BSManager instance.
## Smoke Test
- Follow `docs/SMOKETEST.md` for live game validation.
- Before starting Beat Saber, announce how long the smoke window will run, using
the current duration in `docs/SMOKETEST.md` unless the user requested a
different duration.
- Do not rely on `timeout` to kill the full game process tree.
- Prefer the documented foreground Proton launch with a background watchdog that
sleeps for the smoke window, then terminates Beat Saber by process name.
- After the run, confirm no Beat Saber process remains. If cleanup fails, say so
and ask the user to close it manually.
## Required After Live Apply
After any successful helper `apply` that changes a live BSManager instance, run
the documented live smoke test before the final response unless the user
explicitly says not to. Helper `check`, unit tests, compile checks, and file-hash
verification are useful but do not replace live validation.
## Logs
Inspect `Logs/_latest.log`, Unity `Player.log` when relevant, and the live
process command line before calling a black screen or launch failure a plugin
failure. Duplicate launch args such as repeated `--no-yeet fpfc` can trigger
fatal command-line parsing after BSIPA/plugin loading succeeds.
Expected successful BSIPA/SongCore lines include:
```text
Game version <version>
Loading plugins from Plugins and found <n>
Beat Saber IPA (BSIPA): <version>
SongCore (SongCore): <version>
```
Warnings about older mod target game-version metadata can be acceptable when
BeatMods verified that exact package for the target Beat Saber version. Record
them in the tracker or roadmap rather than treating them as automatic install
failure.
@@ -0,0 +1,52 @@
# plugin-helper Repo Workflow
Use these rules for all Beat Saber `plugin-helper` skills.
## Repo Context
- Work from the `plugin-helper` repo root.
- Confirm context before acting:
```bash
test -f pyproject.toml && test -d src/plugin_helper && test -d registry && test -d locks
git status --short
```
- The worktree may already be dirty. Treat existing changes as user work:
preserve them, do not revert them, and avoid unrelated formatting or metadata
churn.
- Use `rg`/`rg --files` for search.
## Python Commands
- Run helper commands with `PYTHONPATH=src`.
- Prefer `.venv/bin/python` when `.venv` exists; otherwise use `python`.
- For human-style inspection, prefer repo-local state:
```bash
PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state menu
```
## Validation
- After code changes in this repo, run:
```bash
PYTHONPATH=src .venv/bin/python -m compileall -q src tests
PYTHONPATH=src .venv/bin/python -m unittest discover -s tests
```
- For skill-only edits, also run `quick_validate.py` for each edited skill:
```bash
python /home/pleb/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/<skill-name>
```
- Plain `python -m unittest` can fail in this source-layout repo without
`PYTHONPATH=src`.
## Final Reporting
Report the commands actually run and their results. When a task changes repo
files, include a concise commit message suggestion unless the user already asked
for a commit.
@@ -0,0 +1,53 @@
# plugin-helper State And Profiles
Use these rules when selecting BSManager instances, state directories, and source
checkout locations.
## Instance Selection
- Prefer the Beat Saber instance the user names.
- If the user omits an instance, use the latest available BSInstance unless the
current task context or user notes clearly point at another instance.
- Discover available instances with the helper rather than assuming a hard-coded
game version:
```bash
PYTHONPATH=src .venv/bin/python -m plugin_helper instances
```
## State Directories
- Prefer repo-local `.state` for planned installs, update audits, downloaded
artifacts, and generated plans unless the user explicitly targets live default
state or another configured profile.
- For mounted Windows installs, prefer the shared Windows-partition state
directory configured in `plugin-helper.local.toml` when both roots contain the
same instance name.
- Keep target-specific managed state with the selected target root. Do not mix a
Linux install's `installed.json` with a Windows install's state unless the user
intentionally selected that shared state.
## Profiles
- Read `plugin-helper.local.toml` before choosing a configured profile:
```bash
sed -n '1,220p' plugin-helper.local.toml
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> instances
```
- Treat the selected profile's `instances_root` and `state_dir` as a pair.
- When using `--profile`, prefer it over manually passing default BSManager paths.
## Source Checkouts
- Keep plugin source checkouts under `~/src/<owner>/<repo>` when a locked or
registry plugin has a GitHub source repo.
- Prefer checking out upstream `owner/repo` first, with `origin` pointing at
upstream.
- If a personal fork checkout already exists, preserve it as a remote named
`github` and set or add `origin` to the upstream repo instead of replacing
local work.
- For temporary PR/build work tied to a selected profile, use that profile's
`<state_dir>/build/<name>` when the skill explicitly calls for disposable
build checkouts.
+26 -1
View File
@@ -163,9 +163,34 @@ the configured state directory for one command.
Install assets are currently expected to already exist locally, usually under:
```text
<state-dir>/instances/<instance>/downloads/<plugin-id>/
<state-dir>/cache/downloads/<instance>/<plugin-id>/
```
Lock entries may include `download_url`, the exact public zip or DLL URL for the
locked artifact. The URL is currently audit/recovery metadata; `plan`, `check`,
and `apply` still use local assets and do not fetch missing downloads
automatically.
Composite local assets should have an explicit reconstruction recipe checked in
with the helper. For example, Cinema's external Windows downloader tools are a
small deterministic bundle rebuilt from upstream executables with:
```sh
PYTHONPATH=src python -m plugin_helper.recipes.cinema_tools --instance 1.44.1
```
Use `updates` to audit the version lock against obvious public update sources:
```sh
PYTHONPATH=src python -m plugin_helper --state-dir .state updates --instance 1.44.1
```
The command checks GitHub release assets for ordinary repository-backed locks
and BeatMods verified metadata for `beatmods-*` locks. Patreon and Discord
paid/private sources are skipped, and local builds, PR builds, failed
compatibility trials, and manual installs are marked for review instead of being
treated as routine updates.
## Beat Saber Data Backups
`backup-userdata` copies the mounted Windows `UserData` folder and Beat Saber
+7
View File
@@ -162,6 +162,7 @@ id = "songcore"
repo = "Kylemc1413/SongCore"
tag = "v4.3.0"
asset = "SongCore-4.3.0.zip"
download_url = "https://github.com/Kylemc1413/SongCore/releases/download/v4.3.0/SongCore-4.3.0.zip"
sha256 = "..."
reason = "Pinned until dependent mods support newer SongCore"
@@ -170,9 +171,15 @@ id = "some-ui-mod"
repo = "example/some-ui-mod"
tag = "v1.2.1"
asset = "SomeUIMod.dll"
download_url = "https://github.com/example/some-ui-mod/releases/download/v1.2.1/SomeUIMod.dll"
sha256 = "..."
```
`download_url` is the optional concrete URL for the locked artifact. It should
point at the exact zip or DLL when the artifact is publicly downloadable, and
should be omitted for local builds, private/commercial packages, and packages
that must be reconstructed from source or a recipe.
The lockfile should be the source of truth for reproducible installs. `check` may propose newer versions, but `apply` should install what is present in a plan generated from the lockfile or from an explicit update command.
## Install State
@@ -254,6 +254,7 @@ Purpose: add visual and stream-facing mods after functional mods are stable.
| PitchBlack | [github](https://github.com/Loloppe/BeatSaber_PitchBlack), [beatmods zip](https://beatmods.com/cdn/mod/65656bf33b2a0b356c381132387ea7ea.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `Loloppe/BeatSaber_PitchBlack` tag `0.03`, asset `PitchBlack-v0.0.3-bs1.39.1.zip`; BeatMods version id 2233, zipHash `65656bf33b2a0b356c381132387ea7ea`; GitHub asset is byte-identical to the BeatMods CDN zip | IPA loaded PitchBlack 0.0.3, generated config, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.39.1. In-song lighting behavior not exercised in FPFC smoke. |
| Dimmer | | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
| ReeCamera | [github](https://github.com/Reezonate/ReeCamera) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `Reezonate/ReeCamera` tag `v0.0.5`, asset `ReeCamera.1.42.0.zip`; GitHub release digest matched downloaded asset | IPA loaded ReeCamera 0.0.5, logged Spout load success, installed app/menu installers, and the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.42.0; first launch logged missing `UserData/ReeCamera.json` until the mod creates it on exit per upstream docs. Archive also replaced bundled `Plugins/CameraUtils.dll`. Camera presets not exercised in FPFC smoke. |
| Cinema | [github](https://github.com/MisterKelley/BeatSaberCinema/tree/bs-1.42.3) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Local build from `MisterKelley/BeatSaberCinema` branch `bs-1.42.3` commit `010ce6e01728af1e21774ccbb8d14405773f5b02`, asset `BeatSaberCinema-1.7.13-bs1.42.3-010ce6e.zip`, SHA-256 `cd128597b0b0aab2b152eda16849677dd0b52b6f0aebfdaae3ce0c07809d83d7`; source checkout has a local Linux build fix for `BGLib.AppFlow.dll` hint-path casing. | Helper installed `Plugins/BeatSaberCinema.dll` SHA-256 `1dfa5d885ddd1713f8161a266062b413743198961adae87ead45c8ace066ef61` and enabled locked BetterSongList 0.4.3. IPA loaded Cinema 1.7.13 and BetterSongList 0.4.3; Cinema logged hardware info, Steam initialized, and startup reached `MainSystemInit` plus menu installers. Warnings: manifest targets Beat Saber 1.42.3; Cinema failed to register its BetterSongList filter (`Object of type 'BeatSaberCinema.HasVideoFilter' cannot be converted to type 'T'`); Cinema reported missing `yt-dlp`/`ffmpeg` libraries under `Libs`, so video downloading will not work until those are added. |
### Batch 8: Paid or Closed-Source Plugins
+40
View File
@@ -0,0 +1,40 @@
**Findings**
- Resolved: install state is now keyed by concrete `install_id`, and reusable plugin assets live under the shared download cache instead of per-install state.
- Medium-high: dependency handling is intentionally thin. `Dependency.constraint` exists in [models.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/models.py:10), but [planner.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/planner.py:68) only expands required dependency IDs and does not enforce constraints, conflicts, `loadAfter`, `loadBefore`, BeatMods version-id closures, or “provides” relationships. That is the next big unlock for package-manager behavior.
- Medium: the agent skills have a few stale or conflicting operational rules. The manager skill still tells agents to inspect/edit `registry/plugins.toml` in [.agents/skills/beatsaber-plugin-manager/SKILL.md](/home/pleb/ops/beatsaber/plugin-helper/.agents/skills/beatsaber-plugin-manager/SKILL.md:54), while the project has moved to `registry/plugins/*.toml`. The builder skill says PR checkouts go under `<state_dir>/build` in [.agents/skills/beatsaber-plugin-builder/SKILL.md](/home/pleb/ops/beatsaber/plugin-helper/.agents/skills/beatsaber-plugin-builder/SKILL.md:24), while `AGENTS.md` says GitHub plugin source checkouts should live under `~/src/<owner>/<repo>`. That will make agents inconsistent.
**Skills**
I would not collapse the three skills into one. They are conceptually different:
- `beatsaber-plugin-manager`: mutating install/update/bootstrap workflow.
- `beatsaber-plugin-update-auditor`: read-mostly audit/report workflow.
- `beatsaber-plugin-builder`: source build workflow.
But I would consolidate shared policy into references, then have each skill import that mental model:
- `references/repo-workflow.md`: repo root, `.venv`, `PYTHONPATH=src`, dirty worktree rules, validation commands.
- `references/state-and-profiles.md`: `.state`, profiles, install identity, source checkout location.
- `references/artifact-policy.md`: GitHub first, BeatMods metadata/fallback, private sources, checksum policy.
- `references/live-validation.md`: smoketest and process cleanup.
That removes duplicated drift while preserving good trigger boundaries.
**Design Direction**
Yes, the script design is useful. The best parts are exactly the right bones for a real package manager: registry, locks, dry-run plans, hash checks, managed install state, bootstrap as its own phase, update audit, and known-good sets.
The next shape Id aim for is:
- `packages/registry`: identity, source aliases, install strategy, metadata extraction rules.
- `versions/locks`: selected package versions/artifacts/evidence for each Beat Saber version.
- `cache/downloads`: content-addressed artifacts reusable across installs.
- `installs/<install_id>`: installed packages, transactions, bootstrap, known-good generations, backups.
- commands like `resolve`, `fetch`, `plan`, `apply`, `rollback`, `audit`, `verify`.
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.
+10 -10
View File
@@ -1,4 +1,4 @@
# plugin-helper Roadmap
# Roadmap 1
This roadmap tracks ideas that are useful but not part of the first safe CLI slice.
@@ -46,15 +46,15 @@ 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.
The version lock includes an optional `download_url` for publicly downloadable
artifacts. That makes the fetch path explicit for both GitHub release assets and
BeatMods CDN fallbacks, while local builds and private/commercial packages can
leave it unset. A future helper command can use this field to fetch missing
downloads itself. 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
+9
View File
@@ -0,0 +1,9 @@
# Roadmap 2
## Package Manager Features
From a review:
- High: `apply_plan` is not transactional. It backs up and writes files one by one, then saves state at the end in [installer.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/installer.py:39). If extraction/copy fails mid-plan, the game tree can be half-mutated without matching install state. A future package manager wants transactions/generations: stage, apply, record transaction, and have a first-class rollback command.
- Medium-high: installed state records files but not the installed package identity. [installer.py](/home/pleb/ops/beatsaber/plugin-helper/src/plugin_helper/installer.py:63) stores `path`, `sha256`, and `size`, but not repo/tag/asset/source URL/source hash/reason. The report then uses the current lockfile to describe installed versions, which can drift after a lock update. Record the lock identity into install state at apply time.
+58
View File
@@ -6,6 +6,7 @@ id = "bsipa"
repo = "nike4613/BeatSaber-IPA-Reloaded"
tag = "4.3.7"
asset = "BSIPA-net472-x64.zip"
download_url = "https://github.com/nike4613/BeatSaber-IPA-Reloaded/releases/download/4.3.7/BSIPA-net472-x64.zip"
sha256 = "899b8a1dda91935bd5c19a211fa48e44e32f7be9ab82b6fc796709c753b7b2bc"
install_strategy = "root-zip"
reason = "BeatMods verified BSIPA 4.3.7 for Beat Saber 1.44.1 as version id 2561, zipHash 947774ef1010ff809ae05e345e269a90. GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap."
@@ -15,6 +16,7 @@ id = "beatsabermarkuplanguage"
repo = "monkeymanboy/BeatSaberMarkupLanguage"
tag = "v1.14.1"
asset = "BeatSaberMarkupLanguage-v1.14.1+bs.1.41.1-RELEASE.zip"
download_url = "https://github.com/monkeymanboy/BeatSaberMarkupLanguage/releases/download/v1.14.1/BeatSaberMarkupLanguage-v1.14.1%2Bbs.1.41.1-RELEASE.zip"
sha256 = "816613459853955e2b7c02123ed42f8e5bdbd946cc774fd5fcc4e80a6a09120a"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaberMarkupLanguage 1.14.1 for Beat Saber 1.44.1 as version id 2567, zipHash 46149d03f8549e07f2c88fefde4337b2. GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap."
@@ -24,6 +26,7 @@ id = "sirautil"
repo = "Auros/SiraUtil"
tag = "v3.3.1"
asset = "SiraUtil-v3.3.1+bs.1.42.0.zip"
download_url = "https://github.com/Auros/SiraUtil/releases/download/v3.3.1/SiraUtil-v3.3.1%2Bbs.1.42.0.zip"
sha256 = "2253e5c31324e1e01b23480acba5ceee6b67eb8f16a079f58bc2f6ce7b12051f"
install_strategy = "bsipa-zip"
reason = "BeatMods verified SiraUtil 3.3.1 for Beat Saber 1.44.1 as version id 2565, zipHash ae14f7d3192a919d5d996c802fbde037. GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap."
@@ -33,6 +36,7 @@ id = "songcore"
repo = "Kylemc1413/SongCore"
tag = "beatmods-3.16.0"
asset = "SongCore-3.16.0.zip"
download_url = "https://beatmods.com/cdn/mod/0af9c0a03074c17ca15c1b667a0e30c8.zip"
sha256 = "f9be5d66426d795c6c8af2a4da0150a298d2d6a65fbabcab316d379088a40019"
install_strategy = "bsipa-zip"
reason = "BeatMods verified SongCore 3.16.0 for Beat Saber 1.44.1 as version id 2564, zipHash 0af9c0a03074c17ca15c1b667a0e30c8. BeatMods points to Kylemc1413/SongCore, but that GitHub repo currently exposes releases only through 3.10.4, so this remains a BeatMods CDN fallback until a matching upstream release artifact is found."
@@ -42,6 +46,7 @@ id = "customjsondata"
repo = "Aeroluna/CustomJSONData"
tag = "v2.6.8"
asset = "CustomJSONData-2.6.8+1.40.0-bs1.40.0-7c2c32c.zip"
download_url = "https://github.com/Aeroluna/CustomJSONData/releases/download/v2.6.8/CustomJSONData-2.6.8%2B1.40.0-bs1.40.0-7c2c32c.zip"
sha256 = "30555c77485d2837bd294608fe4e23c34415b89413997be67a6150318090b216"
install_strategy = "bsipa-zip"
reason = "BeatMods verified CustomJSONData 2.6.8+1.40.0 for Beat Saber 1.44.1 as version id 2327, zipHash fed31638bbb678580ef760ec83cfd486. GitHub asset is byte-identical to the BeatMods CDN zip."
@@ -51,6 +56,7 @@ id = "heck"
repo = "Aeroluna/Heck"
tag = "v2026-05-02"
asset = "Heck-1.8.3+1.42.1-bs1.42.1-3ebc6a2.zip"
download_url = "https://github.com/Aeroluna/Heck/releases/download/v2026-05-02/Heck-1.8.3%2B1.42.1-bs1.42.1-3ebc6a2.zip"
sha256 = "d6c6a2b7e54285c8e7ee0515546f8cec274033afaa11324164803d003d9afab4"
install_strategy = "bsipa-zip"
reason = "No BeatMods verified 1.44.1 entry was returned on 2026-06-28. Use latest upstream GitHub Heck asset targeting the highest available Beat Saber version, bs1.42.1; GitHub release digest matched the downloaded asset."
@@ -60,6 +66,7 @@ id = "chroma"
repo = "Aeroluna/Heck"
tag = "v2026-02-23"
asset = "Chroma-2.9.22+1.42.1-bs1.42.1-b38d924.zip"
download_url = "https://github.com/Aeroluna/Heck/releases/download/v2026-02-23/Chroma-2.9.22%2B1.42.1-bs1.42.1-b38d924.zip"
sha256 = "5438d039336ec91573818b7dbd8d5fce30454d16a66f3e70d6eedf5bff6c32bf"
install_strategy = "bsipa-zip"
reason = "No BeatMods verified 1.44.1 entry was returned on 2026-06-28. Use latest upstream GitHub Chroma asset targeting the highest available Beat Saber version, bs1.42.1; GitHub release digest matched the downloaded asset."
@@ -69,6 +76,7 @@ id = "lookupid"
repo = "Aeroluna/Heck"
tag = "chroma-v2.5.10"
asset = "LookupID-1.0.1.zip"
download_url = "https://github.com/Aeroluna/Heck/releases/download/chroma-v2.5.10/LookupID-1.0.1.zip"
sha256 = "5fc838c9bf3693a8e3728056d95c07c5b0e13f944c1f9f49607f701b58e3d05d"
install_strategy = "bsipa-zip"
reason = "Required by Chroma. BeatMods verified LookupID 1.0.1 for Beat Saber 1.44.1 as version id 1527, zipHash 5f5655b91193602c0311b8b6d1b4c71c. GitHub exposes the same version as an upstream release asset."
@@ -78,6 +86,7 @@ id = "noodleextensions"
repo = "Aeroluna/Heck"
tag = "v2026-05-09"
asset = "NoodleExtensions-1.7.21+1.42.1-bs1.42.1-3bbcaf6.zip"
download_url = "https://github.com/Aeroluna/Heck/releases/download/v2026-05-09/NoodleExtensions-1.7.21%2B1.42.1-bs1.42.1-3bbcaf6.zip"
sha256 = "116d5a56c52faf652cecee2dd0b9a02c1798d893fb5adef4145a1fa555ef727f"
install_strategy = "bsipa-zip"
reason = "No BeatMods verified 1.44.1 entry was returned on 2026-06-28. Use latest upstream GitHub Noodle Extensions asset targeting the highest available Beat Saber version, bs1.42.1; GitHub release digest matched the downloaded asset."
@@ -87,6 +96,7 @@ id = "camerautils"
repo = "Reezonate/CameraUtils"
tag = "beatmods-1.0.8"
asset = "CameraUtils-1.0.8.zip"
download_url = "https://beatmods.com/cdn/mod/38507e3a9ff8486c75b002cc47226f43.zip"
sha256 = "7066ec311dc599e1d654d70557abc6c0809b72b5f4d695c8cfb85bb0bf2e2366"
install_strategy = "bsipa-zip"
reason = "Required by Vivify. BeatMods verified CameraUtils 1.0.8 for Beat Saber 1.44.1 as version id 2576, zipHash 38507e3a9ff8486c75b002cc47226f43. Upstream GitHub latest release is 1.0.7, so use the newer BeatMods CDN artifact."
@@ -96,6 +106,7 @@ id = "assetbundleloadingtools"
repo = "nicoco007/AssetBundleLoadingTools"
tag = "v1.1.13"
asset = "AssetBundleLoadingTools-v1.1.13+bs.1.41.1.zip"
download_url = "https://github.com/nicoco007/AssetBundleLoadingTools/releases/download/v1.1.13/AssetBundleLoadingTools-v1.1.13%2Bbs.1.41.1.zip"
sha256 = "356b1aa4722ecca6f13199ad378429ece9873c365d9cf013e095fd5c0757a127"
install_strategy = "root-zip"
reason = "Required by Vivify. BeatMods verified AssetBundleLoadingTools 1.1.13 for Beat Saber 1.44.1 as version id 2590, zipHash a381a964cb69d26be8348edf04fabbc7. GitHub asset is the same version and its release digest matched the downloaded asset."
@@ -105,6 +116,7 @@ id = "vivify"
repo = "Aeroluna/Vivify"
tag = "v1.1.0"
asset = "Vivify-1.1.0+1.42.1-bs1.42.1-f83aa3c.zip"
download_url = "https://github.com/Aeroluna/Vivify/releases/download/v1.1.0/Vivify-1.1.0%2B1.42.1-bs1.42.1-f83aa3c.zip"
sha256 = "0e8799a7243496925aa527bc4ca7d3bcab770f4e828bbceeed066539f90a0902"
install_strategy = "bsipa-zip"
reason = "No BeatMods verified 1.44.1 entry was returned on 2026-06-28. Use latest upstream GitHub Vivify asset targeting the highest available Beat Saber version, bs1.42.1; GitHub release digest matched the downloaded asset."
@@ -114,6 +126,7 @@ id = "iniparser"
repo = "rickyah/ini-parser"
tag = "beatmods-2.5.9"
asset = "IniParser-2.5.9.zip"
download_url = "https://beatmods.com/cdn/mod/5df74ad1c6b120fecdc615dd55f15b88.zip"
sha256 = "b761293bea73ff3cb9998f404594aade617638aba4318203320189baf3449ff1"
install_strategy = "bsipa-zip"
reason = "BeatMods verified Ini Parser 2.5.9 for Beat Saber 1.44.1 as version id 1352, zipHash 5df74ad1c6b120fecdc615dd55f15b88. Use BeatMods CDN as a framework/library dependency payload."
@@ -123,6 +136,7 @@ id = "bs-utils"
repo = "Kylemc1413/Beat-Saber-Utils"
tag = "beatmods-1.14.3"
asset = "BSUtils-1.14.3.zip"
download_url = "https://beatmods.com/cdn/mod/918d13ac2821a3a17b2819f8861453e9.zip"
sha256 = "b450fa4561da5b5ad834a3dcf6f127c2d73e5f971aa6c986514a6b185d68edfe"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BS Utils 1.14.3 for Beat Saber 1.44.1 as version id 2563, zipHash 918d13ac2821a3a17b2819f8861453e9. Upstream GitHub releases do not expose a matching 1.14.3 asset, so this remains a BeatMods CDN fallback."
@@ -131,6 +145,7 @@ reason = "BeatMods verified BS Utils 1.14.3 for Beat Saber 1.44.1 as version id
id = "system-io-compression"
tag = "beatmods-4.6.57"
asset = "System.IO.Compression-4.6.57.zip"
download_url = "https://beatmods.com/cdn/mod/a4e9e26f61967e56168e08eecb01ab88.zip"
sha256 = "9067ddaf52c077f38a27ace6180a8134ceaaf1ecba752d08de81b4cc0c13aa43"
install_strategy = "bsipa-zip"
reason = "BeatMods verified System.IO.Compression 4.6.57 for Beat Saber 1.44.1 as version id 1763, zipHash a4e9e26f61967e56168e08eecb01ab88. Use BeatMods CDN as a framework/library dependency payload."
@@ -139,6 +154,7 @@ reason = "BeatMods verified System.IO.Compression 4.6.57 for Beat Saber 1.44.1 a
id = "system-io-compression-filesystem"
tag = "beatmods-4.7.3056"
asset = "System.IO.Compression.FileSystem-4.7.3056.zip"
download_url = "https://beatmods.com/cdn/mod/e19f6fd395d54de7bfcbbbe3084dea28.zip"
sha256 = "1b6f7a33a69980344717a37240ed80b022903a52968d4968f307cf754e3a03f7"
install_strategy = "bsipa-zip"
reason = "BeatMods verified System.IO.Compression.FileSystem 4.7.3056 for Beat Saber 1.44.1 as version id 1762, zipHash e19f6fd395d54de7bfcbbbe3084dea28. Use BeatMods CDN as a framework/library dependency payload."
@@ -148,6 +164,7 @@ id = "imagesharp"
repo = "SixLabors/ImageSharp"
tag = "beatmods-2.0.0"
asset = "ImageSharp-2.0.0.zip"
download_url = "https://beatmods.com/cdn/mod/b642fec88b0f84a0643ebd401d08da35.zip"
sha256 = "b2bf6d195f25e1298199a389e381ab42f163b284105ae6181259d74f2528301b"
install_strategy = "bsipa-zip"
reason = "BeatMods verified ImageSharp 2.0.0 for Beat Saber 1.44.1 as version id 1428, zipHash b642fec88b0f84a0643ebd401d08da35. Use BeatMods CDN as a framework/library dependency payload."
@@ -157,6 +174,7 @@ id = "beatsaberplaylistslib"
repo = "Meivyn/BeatSaberPlaylistsLib"
tag = "beatmods-1.7.2"
asset = "BeatSaberPlaylistsLib-1.7.2.zip"
download_url = "https://beatmods.com/cdn/mod/a3418b75ed7294a3856f3eca12bbd672.zip"
sha256 = "dfb26cf90cb73834c405727418c0121d8546cad0403abd35a09437dab766bd32"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaberPlaylistsLib 1.7.2 for Beat Saber 1.44.1 as version id 2175, zipHash a3418b75ed7294a3856f3eca12bbd672. Upstream GitHub exposes no release assets through the releases API, so this remains a BeatMods CDN fallback."
@@ -166,6 +184,7 @@ id = "beatsaversharp"
repo = "Auros/BeatSaverSharper"
tag = "beatmods-3.4.5"
asset = "BeatSaverSharp-3.4.5.zip"
download_url = "https://beatmods.com/cdn/mod/be37e13e93d9ac7da4efbdc3f514fa8f.zip"
sha256 = "f5f37b27438e9b2fa1d9fbdf51a4f015f44ae04979cbdd9b90f6ae18583a6911"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaverSharp 3.4.5 for Beat Saber 1.44.1 as version id 1831, zipHash be37e13e93d9ac7da4efbdc3f514fa8f. Source repository is now Auros/BeatSaverSharper; this remains a BeatMods CDN fallback for the locked 3.4.5 package."
@@ -174,6 +193,7 @@ reason = "BeatMods verified BeatSaverSharp 3.4.5 for Beat Saber 1.44.1 as versio
id = "scoresabersharp"
tag = "beatmods-0.1.0"
asset = "ScoreSaberSharp-0.1.0.zip"
download_url = "https://beatmods.com/cdn/mod/8713168c598577ee7c73fa3cf0e26f5c.zip"
sha256 = "7f30be996f8f0e997f2d848e34de1d03ef6dc744ffc32d3fe505881ca22c6cd3"
install_strategy = "bsipa-zip"
reason = "BeatMods verified ScoreSaberSharp 0.1.0 for Beat Saber 1.44.1 as version id 445, zipHash 8713168c598577ee7c73fa3cf0e26f5c. BeatMods lists scoresaber.com rather than a GitHub release source, so this remains a BeatMods CDN fallback."
@@ -183,6 +203,7 @@ id = "scoresaber"
repo = "ScoreSaber/pc-mod"
tag = "v3.3.27"
asset = "ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip"
download_url = "https://github.com/ScoreSaber/pc-mod/releases/download/v3.3.27/ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip"
sha256 = "8ad509ab38353dfb4d92d127ba4e40917552f700580a7384509cafa70e62c096"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub releases URL https://github.com/ScoreSaber/pc-mod/releases. Latest non-draft, non-prerelease release v3.3.27 includes this highest compatible asset, labeled for Beat Saber 1.42.0 through 1.44.0; GitHub release digest matched the downloaded asset."
@@ -192,6 +213,7 @@ id = "protobuf-net"
repo = "protobuf-net/protobuf-net"
tag = "beatmods-3.0.102"
asset = "protobuf-net-3.0.102.zip"
download_url = "https://beatmods.com/cdn/mod/1f55ae4b80b747b5f03fa18337ead864.zip"
sha256 = "8bf4a361038172eab2c0c2e6737773d6c9a47fc6504a97304a35f697d5166414"
install_strategy = "bsipa-zip"
reason = "Required by SongDetailsCache. BeatMods verified protobuf-net 3.0.102 for Beat Saber 1.44.1 as version id 958, zipHash 1f55ae4b80b747b5f03fa18337ead864. Use BeatMods CDN as a framework/library dependency payload."
@@ -201,6 +223,7 @@ id = "songdetailscache"
repo = "kinsi55/BeatSaber_SongDetails"
tag = "v1.4.0"
asset = "SongDetailsCache.BS.Lib.zip"
download_url = "https://github.com/kinsi55/BeatSaber_SongDetails/releases/download/v1.4.0/SongDetailsCache.BS.Lib.zip"
sha256 = "6ccc816dd40752b46d5e7e1cfb41e5af9d915d1a032be51ce1e52fb154daa28c"
install_strategy = "bsipa-zip"
reason = "Required by SongRankedBadge. BeatMods verified SongDetailsCache 1.4.0 for Beat Saber 1.44.1 as version id 2226, zipHash e1167b64cd3eff7e3651ec2dbbe50d81. GitHub asset is the same version selected from kinsi55/BeatSaber_SongDetails v1.4.0."
@@ -210,6 +233,7 @@ id = "songrankedbadge"
repo = "qe201020335/SongRankedBadge"
tag = "v1.0.6"
asset = "SongRankedBadge-1.0.6-bs1.40.0-88ee233.zip"
download_url = "https://github.com/qe201020335/SongRankedBadge/releases/download/v1.0.6/SongRankedBadge-1.0.6-bs1.40.0-88ee233.zip"
sha256 = "6028f2392dd5e228f477837a7e32d95f0ca8af4f1b697b17c842ea62c050edff"
install_strategy = "bsipa-zip"
reason = "BeatMods verified SongRankedBadge 1.0.6 for Beat Saber 1.44.1 as version id 2267, zipHash c6944b8a4b00b0c0bb1d44f273b3bb18. GitHub release asset v1.0.6 was used."
@@ -219,6 +243,7 @@ id = "beatleader"
repo = "BeatLeader/beatleader-mod"
tag = "v0.10.0"
asset = "BeatLeader-0.10.0-bs1.42+.zip"
download_url = "https://github.com/BeatLeader/beatleader-mod/releases/download/v0.10.0/BeatLeader-0.10.0-bs1.42%2B.zip"
sha256 = "0b7c2adcf4db9806cdc765164788a9394bca2ee12179fad1b8ed255da1b7104e"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub releases URL https://github.com/BeatLeader/beatleader-mod/releases. Latest non-draft, non-prerelease release v0.10.0 includes this highest compatible asset, labeled for Beat Saber 1.42+; GitHub release digest matched the downloaded asset. The archive bundles Plugins/LeaderboardCore.dll, so the standalone LeaderboardCore lock entry is intentionally omitted."
@@ -228,6 +253,7 @@ id = "beatsaverdownloader"
repo = "Top-Cat/BeatSaverDownloader"
tag = "beatmods-6.0.7"
asset = "BeatSaverDownloader-6.0.7.zip"
download_url = "https://beatmods.com/cdn/mod/a740c6e68a9b5d1dfda3cc8e81f7cf06.zip"
sha256 = "4108b11eae11d09f8c4e838dde1dc36108f1f3fd4fff31b951e7c551fa59f5f1"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaverDownloader 6.0.7 for Beat Saber 1.44.1 as version id 2217, zipHash a740c6e68a9b5d1dfda3cc8e81f7cf06. Upstream GitHub exposes no release assets through the releases API, so this remains a BeatMods CDN fallback."
@@ -237,6 +263,7 @@ id = "beatsaverupdater"
repo = "ibillingsley/BeatSaverUpdater"
tag = "1.2.11"
asset = "BeatSaverUpdater-1.2.11-bs1.39.1-3698f98.zip"
download_url = "https://github.com/ibillingsley/BeatSaverUpdater/releases/download/1.2.11/BeatSaverUpdater-1.2.11-bs1.39.1-3698f98.zip"
sha256 = "6237da05cbc044e99211cb0e569c917fe61bb0da25e42c61a6d438371548ebba"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaverUpdater 1.2.11 for Beat Saber 1.44.1 as version id 2352, zipHash d9ea8dd0cbaac66cbb02fa59a548e42b. GitHub asset is byte-identical to the BeatMods CDN zip."
@@ -246,6 +273,7 @@ id = "failbutton"
repo = "qe201020335/FailButton"
tag = "v0.0.4"
asset = "FailButton-0.0.4-bs1.39.0-b6415fb.zip"
download_url = "https://github.com/qe201020335/FailButton/releases/download/v0.0.4/FailButton-0.0.4-bs1.39.0-b6415fb.zip"
sha256 = "bb163170f400191592af1689e7b7557ec82e45e36de36e6eaaae16b4273e595b"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub repository URL https://github.com/qe201020335/FailButton. Latest non-draft, non-prerelease release v0.0.4 exposes this asset; no newer 1.44.x-specific asset was available."
@@ -255,15 +283,34 @@ id = "easyoffset"
repo = "Reezonate/EasyOffset"
tag = "v2.1.16"
asset = "EasyOffset.dll"
download_url = "https://github.com/Reezonate/EasyOffset/releases/download/v2.1.16/EasyOffset.dll"
sha256 = "6b9c63e5a4ae0ebc103fc11f442917b8fae12d62369faed1aca7470899a2bbbf"
install_strategy = "dll-to-plugins"
reason = "User-provided GitHub repository URL https://github.com/Reezonate/EasyOffset. Latest non-draft, non-prerelease release v2.1.16 exposes this direct DLL asset; GitHub release digest matched the downloaded asset."
[[plugins]]
id = "cinema"
repo = "MisterKelley/BeatSaberCinema"
tag = "localbuild-010ce6e-bs-1.42.3-bsl-filter"
asset = "BeatSaberCinema-1.7.13-bs1.42.3-010ce6e.zip"
sha256 = "2294f3a4e082626ae4508f821469e8f5156c827bf37efdf3327fa6e12039d5e0"
install_strategy = "bsipa-zip"
reason = "User-provided fork/branch URL https://github.com/MisterKelley/BeatSaberCinema/tree/bs-1.42.3. Built locally from commit 010ce6e01728af1e21774ccbb8d14405773f5b02 against the 1.44.1 instance after fixing the BGLib.AppFlow hint-path casing for Linux and compiling HasVideoFilter against BetterSongList 0.4.3's real ITransformerPlugin/IFilter interfaces; manifest still targets Beat Saber 1.42.3."
[[plugins]]
id = "cinema-tools"
tag = "yt-dlp-2026.07.04-ffmpeg-8.1.2"
asset = "CinemaTools-yt-dlp-2026.07.04-ffmpeg-8.1.2.zip"
sha256 = "4d299e40f747abb9777838542ca68ec450e0b86e6eb11af341e6bd568f47edf2"
install_strategy = "root-zip"
reason = "Helper-managed Windows executable bundle for Cinema's hardcoded Proton paths Libs/yt-dlp.exe and Libs/ffmpeg.exe. Rebuild with PYTHONPATH=src python -m plugin_helper.recipes.cinema_tools --instance 1.44.1. Sources: https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp.exe with sha256 52fe3c26dcf71fbdc85b528589020bb0b8e383155cfa81b64dd447bbe35e24b8, and https://github.com/GyanD/codexffmpeg/releases/download/8.1.2/ffmpeg-8.1.2-essentials_build.zip with sha256 db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec; the recipe extracts */bin/ffmpeg.exe with sha256 1326dde4c84ff1f96fe6b8916c5bed29e163e9b5dccf995f6f3db069d143ec5e and writes a deterministic root zip."
[[plugins]]
id = "gottagofast"
repo = "kinsi55/CS_BeatSaber_GottaGoFast"
tag = "v0.2.5"
asset = "GottaGoFast.dll"
download_url = "https://github.com/kinsi55/CS_BeatSaber_GottaGoFast/releases/download/v0.2.5/GottaGoFast.dll"
sha256 = "c9cd0e29dd7540027cdd3b9138335d81f93d69daeb8460d2fdfef31199e0e44e"
install_strategy = "dll-to-plugins"
reason = "User-provided GitHub repository URL https://github.com/kinsi55/CS_BeatSaber_GottaGoFast. Latest non-draft, non-prerelease release v0.2.5 is labeled for Beat Saber 1.42.0+ and exposes this direct DLL asset; GitHub release digest matched the downloaded asset."
@@ -273,6 +320,7 @@ id = "keepmyoverridespls"
repo = "qqrz997/KeepMyOverridesPls"
tag = "v1.1.3-b"
asset = "KeepMyOverridesPls-1.1.3-bs1.40.6-487d417.zip"
download_url = "https://github.com/qqrz997/KeepMyOverridesPls/releases/download/v1.1.3-b/KeepMyOverridesPls-1.1.3-bs1.40.6-487d417.zip"
sha256 = "4c2da7349778eba98eb02ba518a1a056a784b719b974055c877aa6ce3e735fbc"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub repository URL https://github.com/qqrz997/KeepMyOverridesPls. Latest non-draft, non-prerelease release v1.1.3-b exposes this asset; GitHub release digest matched the downloaded asset."
@@ -300,6 +348,7 @@ id = "introskip"
repo = "Loloppe/Intro-Skip"
tag = "beatmods-4.0.8"
asset = "IntroSkip-4.0.8.zip"
download_url = "https://beatmods.com/cdn/mod/7d98ae6049251eb4e3226a5c8ac675b3.zip"
sha256 = "319bce38f69661a3b0997071b85d57e5b4295b9699ac5432877b0fed72aa48b9"
install_strategy = "bsipa-zip"
reason = "BeatMods verified IntroSkip 4.0.8 for Beat Saber 1.44.1 as version id 2570, zipHash 7d98ae6049251eb4e3226a5c8ac675b3. Use BeatMods CDN after the older GitHub 4.0.5 asset failed compatibility on this instance."
@@ -309,6 +358,7 @@ id = "soundreplacer"
repo = "Meivyn/SoundReplacer"
tag = "beatmods-2.0.1"
asset = "SoundReplacer-2.0.1.zip"
download_url = "https://beatmods.com/cdn/mod/7d7a869996e10249d1f85f95e060319b.zip"
sha256 = "c14cc99cb0bd4ea4c3ab47345692489cbdfed8b2b577925200b1a8f6f4f93512"
install_strategy = "bsipa-zip"
reason = "BeatMods verified SoundReplacer 2.0.1 for Beat Saber 1.44.1 as version id 2213, zipHash 7d7a869996e10249d1f85f95e060319b. GitHub releases API returned no releases on 2026-06-29, so use the BeatMods CDN artifact."
@@ -318,6 +368,7 @@ id = "bettersonglist"
repo = "kinsi55/BeatSaber_BetterSongList"
tag = "v0.4.3"
asset = "BetterSongList.dll"
download_url = "https://github.com/kinsi55/BeatSaber_BetterSongList/releases/download/v0.4.3/BetterSongList.dll"
sha256 = "50993315f41e5ca8e83ce7ded6be7780cfbe62ce248023849bd898068a4e25c1"
install_strategy = "dll-to-plugins"
reason = "User-provided GitHub repository URL https://github.com/kinsi55/BeatSaber_BetterSongList. Latest non-draft, non-prerelease release v0.4.3 exposes this direct DLL asset; GitHub release digest matched the downloaded asset."
@@ -327,6 +378,7 @@ id = "hitscorevisualizer"
repo = "ErisApps/HitScoreVisualizer"
tag = "3.7.3"
asset = "HitScoreVisualizer-3.7.3-bs1.42.0-a565cbb.zip"
download_url = "https://github.com/ErisApps/HitScoreVisualizer/releases/download/3.7.3/HitScoreVisualizer-3.7.3-bs1.42.0-a565cbb.zip"
sha256 = "b889355cd47cc3b09a352f081110b8130ac6e1d285fa58776466117d585814db"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub repository URL https://github.com/ErisApps/HitScoreVisualizer. Latest non-draft, non-prerelease release 3.7.3 exposes this asset for Beat Saber 1.42.0; GitHub release digest matched the downloaded asset."
@@ -345,6 +397,7 @@ id = "hidethelogo"
repo = "TheBlackParrot/HideTheLogo"
tag = "1.0.3"
asset = "HideTheLogo-1.0.3-bs1.40.3-c968d91.zip"
download_url = "https://github.com/TheBlackParrot/HideTheLogo/releases/download/1.0.3/HideTheLogo-1.0.3-bs1.40.3-c968d91.zip"
sha256 = "f7177cf28add03ddd73fdbd720628755c7787f515523f2b92e0041c63e83d6ca"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub repository URL https://github.com/TheBlackParrot/HideTheLogo. Latest non-draft, non-prerelease release 1.0.3 exposes this asset for Beat Saber 1.40.3; GitHub release did not expose a digest."
@@ -354,6 +407,7 @@ id = "songchartvisualizer"
repo = "NuggoDEV/SongChartVisualizer"
tag = "beatmods-1.1.11"
asset = "SongChartVisualizer-1.1.11.zip"
download_url = "https://beatmods.com/cdn/mod/5d3fc025fe098277667fc0846e1b8fe3.zip"
sha256 = "290a55ff87d8769f3ae7c5d0b44a67e9e6e89e8ef83a2b1bb988ebea28f89da4"
install_strategy = "bsipa-zip"
reason = "BeatMods verified SongChartVisualizer 1.1.11 for Beat Saber 1.44.1 as version id 2249, zipHash 5d3fc025fe098277667fc0846e1b8fe3. User-provided GitHub repository URL https://github.com/NuggoDEV/SongChartVisualizer returned no releases through the GitHub releases API, so use the BeatMods CDN artifact."
@@ -363,6 +417,7 @@ id = "adblocker"
repo = "JonnyVR1/AdBlocker"
tag = "beatmods-1.0.5"
asset = "AdBlocker-1.0.5.zip"
download_url = "https://beatmods.com/cdn/mod/cd397e93b1a03f163534483462edf768.zip"
sha256 = "0ee298563760d8c7f6ba7c8134982f9d1c2e554fe142648a3465051ac6468487"
install_strategy = "bsipa-zip"
reason = "BeatMods verified AdBlocker 1.0.5 for Beat Saber 1.44.1 as version id 1872, zipHash cd397e93b1a03f163534483462edf768. User-provided GitHub repository URL https://github.com/JonnyVR1/AdBlocker returned no releases through the GitHub releases API, so use the BeatMods CDN artifact."
@@ -372,6 +427,7 @@ id = "highlightbombs"
repo = "Meivyn/HighlightBombs"
tag = "beatmods-1.0.3"
asset = "HighlightBombs-1.0.3.zip"
download_url = "https://beatmods.com/cdn/mod/4bedaa80ce5dda8414fea7d914fb94ad.zip"
sha256 = "fba7750632079dbce846ba57c2e3284fc72cb95671bff08394246e6664fe8113"
install_strategy = "bsipa-zip"
reason = "BeatMods verified HighlightBombs 1.0.3 for Beat Saber 1.44.1 as version id 2066, zipHash 4bedaa80ce5dda8414fea7d914fb94ad. GitHub latest release v1.0.1 is older than the BeatMods-verified 1.0.3 package, so use the BeatMods CDN artifact."
@@ -381,6 +437,7 @@ id = "pitchblack"
repo = "Loloppe/BeatSaber_PitchBlack"
tag = "0.03"
asset = "PitchBlack-v0.0.3-bs1.39.1.zip"
download_url = "https://github.com/Loloppe/BeatSaber_PitchBlack/releases/download/0.03/PitchBlack-v0.0.3-bs1.39.1.zip"
sha256 = "ae7269b4f40fea8bee3d2b213a67c3e779ff8bfbcb4f4deabcb9e9c21bfadafb"
install_strategy = "bsipa-zip"
reason = "BeatMods verified PitchBlack 0.0.3 for Beat Saber 1.44.1 as version id 2233, zipHash 65656bf33b2a0b356c381132387ea7ea. User-provided GitHub repository URL https://github.com/Loloppe/BeatSaber_PitchBlack tag 0.03 asset is byte-identical to the BeatMods CDN zip."
@@ -390,6 +447,7 @@ id = "reecamera"
repo = "Reezonate/ReeCamera"
tag = "v0.0.5"
asset = "ReeCamera.1.42.0.zip"
download_url = "https://github.com/Reezonate/ReeCamera/releases/download/v0.0.5/ReeCamera.1.42.0.zip"
sha256 = "76fb8efa1103ed18d7913c63818cb2699785fb3718d394176d5e2a6938842c24"
install_strategy = "root-zip"
reason = "User-provided GitHub repository URL https://github.com/Reezonate/ReeCamera. Latest non-draft, non-prerelease release v0.0.5 offers ReeCamera.1.42.0.zip as the highest Beat Saber version asset for instance 1.44.1; GitHub release digest matched the downloaded asset. Archive bundles Plugins/ReeCamera.dll, bundled CameraUtils.dll, and UserData/ReeCamera presets."
+5
View File
@@ -0,0 +1,5 @@
id = "cinema-tools"
name = "Cinema Tools"
asset_patterns = ["CinemaTools-yt-dlp-*-ffmpeg-*.zip"]
install_strategy = "root-zip"
category = "dependency"
+13
View File
@@ -0,0 +1,13 @@
id = "cinema"
name = "Cinema"
repo = "MisterKelley/BeatSaberCinema"
asset_patterns = ["BeatSaberCinema-*.zip"]
install_strategy = "bsipa-zip"
category = "visual"
dependencies = [
{ id = "beatsabermarkuplanguage" },
{ id = "bs-utils" },
{ id = "songcore" },
{ id = "bettersonglist" },
{ id = "cinema-tools" },
]
+21
View File
@@ -1,7 +1,10 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlencode
from urllib.request import Request, urlopen
@dataclass(frozen=True)
@@ -49,6 +52,24 @@ def normalize_mods(payload: Any) -> list[BeatModsEntry]:
return [normalize_entry(entry) for entry in extract_mods(payload)]
def fetch_verified_mods(game_version: str) -> list[dict[str, Any]]:
query = urlencode(
{
"status": "verified",
"gameVersion": game_version,
"gameName": "BeatSaber",
"platform": "steampc",
}
)
request = Request(
f"https://beatmods.com/api/mods?{query}",
headers={"User-Agent": "Mozilla/5.0 plugin-helper"},
)
with urlopen(request, timeout=20) as response:
data = json.loads(response.read().decode("utf-8"))
return extract_mods(data)
def by_version_id(entries: list[BeatModsEntry]) -> dict[int, BeatModsEntry]:
return {entry.version_id: entry for entry in entries if entry.version_id is not None}
+12 -6
View File
@@ -215,6 +215,7 @@ def run_bootstrap(
native: bool | None = None,
progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
install_id: str | None = None,
) -> dict[str, Any]:
tell = progress or (lambda _message: None)
use_native = is_windows() if native is None else native
@@ -240,6 +241,7 @@ def run_bootstrap(
repo_root=repo_root,
selected={BSIPA_PLUGIN_ID},
require_bootstrap=False,
install_id=install_id,
)
if not plan["changes"]:
raise ValueError("BSIPA bootstrap plan has no changes")
@@ -292,12 +294,14 @@ def run_bootstrap(
"delta": delta,
"health": {},
}
if install_id:
state["installId"] = install_id
if not use_native:
state["proton"] = str(proton or _default_proton())
save_bootstrap_state(state_root, instance, state)
state["health"] = check_bsipa_health(instance_path, state_root, instance)
save_bootstrap_state(state_root, instance, state)
state["statePath"] = str(bootstrap_state_path(state_root, instance))
save_bootstrap_state(state_root, instance, state, install_id=install_id)
state["health"] = check_bsipa_health(instance_path, state_root, instance, install_id=install_id)
save_bootstrap_state(state_root, instance, state, install_id=install_id)
state["statePath"] = str(bootstrap_state_path(state_root, instance, install_id=install_id))
if completed["timedOut"]:
raise TimeoutError(f"IPA.exe -n timed out after {ipa_timeout_seconds}s; state written to {state['statePath']}")
if completed["returncode"] != 0:
@@ -319,11 +323,12 @@ def ensure_healthy_bootstrap(
native: bool | None = None,
progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
install_id: str | None = None,
) -> None:
if not planning_requires_bootstrap(lockfile.plugins, selected_ids):
return
health = check_bsipa_health(instance_path, state_root, instance)
health = check_bsipa_health(instance_path, state_root, instance, install_id=install_id)
if health["ok"]:
return
@@ -341,8 +346,9 @@ def ensure_healthy_bootstrap(
native=native,
progress=progress,
ipa_timeout_seconds=ipa_timeout_seconds,
install_id=install_id,
)
health = check_bsipa_health(instance_path, state_root, instance)
health = check_bsipa_health(instance_path, state_root, instance, install_id=install_id)
if not health["ok"]:
raise ValueError(bootstrap_health_error(health))
+10 -4
View File
@@ -33,8 +33,14 @@ def _native_bootstrap_satisfied(state: dict[str, Any]) -> bool:
)
def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) -> dict[str, Any]:
state = load_bootstrap_state(state_root, instance)
def check_bsipa_health(
instance_path: Path,
state_root: Path,
instance: str,
*,
install_id: str | None = None,
) -> dict[str, Any]:
state = load_bootstrap_state(state_root, instance, install_id=install_id)
messages: list[str] = []
required = ["IPA.exe", "winhttp.dll"]
@@ -57,12 +63,12 @@ def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) ->
messages.append("Logs/_latest.log does not show BSIPA startup")
if not state:
messages.append(f"missing bootstrap state: {bootstrap_state_path(state_root, instance)}")
messages.append(f"missing bootstrap state: {bootstrap_state_path(state_root, instance, install_id=install_id)}")
return {
"ok": not messages,
"messages": messages,
"statePath": str(bootstrap_state_path(state_root, instance)),
"statePath": str(bootstrap_state_path(state_root, instance, install_id=install_id)),
"logPath": str(log_path),
"logSha256": sha256_file(log_path) if log_path.is_file() else None,
"bootstrapRecordedAt": state.get("updatedAt"),
+106 -18
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from typing import Any
from .config import repo_root, resolve_runtime_config
from .beatmods import fetch_verified_mods
from .bootstrap import run_bootstrap
from .bsipa import check_bsipa_health
from .checker import check_lock
@@ -18,7 +19,8 @@ from .operations import enable_disabled_plugin, restore_known_good_set, save_kno
from .planner import create_plan
from .reports import installed_plugins_report, print_installed_plugins
from .scanner import scan_instance
from .state import load_installed_state
from .state import installation_id, load_installed_state
from .update_runner import run_update
from .updates import check_updates
from .userdata import restore_windows_data_repo, sync_windows_data_repo
@@ -33,12 +35,14 @@ def print_updates(report: dict[str, Any]) -> None:
print(
f"{report['instance']} updates: "
f"{summary['updates']} available, {summary['current']} current, "
f"{summary.get('reviews', 0)} review, {summary.get('skipped', 0)} skipped, "
f"{summary['warnings']} warnings, {summary['errors']} errors"
)
if not plugins:
return
headers = ("Plugin", "Current", "Latest", "Asset", "Status")
headers = ("Plugin", "Current", "Latest", "Asset", "Status", "Notes")
rows = [
(
f"{plugin['name']} ({plugin['id']})",
@@ -46,6 +50,7 @@ def print_updates(report: dict[str, Any]) -> None:
plugin.get("latestTag") or "(unknown)",
plugin.get("latestAsset") or plugin.get("currentAsset") or "(unknown)",
plugin["status"],
", ".join(plugin.get("reviewReasons") or plugin.get("messages") or ()),
)
for plugin in plugins
]
@@ -59,6 +64,20 @@ def print_updates(report: dict[str, Any]) -> None:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
def _installation_id_for(runtime: Any, instance_name: str, instance_path: Path) -> str:
return installation_id(
profile_id=getattr(runtime, "profile_id", None),
root=instance_path.parent,
instance=instance_name,
instance_path=instance_path,
)
def _get_installation(runtime: Any, instance_name: str) -> tuple[Any, str]:
instance = get_instance(runtime.instances_roots, instance_name)
return instance, _installation_id_for(runtime, instance_name, instance.path)
def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None:
default = argparse.SUPPRESS if suppress_default else None
parser.add_argument("--config", default=default, help="plugin-helper config TOML path")
@@ -142,7 +161,7 @@ def build_parser() -> argparse.ArgumentParser:
updates = subcommands.add_parser(
"updates",
help="Check GitHub for newer matching releases for locked plugins",
help="Check GitHub and BeatMods for newer matching releases for locked plugins",
parents=[_common_parent()],
)
updates.add_argument("--instance", required=True)
@@ -152,6 +171,21 @@ def build_parser() -> argparse.ArgumentParser:
updates.add_argument("--include-prerelease", action="store_true", help="Include prerelease GitHub releases")
updates.add_argument("--json", action="store_true", help="Print full JSON update output")
update = subcommands.add_parser(
"update",
help="Download selected update candidates, update the lock, and create an install plan",
parents=[_common_parent()],
)
update.add_argument("--instance", required=True)
update.add_argument("--registry", default="registry/plugins")
update.add_argument("--lockfile")
update.add_argument("--plugin", action="append", required=True, help="Update this locked plugin id; repeatable")
update.add_argument("--include-prerelease", action="store_true", help="Include prerelease GitHub releases")
update.add_argument("--json", action="store_true", help="Print full JSON update output")
update_mode = update.add_mutually_exclusive_group()
update_mode.add_argument("--apply", action="store_true", help="Apply the generated update plan")
update_mode.add_argument("--dry-run", action="store_true", help="Show selected updates without downloading or writing")
plan = subcommands.add_parser(
"plan",
help="Create a dry-run install plan from the catalog and version lock",
@@ -258,12 +292,13 @@ def _run_menu(
return 1
choices: list[InstallationChoice] = []
for index, root in enumerate(runtime.instances_roots, start=1):
for root in runtime.instances_roots:
install_label = str(root) if len(runtime.instances_roots) > 1 else "Default"
for instance in list_instances(root):
install_id = _installation_id_for(runtime, instance.name, instance.path)
choices.append(
InstallationChoice(
install_id=f"root-{index}",
install_id=install_id,
install_label=install_label,
instance_name=instance.name,
instance_path=instance.path,
@@ -347,17 +382,19 @@ def run(argv: list[str] | None = None) -> int:
return 0
if args.command == "state":
_json(load_installed_state(st_root, args.instance))
_instance, install_id = _get_installation(runtime, args.instance)
_json(load_installed_state(st_root, args.instance, install_id=install_id))
return 0
if args.command == "installed":
instance, install_id = _get_installation(runtime, args.instance)
root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve()
result = installed_plugins_report(
installed_state=load_installed_state(st_root, args.instance),
installed_state=load_installed_state(st_root, instance.name, install_id=install_id),
registry=load_registry(registry_path),
lockfile=load_lockfile(lock_path),
)
@@ -406,6 +443,7 @@ def run(argv: list[str] | None = None) -> int:
registry=load_registry(registry_path),
lockfile=load_lockfile(lock_path),
fetch_releases=fetch_releases,
fetch_beatmods=fetch_verified_mods,
selected=set(args.plugin) if args.plugin else None,
include_prerelease=args.include_prerelease,
)
@@ -415,8 +453,53 @@ def run(argv: list[str] | None = None) -> int:
print_updates(result)
return 2 if result["summary"]["errors"] else 0
if args.command == "update":
instance, install_id = _get_installation(runtime, args.instance)
root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve()
result = run_update(
instance=args.instance,
instance_path=instance.path,
registry=load_registry(registry_path),
lockfile=load_lockfile(lock_path),
lock_path=lock_path,
state_root=st_root,
repo_root=root,
selected=set(args.plugin),
fetch_releases=fetch_releases,
fetch_beatmods=fetch_verified_mods,
include_prerelease=args.include_prerelease,
dry_run=args.dry_run,
apply=args.apply,
install_id=install_id,
)
if args.json:
_json(result)
else:
action = "Would update" if result["dryRun"] else "Updated"
print(f"{action}: {len(result['updated'])}")
for item in result["updated"]:
print(f" {item['plugin']}: {item.get('fromTag')} -> {item.get('toTag')} ({item.get('asset')})")
if result["refused"]:
print("Refused:")
for item in result["refused"]:
notes = ", ".join(item.get("reviewReasons") or item.get("messages") or ())
suffix = f": {notes}" if notes else ""
print(f" {item['plugin']}: {item['status']}{suffix}")
if result.get("planPath"):
print(f"Plan: {result['planPath']}")
if not result["dryRun"] and result["updated"]:
print(f"Lockfile: {result['lockfile']}")
if args.apply:
print(f"Applied: {len(result['applied'])}")
print(f"State: {result.get('statePath')}")
return 0 if not result["refused"] else 2
if args.command == "bootstrap":
instance = get_instance(inst_roots, args.instance)
instance, install_id = _get_installation(runtime, args.instance)
root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
@@ -434,6 +517,7 @@ def run(argv: list[str] | None = None) -> int:
proton=Path(args.proton).expanduser() if args.proton else None,
native=True if args.native else None,
progress=lambda message: print(f" {message}", flush=True),
install_id=install_id,
)
if args.json:
_json(result)
@@ -453,8 +537,8 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["health"]["ok"] else 2
if args.command == "bootstrap-check":
instance = get_instance(inst_roots, args.instance)
result = check_bsipa_health(instance.path, st_root, args.instance)
instance, install_id = _get_installation(runtime, args.instance)
result = check_bsipa_health(instance.path, st_root, args.instance, install_id=install_id)
if args.json:
_json(result)
else:
@@ -466,7 +550,7 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["ok"] else 2
if args.command == "plan":
instance = get_instance(inst_roots, args.instance)
instance, install_id = _get_installation(runtime, args.instance)
root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
@@ -484,6 +568,7 @@ def run(argv: list[str] | None = None) -> int:
state_root=st_root,
repo_root=root,
selected=selected,
install_id=install_id,
)
print(f"Wrote plan: {path}")
print(f"Changes: {len(plan['changes'])}")
@@ -500,8 +585,8 @@ def run(argv: list[str] | None = None) -> int:
return 0
if args.command == "uninstall":
instance = get_instance(inst_roots, args.instance)
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
instance, install_id = _get_installation(runtime, args.instance)
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force, install_id=install_id)
print(f"Removed: {len(result['removed'])}")
if result["skipped"]:
print("Skipped:")
@@ -510,8 +595,8 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["stateUpdated"] else 2
if args.command == "disable":
instance = get_instance(inst_roots, args.instance)
result = disable_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
instance, install_id = _get_installation(runtime, args.instance)
result = disable_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force, install_id=install_id)
print(f"Disabled: {args.plugin}")
print(f"Removed: {len(result['removed'])}")
if result["skipped"]:
@@ -521,7 +606,7 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["stateUpdated"] else 2
if args.command == "enable":
instance = get_instance(inst_roots, args.instance)
instance, install_id = _get_installation(runtime, args.instance)
progress = (lambda message: print(f" {message}", flush=True))
result = enable_disabled_plugin(
instance=args.instance,
@@ -531,6 +616,7 @@ def run(argv: list[str] | None = None) -> int:
registry=args.registry,
lockfile=args.lockfile,
progress=progress,
install_id=install_id,
)
print(f"Enabled: {args.plugin}")
print(f"Plan: {result['planPath']}")
@@ -539,7 +625,8 @@ def run(argv: list[str] | None = None) -> int:
return 0
if args.command == "save-known-good":
result = save_known_good_set(instance=args.instance, state_root=st_root)
_instance, install_id = _get_installation(runtime, args.instance)
result = save_known_good_set(instance=args.instance, state_root=st_root, install_id=install_id)
if args.json:
_json(result)
else:
@@ -549,7 +636,7 @@ def run(argv: list[str] | None = None) -> int:
return 0
if args.command == "restore-known-good":
instance = get_instance(inst_roots, args.instance)
instance, install_id = _get_installation(runtime, args.instance)
progress = (lambda message: print(f" {message}", flush=True))
result = restore_known_good_set(
instance=args.instance,
@@ -558,6 +645,7 @@ def run(argv: list[str] | None = None) -> int:
registry=args.registry,
lockfile=args.lockfile,
progress=progress,
install_id=install_id,
)
if args.json:
_json(result)
+11 -1
View File
@@ -189,7 +189,17 @@ def resolve_runtime_config(
) -> RuntimeConfig:
repo = root or repo_root()
local_config, loaded_path, loaded = load_local_config(config_path_value, root=repo)
profile = _select_profile(local_config, profile_id)
explicit_runtime_paths = bool(
instances_root_value
or state_dir_value
or _env_instances_roots(repo)
or _env_state_root(repo)
)
profile = (
_select_profile(local_config, profile_id)
if profile_id or not explicit_runtime_paths
else None
)
resolved_instances = (
_resolve_path_list(instances_root_value, repo)
+28 -11
View File
@@ -7,7 +7,7 @@ from typing import Any
from zipfile import ZipFile
from .fsutil import ensure_inside, ensure_relative, sha256_bytes, sha256_file
from .state import backups_dir, load_installed_state, save_installed_state
from .state import backups_dir, installed_state_path, load_installed_state, save_installed_state
def _timestamp() -> str:
@@ -26,12 +26,13 @@ def _backup_existing(instance_path: Path, backup_root: Path, rel_target: str) ->
def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]:
instance = plan["instance"]
install_id = plan.get("installId")
instance_path = Path(plan["instancePath"])
if not instance_path.is_dir():
raise FileNotFoundError(f"instance path does not exist: {instance_path}")
backup_root = backups_dir(state_root, instance) / f"apply-{_timestamp()}"
installed_state = load_installed_state(state_root, instance)
backup_root = backups_dir(state_root, instance, install_id=install_id) / f"apply-{_timestamp()}"
installed_state = load_installed_state(state_root, instance, install_id=install_id)
installed_state.setdefault("beatSaberVersion", plan.get("beatSaberVersion"))
installed_state.setdefault("plugins", {})
@@ -80,12 +81,20 @@ def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]:
installed_state.setdefault("disabledPlugins", {}).pop(change["plugin"], None)
applied.append({"path": rel_target, "plugin": change["plugin"], "backup": backup})
save_installed_state(state_root, instance, installed_state)
return {"applied": applied, "statePath": str(state_root / "instances" / instance / "installed.json")}
save_installed_state(state_root, instance, installed_state, install_id=install_id)
return {"applied": applied, "statePath": str(installed_state_path(state_root, instance, install_id=install_id))}
def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugin_id: str, force: bool = False) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance)
def uninstall_plugin(
instance: str,
instance_path: Path,
state_root: Path,
plugin_id: str,
force: bool = False,
*,
install_id: str | None = None,
) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance, install_id=install_id)
plugin_state = installed_state.get("plugins", {}).get(plugin_id)
if not plugin_state:
raise KeyError(f"plugin is not recorded in managed install state: {plugin_id}")
@@ -109,12 +118,20 @@ def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugi
return {"removed": removed, "skipped": skipped, "stateUpdated": False}
installed_state.get("plugins", {}).pop(plugin_id, None)
save_installed_state(state_root, instance, installed_state)
save_installed_state(state_root, instance, installed_state, install_id=install_id)
return {"removed": removed, "skipped": skipped, "stateUpdated": True}
def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_id: str, force: bool = False) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance)
def disable_plugin(
instance: str,
instance_path: Path,
state_root: Path,
plugin_id: str,
force: bool = False,
*,
install_id: str | None = None,
) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance, install_id=install_id)
plugin_state = installed_state.get("plugins", {}).get(plugin_id)
if not plugin_state:
if plugin_id in installed_state.get("disabledPlugins", {}):
@@ -143,5 +160,5 @@ def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_
disabled_state["disabledAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
installed_state.setdefault("disabledPlugins", {})[plugin_id] = disabled_state
installed_state.get("plugins", {}).pop(plugin_id, None)
save_installed_state(state_root, instance, installed_state)
save_installed_state(state_root, instance, installed_state, install_id=install_id)
return {"removed": removed, "skipped": skipped, "stateUpdated": True}
+46 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any
@@ -42,6 +42,7 @@ class LockedPlugin:
tag: str | None
asset: str | None
sha256: str | None
download_url: str | None = None
install_strategy: str | None = None
reason: str | None = None
@@ -53,6 +54,49 @@ class Lockfile:
plugins: tuple[LockedPlugin, ...]
def _quote_toml_string(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def lockfile_to_toml(lockfile: Lockfile) -> str:
lines = [
f"beat_saber_version = {_quote_toml_string(lockfile.beat_saber_version)}",
f"instance = {_quote_toml_string(lockfile.instance)}",
"",
]
for plugin in lockfile.plugins:
lines.append("[[plugins]]")
lines.append(f"id = {_quote_toml_string(plugin.id)}")
if plugin.repo is not None:
lines.append(f"repo = {_quote_toml_string(plugin.repo)}")
if plugin.tag is not None:
lines.append(f"tag = {_quote_toml_string(plugin.tag)}")
if plugin.asset is not None:
lines.append(f"asset = {_quote_toml_string(plugin.asset)}")
if plugin.download_url is not None:
lines.append(f"download_url = {_quote_toml_string(plugin.download_url)}")
if plugin.sha256 is not None:
lines.append(f"sha256 = {_quote_toml_string(plugin.sha256)}")
if plugin.install_strategy is not None:
lines.append(f"install_strategy = {_quote_toml_string(plugin.install_strategy)}")
if plugin.reason is not None:
lines.append(f"reason = {_quote_toml_string(plugin.reason)}")
lines.append("")
return "\n".join(lines)
def write_lockfile(path: Path, lockfile: Lockfile) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(lockfile_to_toml(lockfile), encoding="utf-8")
def replace_locked_plugins(lockfile: Lockfile, replacements: dict[str, LockedPlugin]) -> Lockfile:
return replace(
lockfile,
plugins=tuple(replacements.get(plugin.id, plugin) for plugin in lockfile.plugins),
)
def _load_toml(path: Path) -> dict[str, Any]:
with path.open("rb") as handle:
return tomllib.load(handle)
@@ -115,6 +159,7 @@ def load_lockfile(path: Path) -> Lockfile:
tag=item.get("tag"),
asset=item.get("asset"),
sha256=item.get("sha256"),
download_url=item.get("download_url"),
install_strategy=item.get("install_strategy"),
reason=item.get("reason"),
)
+19 -9
View File
@@ -39,8 +39,9 @@ def enable_disabled_plugin(
lockfile: str | None = None,
repo: Path | None = None,
progress: Callable[[str], None] | None = None,
install_id: str | None = None,
) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance)
installed_state = load_installed_state(state_root, instance, install_id=install_id)
if plugin_id in installed_state.get("plugins", {}):
raise KeyError(f"plugin is already enabled: {plugin_id}")
@@ -65,6 +66,7 @@ def enable_disabled_plugin(
repo_root=root,
selected={plugin_id},
require_bootstrap=False,
install_id=install_id,
)
ensure_healthy_bootstrap(
@@ -77,6 +79,7 @@ def enable_disabled_plugin(
repo_root=root,
selected_ids={plugin_id},
progress=progress,
install_id=install_id,
)
tell(f"Applying {plugin_id}")
@@ -94,6 +97,7 @@ def enable_disabled_plugins(
lockfile: str | None = None,
repo: Path | None = None,
progress: Callable[[str], None] | None = None,
install_id: str | None = None,
) -> dict[str, Any]:
if not plugin_ids:
return {"enabled": [], "errors": []}
@@ -104,7 +108,7 @@ def enable_disabled_plugins(
lockfile=lockfile,
repo=repo,
)
installed_state = load_installed_state(state_root, instance)
installed_state = load_installed_state(state_root, instance, install_id=install_id)
enabled_plugins = installed_state.get("plugins", {})
locked_ids = {plugin.id for plugin in loaded_lockfile.plugins}
@@ -136,6 +140,7 @@ def enable_disabled_plugins(
repo_root=root,
selected=selected_ids,
require_bootstrap=False,
install_id=install_id,
)
ensure_healthy_bootstrap(
@@ -148,6 +153,7 @@ def enable_disabled_plugins(
repo_root=root,
selected_ids=selected_ids,
progress=progress,
install_id=install_id,
)
tell(f"Applying {len(plannable_ids)} plugins")
@@ -163,16 +169,18 @@ def enable_disabled_plugins(
return {"enabled": enabled, "errors": errors}
def save_known_good_set(*, instance: str, state_root: Path) -> dict[str, Any]:
def save_known_good_set(*, instance: str, state_root: Path, install_id: str | None = None) -> dict[str, Any]:
"""Record the currently enabled plugin ids as the known-good set for this instance."""
installed_state = load_installed_state(state_root, instance)
installed_state = load_installed_state(state_root, instance, install_id=install_id)
plugin_ids = sorted(installed_state.get("plugins", {}))
known_good = {
"instance": instance,
"beatSaberVersion": installed_state.get("beatSaberVersion"),
"pluginIds": plugin_ids,
}
save_known_good_state(state_root, instance, known_good)
if install_id:
known_good["installId"] = install_id
save_known_good_state(state_root, instance, known_good, install_id=install_id)
return known_good
@@ -185,14 +193,15 @@ def restore_known_good_set(
lockfile: str | None = None,
repo: Path | None = None,
progress: Callable[[str], None] | None = None,
install_id: str | None = None,
) -> dict[str, Any]:
"""Disable plugins outside the saved known-good set and re-enable the ones that are missing."""
known_good = load_known_good_state(state_root, instance)
known_good = load_known_good_state(state_root, instance, install_id=install_id)
known_good_ids = set(known_good.get("pluginIds", []))
if not known_good:
raise KeyError(f"no known-good set is saved for this instance: {instance}")
installed_state = load_installed_state(state_root, instance)
installed_state = load_installed_state(state_root, instance, install_id=install_id)
currently_enabled = set(installed_state.get("plugins", {}))
to_disable = sorted(currently_enabled - known_good_ids)
@@ -200,7 +209,7 @@ def restore_known_good_set(
disable_errors: list[dict[str, str]] = []
for plugin_id in to_disable:
try:
result = disable_plugin(instance, instance_path, state_root, plugin_id, False)
result = disable_plugin(instance, instance_path, state_root, plugin_id, False, install_id=install_id)
if result["stateUpdated"]:
disabled.append(plugin_id)
else:
@@ -209,7 +218,7 @@ def restore_known_good_set(
except Exception as exc:
disable_errors.append({"plugin": plugin_id, "error": str(exc)})
installed_state = load_installed_state(state_root, instance)
installed_state = load_installed_state(state_root, instance, install_id=install_id)
currently_enabled = set(installed_state.get("plugins", {}))
to_enable = sorted(known_good_ids - currently_enabled)
enable_result = enable_disabled_plugins(
@@ -221,6 +230,7 @@ def restore_known_good_set(
lockfile=lockfile,
repo=repo,
progress=progress,
install_id=install_id,
)
return {
+10 -3
View File
@@ -10,7 +10,11 @@ from zipfile import ZipFile
from .fsutil import ensure_relative, sha256_bytes, sha256_file
from .models import Lockfile, Registry, VALID_STRATEGIES
from .bsipa import bootstrap_health_error, check_bsipa_health, planning_requires_bootstrap
from .state import downloads_dir, plans_dir, plugin_downloads_dir
from .state import (
downloads_dir,
plans_dir,
plugin_downloads_dir,
)
ALLOWED_BSIPA_TOP_LEVEL = {"IPA", "Libs", "Plugins"}
@@ -96,6 +100,7 @@ def create_plan(
repo_root: Path,
selected: set[str] | None = None,
require_bootstrap: bool = True,
install_id: str | None = None,
) -> tuple[dict[str, Any], Path]:
selected_ids = (
_expand_required_dependencies(selected, registry, lockfile)
@@ -106,7 +111,7 @@ def create_plan(
warnings: list[str] = []
if require_bootstrap and planning_requires_bootstrap(lockfile.plugins, selected_ids):
health = check_bsipa_health(instance_path, state_root, instance)
health = check_bsipa_health(instance_path, state_root, instance, install_id=install_id)
if not health["ok"]:
raise ValueError(bootstrap_health_error(health))
@@ -175,7 +180,9 @@ def create_plan(
"warnings": warnings,
"changes": changes,
}
plan_path = plans_dir(state_root, instance) / f"plan-{_now_slug()}.json"
if install_id:
plan["installId"] = install_id
plan_path = plans_dir(state_root, instance, install_id=install_id) / f"plan-{_now_slug()}.json"
with plan_path.open("w", encoding="utf-8") as handle:
json.dump(plan, handle, indent=2, sort_keys=True)
handle.write("\n")
+1
View File
@@ -0,0 +1 @@
"""Asset reconstruction recipes for helper-managed composite packages."""
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
import argparse
import hashlib
import os
import shutil
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from urllib.request import Request, urlopen
from zipfile import ZIP_STORED, ZipFile, ZipInfo
PLUGIN_ID = "cinema-tools"
DEFAULT_INSTANCE = "1.44.1"
YT_DLP_VERSION = "2026.07.04"
YT_DLP_URL = f"https://github.com/yt-dlp/yt-dlp/releases/download/{YT_DLP_VERSION}/yt-dlp.exe"
YT_DLP_SHA256 = "52fe3c26dcf71fbdc85b528589020bb0b8e383155cfa81b64dd447bbe35e24b8"
FFMPEG_VERSION = "8.1.2"
FFMPEG_URL = (
f"https://github.com/GyanD/codexffmpeg/releases/download/{FFMPEG_VERSION}/"
f"ffmpeg-{FFMPEG_VERSION}-essentials_build.zip"
)
FFMPEG_ZIP_SHA256 = "db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec"
FFMPEG_EXE_SHA256 = "1326dde4c84ff1f96fe6b8916c5bed29e163e9b5dccf995f6f3db069d143ec5e"
ASSET_NAME = f"CinemaTools-yt-dlp-{YT_DLP_VERSION}-ffmpeg-{FFMPEG_VERSION}.zip"
BUNDLE_SHA256 = "4d299e40f747abb9777838542ca68ec450e0b86e6eb11af341e6bd568f47edf2"
ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
@dataclass(frozen=True)
class BuiltAsset:
path: Path
sha256: str
size: int
def _default_state_root() -> Path:
configured = os.environ.get("PLUGIN_HELPER_STATE_DIR")
if configured:
return Path(configured).expanduser()
xdg_state_home = os.environ.get("XDG_STATE_HOME")
if xdg_state_home:
return Path(xdg_state_home).expanduser() / "plugin-helper"
return Path("~/.local/state/plugin-helper").expanduser()
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 _download(url: str, destination: Path) -> None:
request = Request(url, headers={"User-Agent": "plugin-helper"})
destination.parent.mkdir(parents=True, exist_ok=True)
with urlopen(request, timeout=120) as response, destination.open("wb") as handle:
shutil.copyfileobj(response, handle)
def _verify(path: Path, expected_sha256: str, label: str) -> None:
actual = _sha256_file(path)
if actual != expected_sha256:
raise ValueError(f"{label} sha256 mismatch: expected {expected_sha256}, got {actual}")
def _read_ffmpeg_exe(archive_path: Path) -> bytes:
with ZipFile(archive_path) as archive:
matches = [
info
for info in archive.infolist()
if not info.is_dir() and info.filename.replace("\\", "/").endswith("/bin/ffmpeg.exe")
]
if len(matches) != 1:
raise ValueError(f"expected exactly one ffmpeg.exe under */bin/, found {len(matches)}")
data = archive.read(matches[0])
actual = hashlib.sha256(data).hexdigest()
if actual != FFMPEG_EXE_SHA256:
raise ValueError(f"ffmpeg.exe sha256 mismatch: expected {FFMPEG_EXE_SHA256}, got {actual}")
return data
def _write_member(archive: ZipFile, name: str, data: bytes) -> None:
info = ZipInfo(name, date_time=ZIP_TIMESTAMP)
info.compress_type = ZIP_STORED
info.external_attr = 0o644 << 16
archive.writestr(info, data)
def build_asset(output: Path, *, work_dir: Path | None = None) -> BuiltAsset:
output = output.expanduser()
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=work_dir) as tmp:
tmp_path = Path(tmp)
yt_dlp_path = tmp_path / "yt-dlp.exe"
ffmpeg_zip_path = tmp_path / f"ffmpeg-{FFMPEG_VERSION}-essentials_build.zip"
staged_output = tmp_path / ASSET_NAME
_download(YT_DLP_URL, yt_dlp_path)
_verify(yt_dlp_path, YT_DLP_SHA256, "yt-dlp.exe")
_download(FFMPEG_URL, ffmpeg_zip_path)
_verify(ffmpeg_zip_path, FFMPEG_ZIP_SHA256, "ffmpeg essentials zip")
yt_dlp_data = yt_dlp_path.read_bytes()
ffmpeg_data = _read_ffmpeg_exe(ffmpeg_zip_path)
with ZipFile(staged_output, "w") as archive:
_write_member(archive, "Libs/yt-dlp.exe", yt_dlp_data)
_write_member(archive, "Libs/ffmpeg.exe", ffmpeg_data)
actual_bundle_sha256 = _sha256_file(staged_output)
if actual_bundle_sha256 != BUNDLE_SHA256:
raise ValueError(
f"{ASSET_NAME} sha256 mismatch: expected {BUNDLE_SHA256}, got {actual_bundle_sha256}"
)
shutil.move(str(staged_output), output)
return BuiltAsset(path=output, sha256=_sha256_file(output), size=output.stat().st_size)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Rebuild the helper-managed Cinema tools bundle.")
parser.add_argument("--instance", default=DEFAULT_INSTANCE, help="Beat Saber instance/version cache key")
parser.add_argument(
"--state-dir",
type=Path,
default=_default_state_root(),
help="plugin-helper state root; defaults to PLUGIN_HELPER_STATE_DIR, XDG_STATE_HOME, or ~/.local/state/plugin-helper",
)
parser.add_argument(
"--output",
type=Path,
help="Exact output path; defaults to <state-dir>/cache/downloads/<instance>/cinema-tools/<asset>",
)
parser.add_argument("--work-dir", type=Path, help="Parent directory for temporary downloads")
return parser
def run(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
output = args.output or args.state_dir / "cache" / "downloads" / args.instance / PLUGIN_ID / ASSET_NAME
try:
built = build_asset(output, work_dir=args.work_dir)
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
print(f"Wrote: {built.path}")
print(f"Size: {built.size}")
print(f"SHA-256: {built.sha256}")
print("Members:")
print(f" Libs/yt-dlp.exe {YT_DLP_SHA256}")
print(f" Libs/ffmpeg.exe {FFMPEG_EXE_SHA256}")
return 0
def main() -> None:
raise SystemExit(run())
if __name__ == "__main__":
main()
+87 -33
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import hashlib
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -7,49 +9,97 @@ from typing import Any
from .fsutil import atomic_write_json, read_json
def instance_state_dir(state_root: Path, instance: str) -> Path:
return state_root / "instances" / instance
def _slug(value: str) -> str:
slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip()).strip("-._")
return slug[:48] or "install"
def installed_state_path(state_root: Path, instance: str) -> Path:
return instance_state_dir(state_root, instance) / "installed.json"
def bootstrap_state_path(state_root: Path, instance: str) -> Path:
return instance_state_dir(state_root, instance) / "bootstrap.json"
def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
return read_json(
installed_state_path(state_root, instance),
{"instance": instance, "plugins": {}},
def installation_id(
*,
profile_id: str | None,
root: Path | None,
instance: str,
instance_path: Path,
) -> str:
resolved_root = (
root.expanduser().resolve(strict=False)
if root
else instance_path.parent.expanduser().resolve(strict=False)
)
resolved_path = instance_path.expanduser().resolve(strict=False)
profile = profile_id or "default"
digest = hashlib.sha256(
f"{profile}\0{resolved_root}\0{resolved_path}".encode("utf-8")
).hexdigest()[:12]
return f"{_slug(profile)}-{_slug(instance)}-{digest}"
def load_bootstrap_state(state_root: Path, instance: str) -> dict[str, Any]:
return read_json(bootstrap_state_path(state_root, instance), {})
def instance_state_dir(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
if install_id:
return state_root / "installs" / install_id
return state_root / "installs" / _slug(instance)
def save_bootstrap_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
def installed_state_path(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
return instance_state_dir(state_root, instance, install_id=install_id) / "installed.json"
def bootstrap_state_path(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
return instance_state_dir(state_root, instance, install_id=install_id) / "bootstrap.json"
def known_good_state_path(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
return instance_state_dir(state_root, instance, install_id=install_id) / "known-good.json"
def load_installed_state(state_root: Path, instance: str, *, install_id: str | None = None) -> dict[str, Any]:
default = {"instance": instance, "plugins": {}}
state = read_json(installed_state_path(state_root, instance, install_id=install_id), default)
if install_id:
state.setdefault("installId", install_id)
return state
def load_bootstrap_state(state_root: Path, instance: str, *, install_id: str | None = None) -> dict[str, Any]:
return read_json(bootstrap_state_path(state_root, instance, install_id=install_id), {})
def save_bootstrap_state(
state_root: Path,
instance: str,
state: dict[str, Any],
*,
install_id: str | None = None,
) -> None:
state.setdefault("instance", instance)
if install_id:
state.setdefault("installId", install_id)
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
atomic_write_json(bootstrap_state_path(state_root, instance), state)
atomic_write_json(bootstrap_state_path(state_root, instance, install_id=install_id), state)
def save_installed_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
def save_installed_state(
state_root: Path,
instance: str,
state: dict[str, Any],
*,
install_id: str | None = None,
) -> None:
state.setdefault("instance", instance)
if install_id:
state.setdefault("installId", install_id)
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
atomic_write_json(installed_state_path(state_root, instance), state)
atomic_write_json(installed_state_path(state_root, instance, install_id=install_id), state)
def plans_dir(state_root: Path, instance: str) -> Path:
path = instance_state_dir(state_root, instance) / "plans"
def plans_dir(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
path = instance_state_dir(state_root, instance, install_id=install_id) / "plans"
path.mkdir(parents=True, exist_ok=True)
return path
def downloads_dir(state_root: Path, instance: str) -> Path:
path = instance_state_dir(state_root, instance) / "downloads"
path = state_root / "cache" / "downloads" / instance
path.mkdir(parents=True, exist_ok=True)
return path
@@ -60,21 +110,25 @@ def plugin_downloads_dir(state_root: Path, instance: str, plugin_id: str) -> Pat
return path
def backups_dir(state_root: Path, instance: str) -> Path:
path = instance_state_dir(state_root, instance) / "backups"
def backups_dir(state_root: Path, instance: str, *, install_id: str | None = None) -> Path:
path = instance_state_dir(state_root, instance, install_id=install_id) / "backups"
path.mkdir(parents=True, exist_ok=True)
return path
def known_good_state_path(state_root: Path, instance: str) -> Path:
return instance_state_dir(state_root, instance) / "known-good.json"
def load_known_good_state(state_root: Path, instance: str, *, install_id: str | None = None) -> dict[str, Any]:
return read_json(known_good_state_path(state_root, instance, install_id=install_id), {})
def load_known_good_state(state_root: Path, instance: str) -> dict[str, Any]:
return read_json(known_good_state_path(state_root, instance), {})
def save_known_good_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
def save_known_good_state(
state_root: Path,
instance: str,
state: dict[str, Any],
*,
install_id: str | None = None,
) -> None:
state.setdefault("instance", instance)
if install_id:
state.setdefault("installId", install_id)
state["savedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
atomic_write_json(known_good_state_path(state_root, instance), state)
atomic_write_json(known_good_state_path(state_root, instance, install_id=install_id), state)
+14 -5
View File
@@ -20,7 +20,7 @@ from .operations import (
save_known_good_set,
)
from .reports import installed_plugins_report
from .state import load_installed_state, load_known_good_state
from .state import instance_state_dir, load_installed_state, load_known_good_state
@dataclass(frozen=True)
@@ -133,6 +133,7 @@ class PluginHelperTui(App[int]):
target.state_root,
plugin_id,
False,
install_id=target.install_id,
)
if not result["stateUpdated"]:
self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}")
@@ -148,6 +149,7 @@ class PluginHelperTui(App[int]):
plugin_id=plugin_id,
repo=self.repo_root,
progress=self._operation_progress,
install_id=target.install_id,
)
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
else:
@@ -190,6 +192,7 @@ class PluginHelperTui(App[int]):
target.state_root,
plugin_id,
False,
install_id=target.install_id,
)
if result["stateUpdated"]:
changed += 1
@@ -225,6 +228,7 @@ class PluginHelperTui(App[int]):
plugin_ids=plugin_ids,
repo=self.repo_root,
progress=self._operation_progress,
install_id=target.install_id,
)
except Exception as exc:
self._set_status(f"Could not enable plugins: {exc}")
@@ -240,7 +244,11 @@ class PluginHelperTui(App[int]):
if self._busy or self.mode != "plugins" or self.selected_installation is None:
return
target = self.selected_installation
known_good = save_known_good_set(instance=target.instance_name, state_root=target.state_root)
known_good = save_known_good_set(
instance=target.instance_name,
state_root=target.state_root,
install_id=target.install_id,
)
count = len(known_good["pluginIds"])
self._set_status(f"Saved known-good set: {count} enabled plugins.")
@@ -248,7 +256,7 @@ class PluginHelperTui(App[int]):
if self._busy or self.mode != "plugins" or self.selected_installation is None:
return
target = self.selected_installation
if not load_known_good_state(target.state_root, target.instance_name):
if not load_known_good_state(target.state_root, target.instance_name, install_id=target.install_id):
self._set_status("No known-good set saved yet. Press s to save the current selection.")
return
self._busy = True
@@ -261,6 +269,7 @@ class PluginHelperTui(App[int]):
state_root=target.state_root,
repo=self.repo_root,
progress=self._operation_progress,
install_id=target.install_id,
)
except Exception as exc:
self._set_status(f"Could not restore known-good set: {exc}")
@@ -298,7 +307,7 @@ class PluginHelperTui(App[int]):
choice.install_label,
choice.instance_name,
str(choice.instance_path),
str(choice.state_root),
str(instance_state_dir(choice.state_root, choice.instance_name, install_id=choice.install_id)),
)
if self.setup_hint:
self._set_status(self.setup_hint)
@@ -322,7 +331,7 @@ class PluginHelperTui(App[int]):
try:
lockfile = load_lockfile(self.repo_root / "locks" / f"{target.instance_name}.lock.toml")
report = installed_plugins_report(
installed_state=load_installed_state(target.state_root, target.instance_name),
installed_state=load_installed_state(target.state_root, target.instance_name, install_id=target.install_id),
registry=load_registry(self.repo_root / "registry" / "plugins"),
lockfile=lockfile,
)
+239
View File
@@ -0,0 +1,239 @@
from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import replace
from pathlib import Path
from typing import Any
from urllib.request import Request, urlopen
from .fsutil import sha256_file
from .installer import apply_plan
from .models import LockedPlugin, Lockfile, Registry, replace_locked_plugins, write_lockfile
from .planner import create_plan
from .state import plugin_downloads_dir
from .updates import FetchBeatMods, FetchReleases, check_updates
DownloadAsset = Callable[[str, Path], dict[str, Any]]
ApplyPlan = Callable[[dict[str, Any], Path], dict[str, Any]]
def _download_asset(url: str, destination: Path) -> dict[str, Any]:
destination.parent.mkdir(parents=True, exist_ok=True)
headers = {"User-Agent": "plugin-helper"}
token = os.environ.get("GITHUB_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
request = Request(url, headers=headers)
with urlopen(request, timeout=60) as response:
destination.write_bytes(response.read())
return {"url": url, "path": str(destination), "sha256": sha256_file(destination)}
def _reason_for_update(plugin: dict[str, Any], *, actual_sha256: str, beat_saber_version: str) -> str:
if plugin.get("latestBeatModsVersionId") is not None:
return (
f"BeatMods verified {plugin['name']} "
f"{str(plugin.get('latestTag') or '').removeprefix('beatmods-')} "
f"for Beat Saber {beat_saber_version} as version id {plugin['latestBeatModsVersionId']}, "
f"zipHash {plugin.get('latestBeatModsZipHash')}. "
f"Downloaded from BeatMods CDN and verified SHA-256 {actual_sha256}."
)
digest = plugin.get("latestAssetSha256")
digest_note = (
f"GitHub release digest matched {digest}."
if digest
else f"GitHub release did not publish a digest; computed SHA-256 {actual_sha256}."
)
release = f" release {plugin.get('latestUrl')}" if plugin.get("latestUrl") else ""
return (
f"Updated from GitHub {plugin.get('repo')} tag {plugin.get('latestTag')}{release}, "
f"asset {plugin.get('latestAsset')}. {digest_note}"
)
def _updated_locked_plugin(
locked: LockedPlugin,
plugin: dict[str, Any],
*,
actual_sha256: str,
beat_saber_version: str,
) -> LockedPlugin:
return replace(
locked,
tag=plugin.get("latestTag"),
asset=plugin.get("latestAsset"),
sha256=actual_sha256,
download_url=plugin.get("latestAssetUrl"),
reason=_reason_for_update(
plugin,
actual_sha256=actual_sha256,
beat_saber_version=beat_saber_version,
),
)
def run_update(
*,
instance: str,
instance_path: Path,
registry: Registry,
lockfile: Lockfile,
lock_path: Path,
state_root: Path,
repo_root: Path,
selected: set[str],
fetch_releases: FetchReleases,
fetch_beatmods: FetchBeatMods | None = None,
include_prerelease: bool = False,
dry_run: bool = False,
apply: bool = False,
download_asset: DownloadAsset = _download_asset,
apply_plan_func: ApplyPlan = apply_plan,
install_id: str | None = None,
) -> dict[str, Any]:
if not selected:
raise ValueError("update requires at least one --plugin")
report = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=fetch_releases,
fetch_beatmods=fetch_beatmods,
selected=selected,
include_prerelease=include_prerelease,
)
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
reported_ids = {plugin["id"] for plugin in report["plugins"]}
updated: list[dict[str, Any]] = []
refused: list[dict[str, Any]] = []
downloads: list[dict[str, Any]] = []
replacements: dict[str, LockedPlugin] = {}
for missing_id in sorted(selected - reported_ids):
refused.append(
{
"plugin": missing_id,
"status": "error",
"messages": ["plugin is not locked for this instance"],
"reviewReasons": [],
}
)
for plugin in report["plugins"]:
plugin_id = plugin["id"]
if plugin["status"] != "update":
refused.append(
{
"plugin": plugin_id,
"status": plugin["status"],
"messages": plugin.get("messages", []),
"reviewReasons": plugin.get("reviewReasons", []),
}
)
continue
if not plugin.get("latestAsset") or not plugin.get("latestAssetUrl") or not plugin.get("latestTag"):
refused.append(
{
"plugin": plugin_id,
"status": "error",
"messages": ["update candidate is missing tag, asset, or download URL"],
"reviewReasons": [],
}
)
continue
if plugin_id not in locked_by_id:
refused.append(
{
"plugin": plugin_id,
"status": "error",
"messages": ["plugin is not locked for this instance"],
"reviewReasons": [],
}
)
continue
if dry_run:
updated.append(
{
"plugin": plugin_id,
"fromTag": plugin.get("currentTag"),
"toTag": plugin.get("latestTag"),
"asset": plugin.get("latestAsset"),
"url": plugin.get("latestAssetUrl"),
"dryRun": True,
}
)
continue
destination = plugin_downloads_dir(state_root, instance, plugin_id) / plugin["latestAsset"]
downloaded = download_asset(plugin["latestAssetUrl"], destination)
actual_sha = str(downloaded.get("sha256") or sha256_file(destination))
expected_sha = plugin.get("latestAssetSha256")
if expected_sha and actual_sha != expected_sha:
destination.unlink(missing_ok=True)
raise ValueError(f"{plugin_id}: downloaded asset sha256 mismatch")
downloads.append(
{
"plugin": plugin_id,
"asset": plugin["latestAsset"],
"path": str(destination),
"url": plugin["latestAssetUrl"],
"sha256": actual_sha,
}
)
replacements[plugin_id] = _updated_locked_plugin(
locked_by_id[plugin_id],
plugin,
actual_sha256=actual_sha,
beat_saber_version=report["beatSaberVersion"],
)
updated.append(
{
"plugin": plugin_id,
"fromTag": plugin.get("currentTag"),
"toTag": plugin.get("latestTag"),
"asset": plugin.get("latestAsset"),
"sha256": actual_sha,
}
)
result: dict[str, Any] = {
"instance": report["instance"],
"beatSaberVersion": report["beatSaberVersion"],
"lockfile": str(lock_path),
"updated": updated,
"refused": refused,
"downloads": downloads,
"planPath": None,
"applied": [],
"dryRun": dry_run,
}
if dry_run or not replacements:
return result
updated_lockfile = replace_locked_plugins(lockfile, replacements)
plan, plan_path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=updated_lockfile.beat_saber_version,
registry=registry,
lockfile=updated_lockfile,
state_root=state_root,
repo_root=repo_root,
selected=set(replacements),
install_id=install_id,
)
result["planPath"] = str(plan_path)
if apply:
apply_result = apply_plan_func(plan, state_root)
result["applied"] = apply_result["applied"]
result["statePath"] = apply_result["statePath"]
write_lockfile(lock_path, updated_lockfile)
return result
+181 -1
View File
@@ -4,10 +4,28 @@ import fnmatch
import re
from typing import Any, Callable
from .beatmods import BeatModsEntry, normalize_mods
from .models import Lockfile, Registry
FetchReleases = Callable[[str], list[dict[str, Any]]]
FetchBeatMods = Callable[[str], list[dict[str, Any]]]
PRIVATE_SOURCE_PREFIXES = ("discord/", "patreon/")
REVIEW_PATTERNS = (
"local build",
"localbuild",
"github pr",
"/pull/",
" pr ",
"manual",
"compatibility trial",
"failed compatibility",
"currently failed",
"smoke blocked",
"resmoke pending",
"do not reinstall",
)
def _release_tag(release: dict[str, Any]) -> str:
@@ -23,6 +41,71 @@ def _asset_name(asset: dict[str, Any]) -> str:
return str(asset.get("name") or "")
def _source_kind(repo: str | None) -> str:
if not repo:
return "missing"
lowered = repo.lower()
if lowered.startswith(PRIVATE_SOURCE_PREFIXES):
return "private"
if re.fullmatch(r"[^/\s]+/[^/\s]+", repo):
return "github"
return "unknown"
def _is_beatmods_locked(tag: str | None, reason: str | None) -> bool:
return str(tag or "").startswith("beatmods-") or "beatmods" in str(reason or "").lower()
def _current_beatmods_version(tag: str | None, reason: str | None, asset: str | None) -> str | None:
if tag and tag.startswith("beatmods-"):
return tag.removeprefix("beatmods-")
for text in (reason, asset):
if not text:
continue
match = re.search(r"\bBeatMods(?:\s+verified)?\s+[^0-9]*(\d+(?:\.\d+){1,4})\b", text, re.I)
if match:
return match.group(1)
match = re.search(r"-(\d+(?:\.\d+){1,4})(?:\.zip|\+|-|$)", text)
if match:
return match.group(1)
return None
def _normalized_name(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "", text.lower())
def _beatmods_match(
entries: list[BeatModsEntry],
*,
plugin_id: str,
plugin_name: str,
) -> BeatModsEntry | None:
wanted = {_normalized_name(plugin_id), _normalized_name(plugin_name)}
for entry in entries:
if _normalized_name(entry.name) in wanted:
return entry
return None
def _review_reasons(tag: str | None, reason: str | None) -> list[str]:
text = f"{tag or ''} {reason or ''}".lower()
reasons: list[str] = []
if any(pattern in text for pattern in ("local build", "localbuild")):
reasons.append("local build")
if any(pattern in text for pattern in ("github pr", "/pull/", " pr ")) or str(tag or "").startswith("pr-"):
reasons.append("pull request build")
if "failed" in text or "compatibility trial" in text:
reasons.append("compatibility failure history")
if "smoke blocked" in text or "resmoke pending" in text or "currently failed" in text:
reasons.append("verification follow-up pending")
if "manual" in text:
reasons.append("manual install notes")
if not reasons and any(pattern in text for pattern in REVIEW_PATTERNS):
reasons.append("special handling noted")
return reasons
def _semver_key(tag: str) -> tuple[int, tuple[int, ...], str]:
match = re.search(r"(\d+(?:\.\d+){0,3})", tag)
if not match:
@@ -99,18 +182,22 @@ def check_updates(
registry: Registry,
lockfile: Lockfile,
fetch_releases: FetchReleases,
fetch_beatmods: FetchBeatMods | None = None,
selected: set[str] | None = None,
include_prerelease: bool = False,
) -> dict[str, Any]:
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
plugins: list[dict[str, Any]] = []
summary = {"current": 0, "updates": 0, "warnings": 0, "errors": 0}
summary = {"current": 0, "updates": 0, "warnings": 0, "errors": 0, "skipped": 0, "reviews": 0}
beatmods_entries: list[BeatModsEntry] | None = None
beatmods_error: Exception | None = None
for locked in lockfile.plugins:
if locked.id not in selected_ids:
continue
registry_plugin = registry.get(locked.id)
repo = locked.repo or (registry_plugin.repo if registry_plugin else None)
review_reasons = _review_reasons(locked.tag, locked.reason)
entry: dict[str, Any] = {
"id": locked.id,
"name": registry_plugin.name if registry_plugin else locked.id,
@@ -123,13 +210,106 @@ def check_updates(
"latestAssetSha256": None,
"status": "unknown",
"messages": [],
"review": bool(review_reasons),
"reviewReasons": review_reasons,
}
if review_reasons:
summary["reviews"] += 1
source_kind = _source_kind(repo)
if source_kind == "private":
entry["status"] = "skipped"
entry["messages"].append("paid/private source; check manually outside public update APIs")
summary["skipped"] += 1
plugins.append(entry)
continue
needs_manual_review = any(
reason in review_reasons
for reason in (
"local build",
"pull request build",
"verification follow-up pending",
"manual install notes",
)
)
if review_reasons and (needs_manual_review or not _is_beatmods_locked(locked.tag, locked.reason)):
entry["status"] = "review"
entry["messages"].append("special install or compatibility history; review notes before updating")
plugins.append(entry)
continue
if _is_beatmods_locked(locked.tag, locked.reason):
if fetch_beatmods is None:
entry["status"] = "warning"
entry["messages"].append("BeatMods check unavailable")
summary["warnings"] += 1
plugins.append(entry)
continue
if beatmods_entries is None and beatmods_error is None:
try:
beatmods_entries = normalize_mods(fetch_beatmods(lockfile.beat_saber_version))
except Exception as exc:
beatmods_error = exc
if beatmods_error is not None:
entry["status"] = "error"
entry["messages"].append(f"BeatMods: {beatmods_error}")
summary["errors"] += 1
plugins.append(entry)
continue
beatmods_match = _beatmods_match(
beatmods_entries or [],
plugin_id=locked.id,
plugin_name=entry["name"],
)
if beatmods_match is not None and beatmods_match.mod_version:
current_version = _current_beatmods_version(locked.tag, locked.reason, locked.asset)
entry["latestTag"] = f"beatmods-{beatmods_match.mod_version}"
entry["latestAsset"] = (
f"{beatmods_match.name}-{beatmods_match.mod_version}.zip"
if beatmods_match.name
else None
)
entry["latestBeatModsVersionId"] = beatmods_match.version_id
entry["latestBeatModsZipHash"] = beatmods_match.zip_hash
entry["latestAssetUrl"] = (
f"https://beatmods.com/cdn/mod/{beatmods_match.zip_hash}.zip"
if beatmods_match.zip_hash
else None
)
if current_version == beatmods_match.mod_version:
entry["status"] = "current"
summary["current"] += 1
else:
entry["status"] = "update"
entry["messages"].append(
f"BeatMods verified {beatmods_match.mod_version} for Beat Saber {lockfile.beat_saber_version}"
)
summary["updates"] += 1
plugins.append(entry)
continue
if source_kind != "github":
entry["status"] = "warning"
entry["messages"].append("no matching BeatMods verified entry found")
summary["warnings"] += 1
plugins.append(entry)
continue
entry["messages"].append("no matching BeatMods verified entry found; falling back to GitHub")
if not repo:
entry["status"] = "warning"
entry["messages"].append("missing repository")
summary["warnings"] += 1
plugins.append(entry)
continue
if source_kind != "github":
entry["status"] = "warning"
entry["messages"].append("unsupported repository source")
summary["warnings"] += 1
plugins.append(entry)
continue
try:
releases = fetch_releases(repo)
+10 -2
View File
@@ -36,13 +36,19 @@ DEFAULT_BACKUP_EXCLUDES = (
)
def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]:
def backup_userdata(
instance: str,
instance_path: Path,
state_root: Path,
*,
install_id: str | None = None,
) -> dict[str, Any]:
source = instance_path / "UserData"
if not source.is_dir():
raise FileNotFoundError(f"UserData directory not found: {source}")
created_at = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
destination = backups_dir(state_root, instance) / f"userdata-{created_at}.tar.gz"
destination = backups_dir(state_root, instance, install_id=install_id) / f"userdata-{created_at}.tar.gz"
files: list[dict[str, Any]] = []
total_size = 0
for path in sorted(item for item in source.rglob("*") if item.is_file()):
@@ -59,6 +65,8 @@ def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dic
"totalSize": total_size,
"files": files,
}
if install_id:
manifest["installId"] = install_id
destination.parent.mkdir(parents=True, exist_ok=True)
manifest_path = destination.parent / f".{destination.name}.manifest.json"
+741 -13
View File
@@ -22,12 +22,20 @@ from plugin_helper.config import is_windows, load_local_config, resolve_runtime_
from plugin_helper.fsutil import sha256_file
from plugin_helper.installer import apply_plan, disable_plugin, uninstall_plugin
from plugin_helper.instances import get_instance, list_instances
from plugin_helper.models import Dependency, Lockfile, LockedPlugin, Registry, RegistryPlugin, load_registry
from plugin_helper.models import Dependency, Lockfile, LockedPlugin, Registry, RegistryPlugin, load_lockfile, load_registry, write_lockfile
from plugin_helper.operations import enable_disabled_plugin
from plugin_helper.planner import create_plan
from plugin_helper.scanner import scan_bootstrap_files, scan_instance
from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state, save_installed_state
from plugin_helper.state import (
downloads_dir,
installation_id,
load_installed_state,
plugin_downloads_dir,
save_bootstrap_state,
save_installed_state,
)
from plugin_helper.tui import InstallationChoice, PluginHelperTui
from plugin_helper.update_runner import run_update
from plugin_helper.updates import check_updates
from plugin_helper.userdata import (
backup_userdata,
@@ -40,6 +48,11 @@ from plugin_helper.userdata import (
class PluginHelperTests(unittest.TestCase):
def _write_plugin_zip(self, path: Path, *, content: bytes = b"updated dll") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with ZipFile(path, "w") as archive:
archive.writestr("Plugins/Example.dll", content)
def test_load_registry_reads_plugin_directory(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -69,6 +82,57 @@ dependencies = [
self.assertEqual(plugin.asset_patterns, ("Example-*.zip",))
self.assertEqual(plugin.dependencies, (Dependency(id="bsipa", constraint=">=4.3.7"),))
def test_lockfile_download_url_is_optional_and_round_trips(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
old_lock_path = root / "old.lock.toml"
old_lock_path.write_text(
"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
[[plugins]]
id = "old"
repo = "owner/old"
tag = "v1"
asset = "Old.zip"
sha256 = "abc"
install_strategy = "bsipa-zip"
""".lstrip(),
encoding="utf-8",
)
old_lock = load_lockfile(old_lock_path)
self.assertIsNone(old_lock.plugins[0].download_url)
new_lock_path = root / "new.lock.toml"
write_lockfile(
new_lock_path,
Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo="owner/example",
tag="v2",
asset="Example.zip",
download_url="https://example.invalid/Example.zip",
sha256="def",
install_strategy="bsipa-zip",
),
),
),
)
text = new_lock_path.read_text(encoding="utf-8")
reloaded = load_lockfile(new_lock_path)
self.assertIn('download_url = "https://example.invalid/Example.zip"', text)
self.assertLess(text.index("asset = "), text.index("download_url = "))
self.assertLess(text.index("download_url = "), text.index("sha256 = "))
self.assertEqual(reloaded.plugins[0].download_url, "https://example.invalid/Example.zip")
def test_normalize_beatmods_current_nested_response(self) -> None:
payload = {
"mods": [
@@ -857,10 +921,112 @@ sha256 = "{sha256_file(asset)}"
self.assertEqual(status, 0)
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8")
install_id = installation_id(
profile_id=None,
root=instance_root,
instance="1.40.8",
instance_path=instance,
)
updated = load_installed_state(state, "1.40.8", install_id=install_id)
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated["disabledPlugins"])
def test_install_states_are_separate_for_duplicate_instance_names(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
linux_root = work / "linux"
windows_root = work / "windows"
linux_instance = linux_root / "1.40.8"
windows_instance = windows_root / "1.40.8"
state = work / "state"
registry_dir = work / "registry"
locks_dir = work / "locks"
registry_dir.mkdir()
locks_dir.mkdir()
for instance in (linux_instance, windows_instance):
(instance / "Beat Saber_Data").mkdir(parents=True)
(instance / "Plugins").mkdir()
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
self.assertEqual(asset.parent, state / "cache" / "downloads" / "1.40.8" / "example")
(registry_dir / "plugins.toml").write_text(
"""
[[plugins]]
id = "example"
name = "Example"
repo = "owner/example"
asset_patterns = ["*.dll"]
install_strategy = "dll-to-plugins"
""".lstrip(),
encoding="utf-8",
)
(locks_dir / "1.40.8.lock.toml").write_text(
f"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
[[plugins]]
id = "example"
repo = "owner/example"
tag = "v1.0.0"
asset = "Example.dll"
sha256 = "{sha256_file(asset)}"
""".lstrip(),
encoding="utf-8",
)
linux_id = installation_id(
profile_id="shared",
root=linux_root,
instance="1.40.8",
instance_path=linux_instance,
)
windows_id = installation_id(
profile_id="shared",
root=windows_root,
instance="1.40.8",
instance_path=windows_instance,
)
disabled_state = {
"instance": "1.40.8",
"plugins": {},
"disabledPlugins": {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"disabledAt": "2026-06-14T17:20:00Z",
"files": [
{
"path": "Plugins/Example.dll",
"sha256": sha256_file(asset),
"size": asset.stat().st_size,
}
],
}
},
}
save_installed_state(state, "1.40.8", json.loads(json.dumps(disabled_state)), install_id=linux_id)
save_installed_state(state, "1.40.8", json.loads(json.dumps(disabled_state)), install_id=windows_id)
with patch("plugin_helper.operations.repo_root", return_value=work):
result = enable_disabled_plugin(
instance="1.40.8",
instance_path=linux_instance,
state_root=state,
plugin_id="example",
repo=work,
install_id=linux_id,
)
self.assertTrue((linux_instance / "Plugins" / "Example.dll").is_file())
self.assertFalse((windows_instance / "Plugins" / "Example.dll").exists())
self.assertIn(f"installs/{linux_id}/plans", result["planPath"])
linux_state = load_installed_state(state, "1.40.8", install_id=linux_id)
windows_state = load_installed_state(state, "1.40.8", install_id=windows_id)
self.assertIn("example", linux_state["plugins"])
self.assertNotIn("example", linux_state["disabledPlugins"])
self.assertEqual(windows_state["plugins"], {})
self.assertIn("example", windows_state["disabledPlugins"])
def test_save_and_restore_known_good_set(self) -> None:
from plugin_helper.operations import restore_known_good_set, save_known_good_set
from plugin_helper.state import load_known_good_state, save_installed_state
@@ -1000,7 +1166,7 @@ instance = "1.40.8"
apply_plan(plan, state)
self.assertEqual((instance / "IPA" / "Pending" / "Plugins" / "Example.dll").read_bytes(), b"dll")
def test_plan_still_finds_legacy_flat_downloads(self) -> None:
def test_plan_finds_shared_version_downloads(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
@@ -1008,7 +1174,7 @@ instance = "1.40.8"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
asset = downloads_dir(state, "1.40.8") / "Example.dll"
asset.write_bytes(b"legacy flat download")
asset.write_bytes(b"shared version download")
plan, _ = create_plan(
instance="1.40.8",
@@ -1705,6 +1871,564 @@ instance = "1.40.8"
self.assertEqual(result["plugins"][0]["status"], "update")
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new")
def test_update_check_reports_beatmods_update(self) -> None:
registry = Registry(
{
"songcore": RegistryPlugin(
id="songcore",
name="SongCore",
repo="Kylemc1413/SongCore",
asset_patterns=("SongCore-*.zip",),
install_strategy="bsipa-zip",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(
id="songcore",
repo="Kylemc1413/SongCore",
tag="beatmods-3.16.0",
asset="SongCore-3.16.0.zip",
sha256="old",
),
),
)
result = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=lambda repo: [],
fetch_beatmods=lambda game_version: [
{
"mod": {"id": 1, "name": "SongCore", "gitUrl": "https://github.com/Kylemc1413/SongCore"},
"latest": {"id": 2600, "modVersion": "3.17.0", "zipHash": "abc"},
}
],
)
self.assertEqual(result["summary"]["updates"], 1)
self.assertEqual(result["plugins"][0]["status"], "update")
self.assertEqual(result["plugins"][0]["latestTag"], "beatmods-3.17.0")
self.assertEqual(result["plugins"][0]["latestBeatModsZipHash"], "abc")
def test_update_check_skips_private_sources(self) -> None:
registry = Registry(
{
"naluluna": RegistryPlugin(
id="naluluna",
name="Naluluna",
repo="patreon/NalulunaModAssistant",
install_strategy="root-zip",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(
id="naluluna",
repo="patreon/NalulunaModAssistant",
tag="installed-1.44.1",
asset="Naluluna-1.44.1-installed-bundle.zip",
sha256="hash",
),
),
)
result = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=lambda repo: self.fail("private sources should not query GitHub"),
fetch_beatmods=lambda game_version: self.fail("private sources should not query BeatMods"),
)
self.assertEqual(result["summary"]["skipped"], 1)
self.assertEqual(result["plugins"][0]["status"], "skipped")
self.assertIn("paid/private", result["plugins"][0]["messages"][0])
def test_update_check_marks_local_pr_build_for_review(self) -> None:
registry = Registry(
{
"jdfixer": RegistryPlugin(
id="jdfixer",
name="JDFixer",
repo="zeph-yr/JDFixer",
asset_patterns=("JDFixer.dll",),
install_strategy="dll-to-plugins",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(
id="jdfixer",
repo="zeph-yr/JDFixer",
tag="pr-26-3fce6ce",
asset="JDFixer.dll",
sha256="hash",
reason=(
"Local build from GitHub PR https://github.com/zeph-yr/JDFixer/pull/26. "
"Use this PR build instead of the failed upstream release asset."
),
),
),
)
result = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=lambda repo: self.fail("review builds should not be treated as routine GitHub checks"),
)
self.assertEqual(result["summary"]["reviews"], 1)
self.assertEqual(result["plugins"][0]["status"], "review")
self.assertEqual(result["plugins"][0]["reviewReasons"], [
"local build",
"pull request build",
"compatibility failure history",
])
def test_update_command_prepares_github_update_and_writes_lock(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
state = work / "state"
instance = work / "instance"
lock_path = work / "locks" / "1.40.8.lock.toml"
source_zip = work / "source" / "Example-v2.zip"
instance.mkdir()
self._write_plugin_zip(source_zip)
source_sha = sha256_file(source_zip)
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("*.zip",),
install_strategy="bsipa-zip",
)
}
)
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-v1.zip",
sha256="old",
install_strategy="bsipa-zip",
reason="old reason",
),
),
)
def download(_url: str, destination: Path) -> dict[str, object]:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source_zip.read_bytes())
return {"path": str(destination), "sha256": sha256_file(destination)}
result = run_update(
instance="1.40.8",
instance_path=instance,
registry=registry,
lockfile=lockfile,
lock_path=lock_path,
state_root=state,
repo_root=work,
selected={"example"},
fetch_releases=lambda repo: [
{
"tag_name": "v2.0.0",
"html_url": "https://github.com/owner/example/releases/tag/v2.0.0",
"published_at": "2026-06-12T00:00:00Z",
"assets": [
{
"name": "Example-v2.zip",
"browser_download_url": "https://example.invalid/Example-v2.zip",
"digest": f"sha256:{source_sha}",
}
],
}
],
download_asset=download,
)
self.assertEqual(result["updated"][0]["plugin"], "example")
self.assertTrue(Path(result["planPath"]).is_file())
updated_lock = load_lockfile(lock_path)
self.assertEqual(updated_lock.plugins[0].tag, "v2.0.0")
self.assertEqual(updated_lock.plugins[0].asset, "Example-v2.zip")
self.assertEqual(updated_lock.plugins[0].sha256, source_sha)
self.assertEqual(updated_lock.plugins[0].download_url, "https://example.invalid/Example-v2.zip")
self.assertIn("GitHub release digest matched", updated_lock.plugins[0].reason or "")
def test_update_command_prepares_beatmods_update(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
state = work / "state"
instance = work / "instance"
lock_path = work / "locks" / "1.44.1.lock.toml"
source_zip = work / "source" / "SongCore-3.17.0.zip"
instance.mkdir()
self._write_plugin_zip(source_zip)
registry = Registry(
{
"songcore": RegistryPlugin(
id="songcore",
name="SongCore",
repo="Kylemc1413/SongCore",
asset_patterns=("SongCore-*.zip",),
install_strategy="bsipa-zip",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(
id="songcore",
repo="Kylemc1413/SongCore",
tag="beatmods-3.16.0",
asset="SongCore-3.16.0.zip",
sha256="old",
install_strategy="bsipa-zip",
),
),
)
def download(url: str, destination: Path) -> dict[str, object]:
self.assertEqual(url, "https://beatmods.com/cdn/mod/abc.zip")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source_zip.read_bytes())
return {"path": str(destination), "sha256": sha256_file(destination)}
run_update(
instance="1.44.1",
instance_path=instance,
registry=registry,
lockfile=lockfile,
lock_path=lock_path,
state_root=state,
repo_root=work,
selected={"songcore"},
fetch_releases=lambda repo: [],
fetch_beatmods=lambda game_version: [
{
"mod": {"id": 1, "name": "SongCore", "gitUrl": "https://github.com/Kylemc1413/SongCore"},
"latest": {"id": 2600, "modVersion": "3.17.0", "zipHash": "abc"},
}
],
download_asset=download,
)
updated_lock = load_lockfile(lock_path)
self.assertEqual(updated_lock.plugins[0].tag, "beatmods-3.17.0")
self.assertEqual(updated_lock.plugins[0].asset, "SongCore-3.17.0.zip")
self.assertEqual(updated_lock.plugins[0].download_url, "https://beatmods.com/cdn/mod/abc.zip")
self.assertIn("version id 2600", updated_lock.plugins[0].reason or "")
self.assertIn("zipHash abc", updated_lock.plugins[0].reason or "")
def test_update_command_dry_run_does_not_download_or_write(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
lock_path = work / "locks" / "1.40.8.lock.toml"
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("*.zip",),
install_strategy="bsipa-zip",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(LockedPlugin(id="example", repo="owner/example", tag="v1", asset="old.zip", sha256="old"),),
)
result = run_update(
instance="1.40.8",
instance_path=work / "instance",
registry=registry,
lockfile=lockfile,
lock_path=lock_path,
state_root=work / "state",
repo_root=work,
selected={"example"},
fetch_releases=lambda repo: [
{
"tag_name": "v2",
"published_at": "2026-06-12T00:00:00Z",
"assets": [{"name": "new.zip", "browser_download_url": "https://example.invalid/new.zip"}],
}
],
dry_run=True,
download_asset=lambda _url, _destination: self.fail("dry-run should not download"),
)
self.assertTrue(result["dryRun"])
self.assertEqual(result["updated"][0]["toTag"], "v2")
self.assertFalse(lock_path.exists())
self.assertIsNone(result["planPath"])
def test_update_command_refuses_review_plugin(self) -> None:
registry = Registry(
{
"jdfixer": RegistryPlugin(
id="jdfixer",
name="JDFixer",
repo="zeph-yr/JDFixer",
asset_patterns=("JDFixer.dll",),
install_strategy="dll-to-plugins",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(
id="jdfixer",
repo="zeph-yr/JDFixer",
tag="pr-26-3fce6ce",
asset="JDFixer.dll",
sha256="hash",
reason="Local build from GitHub PR; failed compatibility trial.",
),
),
)
result = run_update(
instance="1.44.1",
instance_path=Path("/tmp/unused"),
registry=registry,
lockfile=lockfile,
lock_path=Path("/tmp/unused.lock.toml"),
state_root=Path("/tmp/state"),
repo_root=Path("/tmp/repo"),
selected={"jdfixer"},
fetch_releases=lambda repo: self.fail("review plugins should not query GitHub"),
download_asset=lambda _url, _destination: self.fail("review plugins should not download"),
)
self.assertEqual(result["updated"], [])
self.assertEqual(result["refused"][0]["plugin"], "jdfixer")
self.assertEqual(result["refused"][0]["status"], "review")
def test_update_command_apply_failure_leaves_lock_unchanged(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
state = work / "state"
instance = work / "instance"
lock_path = work / "locks" / "1.40.8.lock.toml"
source_zip = work / "source" / "Example-v2.zip"
instance.mkdir()
self._write_plugin_zip(source_zip)
original_lock_text = """
beat_saber_version = "1.40.8"
instance = "1.40.8"
[[plugins]]
id = "example"
repo = "owner/example"
tag = "v1"
asset = "old.zip"
sha256 = "old"
install_strategy = "bsipa-zip"
""".lstrip()
lock_path.parent.mkdir()
lock_path.write_text(original_lock_text, encoding="utf-8")
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("*.zip",),
install_strategy="bsipa-zip",
)
}
)
def download(_url: str, destination: Path) -> dict[str, object]:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source_zip.read_bytes())
return {"path": str(destination), "sha256": sha256_file(destination)}
with self.assertRaises(RuntimeError):
run_update(
instance="1.40.8",
instance_path=instance,
registry=registry,
lockfile=load_lockfile(lock_path),
lock_path=lock_path,
state_root=state,
repo_root=work,
selected={"example"},
fetch_releases=lambda repo: [
{
"tag_name": "v2",
"published_at": "2026-06-12T00:00:00Z",
"assets": [{"name": "Example-v2.zip", "browser_download_url": "https://example.invalid/new.zip"}],
}
],
download_asset=download,
apply=True,
apply_plan_func=lambda _plan, _state: (_ for _ in ()).throw(RuntimeError("boom")),
)
self.assertEqual(lock_path.read_text(encoding="utf-8"), original_lock_text)
def test_update_command_updates_multiple_plugins_preserving_lock_order(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
state = work / "state"
instance = work / "instance"
lock_path = work / "locks" / "1.40.8.lock.toml"
instance.mkdir()
zip_a = work / "source" / "Alpha-v2.zip"
zip_b = work / "source" / "Beta-v2.zip"
self._write_plugin_zip(zip_a, content=b"alpha")
self._write_plugin_zip(zip_b, content=b"beta")
registry = Registry(
{
"alpha": RegistryPlugin(id="alpha", name="Alpha", repo="owner/alpha", asset_patterns=("*.zip",), install_strategy="bsipa-zip"),
"beta": RegistryPlugin(id="beta", name="Beta", repo="owner/beta", asset_patterns=("*.zip",), install_strategy="bsipa-zip"),
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(id="alpha", repo="owner/alpha", tag="v1", asset="Alpha-v1.zip", sha256="old-a", install_strategy="bsipa-zip"),
LockedPlugin(id="beta", repo="owner/beta", tag="v1", asset="Beta-v1.zip", sha256="old-b", install_strategy="bsipa-zip"),
),
)
def releases(repo: str) -> list[dict[str, object]]:
name = "Alpha-v2.zip" if repo == "owner/alpha" else "Beta-v2.zip"
return [{"tag_name": "v2", "published_at": "2026-06-12T00:00:00Z", "assets": [{"name": name, "browser_download_url": f"https://example.invalid/{name}"}]}]
def download(url: str, destination: Path) -> dict[str, object]:
source = zip_a if "Alpha" in url else zip_b
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source.read_bytes())
return {"path": str(destination), "sha256": sha256_file(destination)}
run_update(
instance="1.40.8",
instance_path=instance,
registry=registry,
lockfile=lockfile,
lock_path=lock_path,
state_root=state,
repo_root=work,
selected={"alpha", "beta"},
fetch_releases=releases,
download_asset=download,
)
updated_lock = load_lockfile(lock_path)
self.assertEqual([plugin.id for plugin in updated_lock.plugins], ["alpha", "beta"])
self.assertEqual([plugin.asset for plugin in updated_lock.plugins], ["Alpha-v2.zip", "Beta-v2.zip"])
def test_update_cli_requires_plugin(self) -> None:
with patch("sys.stderr", new_callable=StringIO):
with self.assertRaises(SystemExit):
run(["update", "--instance", "1.40.8"])
def test_update_cli_passes_plugins_and_prints_json(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance_root = work / "instances"
instance = instance_root / "1.40.8"
state = work / "state"
locks = work / "locks"
registry = work / "registry"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
locks.mkdir()
registry.mkdir()
(locks / "1.40.8.lock.toml").write_text(
"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
[[plugins]]
id = "alpha"
repo = "owner/alpha"
tag = "v1"
asset = "Alpha.zip"
sha256 = "old"
""".lstrip(),
encoding="utf-8",
)
(registry / "plugins.toml").write_text(
"""
[[plugins]]
id = "alpha"
name = "Alpha"
repo = "owner/alpha"
asset_patterns = ["*.zip"]
install_strategy = "bsipa-zip"
""".lstrip(),
encoding="utf-8",
)
captured: dict[str, object] = {}
def fake_update(**kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return {
"instance": "1.40.8",
"beatSaberVersion": "1.40.8",
"lockfile": str(locks / "1.40.8.lock.toml"),
"updated": [{"plugin": "alpha"}],
"refused": [],
"downloads": [],
"planPath": str(state / "plan.json"),
"applied": [],
"dryRun": False,
}
with patch("plugin_helper.cli.repo_root", return_value=work):
with patch("plugin_helper.cli.run_update", side_effect=fake_update):
with patch("sys.stdout", new_callable=StringIO) as stdout:
status = run(
[
"--instances-root",
str(instance_root),
"--state-dir",
str(state),
"update",
"--instance",
"1.40.8",
"--plugin",
"alpha",
"--plugin",
"beta",
"--json",
]
)
self.assertEqual(status, 0)
self.assertEqual(captured["selected"], {"alpha", "beta"})
data = json.loads(stdout.getvalue())
self.assertEqual(data["lockfile"], str(locks / "1.40.8.lock.toml"))
self.assertEqual(data["updated"][0]["plugin"], "alpha")
def _make_tui_fixture(
root: Path,
@@ -1776,6 +2500,7 @@ sha256 = "{sha256_file(asset)}"
state,
"1.40.8",
{"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins},
install_id="test",
)
choice = InstallationChoice(
install_id="test",
@@ -1845,6 +2570,7 @@ instance = "1.40.8"
state,
"1.40.8",
{"instance": "1.40.8", "plugins": plugins_state, "disabledPlugins": {}},
install_id="test",
)
choice = InstallationChoice(
install_id="test",
@@ -1880,6 +2606,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2)
self.assertEqual(app.mode, "installations")
self.assertEqual(str(table.get_cell_at(Coordinate(0, 3))), "/tmp/state-linux/installs/linux")
self.assertEqual(str(table.get_cell_at(Coordinate(1, 3))), "/tmp/state-windows/installs/windows")
async def test_single_instance_skips_installation_picker(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
@@ -1904,7 +2632,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause()
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
updated = load_installed_state(state, "1.40.8")
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"])
@@ -1938,7 +2666,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8")
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated["disabledPlugins"])
@@ -1953,7 +2681,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8")
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated.get("disabledPlugins", {}))
@@ -1966,7 +2694,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause()
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
updated = load_installed_state(state, "1.40.8")
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"])
self.assertIn("Disabled 1 plugins", app.status_message)
@@ -1980,7 +2708,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8")
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated["disabledPlugins"])
self.assertIn("Enabled 1 plugins", app.status_message)
@@ -1995,7 +2723,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
from plugin_helper.state import load_known_good_state
known_good = load_known_good_state(state, "1.40.8")
known_good = load_known_good_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertEqual(known_good["pluginIds"], ["alpha", "beta"])
self.assertIn("Saved known-good set: 2 enabled plugins", app.status_message)
@@ -2017,7 +2745,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("Restored known-good set", app.status_message)
self.assertTrue((instance / "Plugins" / "Beta.dll").exists())
updated = load_installed_state(state, "1.40.8")
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertEqual(set(updated["plugins"]), {"alpha", "beta"})
async def test_restore_known_good_without_saved_set_reports_status(self) -> None:
@@ -2039,7 +2767,7 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
updated = load_installed_state(state, "1.40.8")
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated.get("disabledPlugins", {}))
self.assertIn("hash mismatch", app.status_message)