Merge branch 'main' of gitea.satstack.dev:pleb/beatsaber-plugin-helper
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: beatsaber-plugin-builder
|
||||
description: Build, test-compile, or package Beat Saber PC BSIPA plugin source on Linux from local checkouts, GitHub branches, or pull requests. Use when asked to build a Beat Saber plugin, compile a plugin PR, produce a DLL/zip artifact, configure BeatSaberDir/local refs for dotnet builds, or diagnose Linux build failures for net472 BSIPA projects.
|
||||
description: Build, test-compile, or package Beat Saber PC BSIPA plugin source on Linux from local checkouts, GitHub branches, or pull requests. Use when asked to build a Beat Saber plugin, compile a plugin PR, produce a DLL/zip artifact, configure BeatSaberDir/local refs for dotnet builds, or diagnose Linux build failures for .NET Framework BSIPA projects.
|
||||
---
|
||||
|
||||
# Build Beat Saber Plugin
|
||||
# Beat Saber Plugin Builder
|
||||
|
||||
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.
|
||||
|
||||
@@ -16,18 +16,25 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b
|
||||
```bash
|
||||
pwd
|
||||
git status --short
|
||||
sed -n '1,220p' plugin-helper.local.toml
|
||||
```
|
||||
|
||||
In `plugin-helper`, run commands from repo root and keep temporary source checkouts under `.state/build/<name>` unless the user asks for another location. Do not disturb unrelated dirty files.
|
||||
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.
|
||||
|
||||
2. Resolve source.
|
||||
|
||||
For a GitHub PR, clone or reuse a checkout under `.state/build`, add/fetch the upstream remote if needed, and check out the PR head:
|
||||
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:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<owner>/<repo>.git .state/build/<name>
|
||||
git -C .state/build/<name> fetch origin pull/<pr>/head:pr-<pr>
|
||||
git -C .state/build/<name> checkout pr-<pr>
|
||||
git clone https://github.com/<owner>/<repo>.git <state_dir>/build/<name>
|
||||
git -C <state_dir>/build/<name> fetch origin pull/<pr>/head:pr-<pr>
|
||||
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.
|
||||
@@ -43,14 +50,19 @@ 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`. Common local roots:
|
||||
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:
|
||||
|
||||
```text
|
||||
/home/pleb/.local/share/BSManager/BSInstances/<version>
|
||||
/home/pleb/Windows/Users/pleb/BSManager/BSInstances/<version>
|
||||
```bash
|
||||
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> instances
|
||||
```
|
||||
|
||||
Use a local `.csproj.user` or MSBuild properties rather than committing machine paths. For test builds, pass `-p:DisableCopyToPlugins=True` and, for BSMT projects that expose it, `-p:DisableCopyToGame=True` so compilation does not mutate the game install.
|
||||
Use a local `.csproj.user` or MSBuild properties rather than committing
|
||||
machine paths. For test builds, pass `-p:DisableCopyToPlugins=True` and, for
|
||||
BSMT projects that expose it, `-p:DisableCopyToGame=True` so compilation
|
||||
does not mutate the game install.
|
||||
|
||||
5. Restore and build.
|
||||
|
||||
@@ -61,9 +73,25 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b
|
||||
dotnet build <solution-or-project> -c Release -p:DisableCopyToPlugins=True -p:DisableCopyToGame=True
|
||||
```
|
||||
|
||||
If the project lacks .NET Framework reference assemblies on Linux, add or pass `Microsoft.NETFramework.ReferenceAssemblies.net472` as described in the reference file.
|
||||
If the project lacks .NET Framework reference assemblies on Linux, add or
|
||||
pass the package matching the project's target framework, usually
|
||||
`Microsoft.NETFramework.ReferenceAssemblies.net48` for current BSIPA
|
||||
projects, as described in the reference file.
|
||||
|
||||
6. Collect artifacts.
|
||||
6. Verify Beat Saber API changes before patching gameplay code.
|
||||
|
||||
When a Beat Saber upgrade breaks a type/member reference, first determine
|
||||
whether the API moved assemblies before substituting another type. Inspect
|
||||
the target game DLLs and nearby mod sources:
|
||||
|
||||
```bash
|
||||
strings "<BeatSaberDir>/Beat Saber_Data/Managed/Main.dll" | rg 'TypeOrMemberName'
|
||||
strings "<BeatSaberDir>/Beat Saber_Data/Managed/HMLib.dll" | rg 'TypeOrMemberName'
|
||||
ilspycmd -t TypeName "<BeatSaberDir>/Beat Saber_Data/Managed/Main.dll" | sed -n '1,220p'
|
||||
rg -n 'TypeOrMemberName|NearbyConcept' ~/src/<owner>/<repo> ~/src/Auros/SiraUtil ~/src/nike4613/BeatSaber-IPA-Reloaded
|
||||
```
|
||||
|
||||
7. Collect artifacts.
|
||||
|
||||
Find produced DLLs and release zips:
|
||||
|
||||
@@ -71,23 +99,27 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b
|
||||
find <checkout> -path '*/bin/*' \( -name '*.dll' -o -name '*.zip' \) -print
|
||||
```
|
||||
|
||||
Verify the DLL name, version, and manifest. If the result is meant for `plugin-helper`, place a copy under `.state/instances/<instance>/downloads/<plugin-id>/` and use the helper plan/apply workflow rather than hand-copying into a BSManager instance.
|
||||
Verify the DLL name, version, and manifest. If the result is meant for
|
||||
`plugin-helper`, place a copy under
|
||||
`<state_dir>/instances/<instance>/downloads/<plugin-id>/` for the selected
|
||||
profile and use the helper plan/apply workflow rather than hand-copying into
|
||||
a BSManager instance.
|
||||
|
||||
7. Validate.
|
||||
8. Validate.
|
||||
|
||||
For skill edits inside this repo, run:
|
||||
|
||||
```bash
|
||||
python /home/pleb/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/beatsaber-plugin-builder
|
||||
PYTHONPATH=src python -m compileall -q src tests
|
||||
PYTHONPATH=src python -m unittest discover -s tests
|
||||
PYTHONPATH=src .venv/bin/python -m compileall -q src tests
|
||||
PYTHONPATH=src .venv/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
For a plugin build, at minimum report the exact `dotnet build` result and artifact paths. For live game validation, use `docs/SMOKETEST.md` and tear down Beat Saber processes afterward.
|
||||
|
||||
## Failure Triage
|
||||
|
||||
- Missing `Microsoft.NETFramework.ReferenceAssemblies`: add the net472 reference-assemblies package or pass an equivalent MSBuild/package restore fix.
|
||||
- Missing `Microsoft.NETFramework.ReferenceAssemblies`: add the package matching the target framework, usually `Microsoft.NETFramework.ReferenceAssemblies.net48` for current projects, or pass an equivalent MSBuild/package restore fix.
|
||||
- Missing `Main.dll`, `HMUI.dll`, `IPA.Loader.dll`, `BSML.dll`, `SongCore.dll`, or similar: `BeatSaberDir` points at the wrong/unmodded instance, or dependencies are absent from `Plugins/`/`Libs/`.
|
||||
- BSMT copies to `IPA/Pending` or `Plugins` during build: rebuild with `-p:DisableCopyToPlugins=True -p:DisableCopyToGame=True` unless the user explicitly wants deployment.
|
||||
- NuGet package restore fails because a source is missing: inspect `NuGet.config` and installed package sources; use repo-local configuration where possible.
|
||||
|
||||
@@ -6,7 +6,8 @@ This workflow comes from `/home/pleb/ops/beatsaber/setlist/docs/pc-modding.md` a
|
||||
|
||||
## Toolchain
|
||||
|
||||
- PC BSIPA plugins are usually .NET Framework `net472` class libraries.
|
||||
- PC BSIPA plugins are .NET Framework class libraries; current projects
|
||||
commonly target `net48`, while older projects may still target `net472`.
|
||||
- Linux `dotnet` SDK 6+ can build them because output DLLs are platform-agnostic CIL loaded by Beat Saber under Proton.
|
||||
- `BeatSaberModdingTools.Tasks` supplies the MSBuild targets normally driven by Visual Studio/Rider BSMT extensions.
|
||||
- On this host, `dotnet --list-sdks` should show a usable SDK. NuGet is available for package inspection.
|
||||
@@ -24,16 +25,13 @@ Plugins/
|
||||
winhttp.dll
|
||||
```
|
||||
|
||||
Preferred local managed instance root:
|
||||
In `plugin-helper`, prefer the profile selected from
|
||||
`plugin-helper.local.toml` as the source of truth for the managed instance root
|
||||
and state directory:
|
||||
|
||||
```text
|
||||
/home/pleb/.local/share/BSManager/BSInstances/<version>
|
||||
```
|
||||
|
||||
Windows mirror root:
|
||||
|
||||
```text
|
||||
/home/pleb/Windows/Users/pleb/BSManager/BSInstances/<version>
|
||||
```bash
|
||||
sed -n '1,220p' plugin-helper.local.toml
|
||||
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> instances
|
||||
```
|
||||
|
||||
Use `BeatSaberVersion.txt` for the exact game version. The manifest `gameVersion` normally uses the `major.minor.patch` prefix, not the build suffix.
|
||||
@@ -45,7 +43,7 @@ Prefer machine-local configuration in `<Project>.csproj.user`:
|
||||
```xml
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<BeatSaberDir>/home/pleb/.local/share/BSManager/BSInstances/1.40.8</BeatSaberDir>
|
||||
<BeatSaberDir>/path/from/selected/profile/instances_root/1.44.1</BeatSaberDir>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
```
|
||||
@@ -60,7 +58,10 @@ For those, pass `-p:LocalRefsDir=/path/to/instance` or add a local `.csproj.user
|
||||
|
||||
When changing a project file for Linux portability, prefer the smallest explicit fix:
|
||||
|
||||
- Add `Microsoft.NETFramework.ReferenceAssemblies.net472` when MSBuild reports missing .NET Framework reference assemblies.
|
||||
- Add the `Microsoft.NETFramework.ReferenceAssemblies.*` package matching the
|
||||
project target framework, usually `Microsoft.NETFramework.ReferenceAssemblies.net48`
|
||||
for current projects, when MSBuild reports missing .NET Framework reference
|
||||
assemblies.
|
||||
- Set or pass `DisableCopyToPlugins=True` for artifact-only builds.
|
||||
- Keep hint paths rooted at `$(BeatSaberDir)` where possible.
|
||||
- Do not add broad multi-version compatibility logic unless requested.
|
||||
@@ -105,7 +106,9 @@ If the purpose is to install the built artifact, copy it into plugin-helper's st
|
||||
|
||||
## Common Reference Failures
|
||||
|
||||
- `MSB3644` or missing `.NETFramework,Version=v4.7.2` reference assemblies: add `Microsoft.NETFramework.ReferenceAssemblies.net472`.
|
||||
- `MSB3644` or missing `.NETFramework,Version=v4.x` reference assemblies: add
|
||||
the matching `Microsoft.NETFramework.ReferenceAssemblies.*` package, usually
|
||||
`Microsoft.NETFramework.ReferenceAssemblies.net48` for current projects.
|
||||
- Missing game assemblies such as `Main.dll`, `HMUI.dll`, `UnityEngine.CoreModule.dll`: `BeatSaberDir` is wrong or incomplete.
|
||||
- Missing mod dependencies such as `BSML.dll`, `SongCore.dll`, `SiraUtil.dll`, `BeatSaberPlaylistsLib.dll`: install or point at an instance containing those plugins, or fetch the dependency DLL from its verified release only when appropriate.
|
||||
- `IPA.Loader.dll` missing: BSIPA is not bootstrapped in that instance.
|
||||
@@ -115,17 +118,17 @@ If the purpose is to install the built artifact, copy it into plugin-helper's st
|
||||
For a built plugin DLL intended for a managed instance:
|
||||
|
||||
```bash
|
||||
mkdir -p .state/instances/<instance>/downloads/<plugin-id>
|
||||
cp <checkout>/<path>/bin/Release/<Plugin>.dll .state/instances/<instance>/downloads/<plugin-id>/<Plugin>.dll
|
||||
sha256sum .state/instances/<instance>/downloads/<plugin-id>/<Plugin>.dll
|
||||
mkdir -p <state_dir>/instances/<instance>/downloads/<plugin-id>
|
||||
cp <checkout>/<path>/bin/Release/<Plugin>.dll <state_dir>/instances/<instance>/downloads/<plugin-id>/<Plugin>.dll
|
||||
sha256sum <state_dir>/instances/<instance>/downloads/<plugin-id>/<Plugin>.dll
|
||||
```
|
||||
|
||||
Then update registry/lock data only if the user asked to manage/install the artifact, and use:
|
||||
|
||||
```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 <plan-path>
|
||||
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> check --instance <instance>
|
||||
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> plan --instance <instance> --plugin <plugin-id>
|
||||
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> apply <plan-path>
|
||||
```
|
||||
|
||||
Inspect the generated plan before applying.
|
||||
|
||||
@@ -3,12 +3,9 @@ name: beatsaber-plugin-manager
|
||||
description: Install or update a Beat Saber plugin in the plugin-helper repo by using the local helper workflow. Use when the user asks to add, install, update, bump, lock, bootstrap BSIPA, or manage a Beat Saber plugin release for a BSManager instance. Prefer upstream GitHub release artifacts for normal plugins; use BeatMods primarily as compatibility/dependency metadata, with CDN artifacts only for inaccessible upstream assets, BeatMods-only packages, or framework/library dependencies.
|
||||
---
|
||||
|
||||
# Install Beat Saber Plugin
|
||||
# Beat Saber Plugin Installer
|
||||
|
||||
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation. Do not manually copy release files into the game instance except:
|
||||
|
||||
- to bootstrap BSIPA/core packages before the helper has a first-class bootstrap command
|
||||
- to undo your own mistaken install before rerunning the helper
|
||||
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation.
|
||||
|
||||
## Hard Guardrail
|
||||
|
||||
@@ -37,14 +34,6 @@ 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.
|
||||
|
||||
Accepted URL shapes include:
|
||||
|
||||
```text
|
||||
https://github.com/<owner>/<repo>/releases
|
||||
https://github.com/<owner>/<repo>/releases/tag/<tag>
|
||||
https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm the workspace is the `plugin-helper` repo.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/.state/
|
||||
/.state-*/
|
||||
/plugin-helper.local.toml
|
||||
/plugin-helper.windows.toml
|
||||
/.pytest_cache/
|
||||
/build/
|
||||
/dist/
|
||||
@@ -7,3 +9,4 @@
|
||||
/src/*.egg-info/
|
||||
/__pycache__/
|
||||
*.pyc
|
||||
.venv
|
||||
|
||||
@@ -6,13 +6,18 @@ Guidance for coding agents working in this repo.
|
||||
|
||||
- This repo manages Beat Saber plugins for BSManager instances.
|
||||
- Default instance roots are:
|
||||
- `/home/pleb/Windows/Users/pleb/BSManager/BSInstances`
|
||||
- `/home/pleb/.local/share/BSManager/BSInstances`
|
||||
- `~/Windows/Users/pleb/BSManager/BSInstances`
|
||||
- `~/.local/share/BSManager/BSInstances`
|
||||
- A local BSManager source checkout may be available at
|
||||
`/home/pleb/src/Zagrios/bs-manager`. Use it as a read-only reference when
|
||||
`~/src/Zagrios/bs-manager`. Use it as a read-only reference when
|
||||
investigating launch behavior, inherited Steam arguments, instance layout, or
|
||||
Proton environment details unless the user explicitly asks for BSManager code
|
||||
changes.
|
||||
- Keep plugin source checkouts under `~/src/<owner>/<repo>` when a
|
||||
locked or registry plugin has a GitHub source repo. Prefer checking out the
|
||||
upstream author/repo first, with `origin` pointing at upstream. If the user has
|
||||
an existing personal fork checkout, preserve it as a remote named `github`
|
||||
and set/add `origin` to the upstream repo instead of replacing local work.
|
||||
- Prefer repo-local state for planned installs unless the task explicitly
|
||||
targets the user's live default state. Use `--state-dir .state` for the local
|
||||
Linux install and `--state-dir .state-windows` for the mounted Windows install
|
||||
@@ -21,31 +26,55 @@ Guidance for coding agents working in this repo.
|
||||
## Workflow Rules
|
||||
|
||||
- Run commands from the repo root with `PYTHONPATH=src`.
|
||||
- A repo-local Python virtualenv is normally available at `.venv`; prefer
|
||||
`.venv/bin/python` for helper commands and tests when it exists.
|
||||
- For human-style inspection, prefer the menu with repo-local state:
|
||||
`PYTHONPATH=src python -m plugin_helper --state-dir .state menu`.
|
||||
`PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state menu`.
|
||||
- When targeting the local Linux BSManager install, pass
|
||||
`--instances-root /home/pleb/.local/share/BSManager/BSInstances` and normally
|
||||
`--instances-root ~/.local/share/BSManager/BSInstances` and normally
|
||||
`--state-dir .state`.
|
||||
- When targeting the mounted Windows BSManager install, pass
|
||||
`--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances` and
|
||||
`--instances-root ~/Windows/Users/pleb/BSManager/BSInstances` and
|
||||
normally `--state-dir .state-windows`.
|
||||
- Use the helper commands instead of manually copying plugin files into an
|
||||
instance.
|
||||
- When adding, updating, building, or investigating a GitHub-hosted plugin,
|
||||
check for `~/src/<owner>/<repo>` and clone the upstream repo there if
|
||||
it is missing. Do not substitute forks or similar repos without explicit user
|
||||
direction.
|
||||
- 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.
|
||||
- Be careful with duplicate instance names across Windows and local roots. Use
|
||||
the menu or pass `--instances-root` explicitly when targeting one install, and
|
||||
keep install/bootstrap state separate per target root.
|
||||
- After completing repo changes, suggest a concise commit message in the final
|
||||
response unless the user already asked you to commit.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run `PYTHONPATH=src python -m unittest discover -s tests` after code changes.
|
||||
- Run `PYTHONPATH=src python -m compileall -q src tests` for syntax/import
|
||||
checks.
|
||||
- Run `PYTHONPATH=src .venv/bin/python -m unittest discover -s tests` after
|
||||
code changes when `.venv` exists; otherwise use `python`.
|
||||
- Run `PYTHONPATH=src .venv/bin/python -m compileall -q src tests` for
|
||||
syntax/import checks when `.venv` exists; otherwise use `python`.
|
||||
- For live game validation, follow `docs/SMOKETEST.md` and tear down Beat Saber
|
||||
processes afterward.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- When the user asks for a PR against an upstream plugin, compose and submit a
|
||||
polite pull request using the user's existing `gh` session.
|
||||
- Keep upstream PRs small, focused, and easy for the maintainer to verify. Favor
|
||||
narrow compatibility fixes, clear commit messages, and minimal formatting or
|
||||
project-file churn.
|
||||
- Work from the upstream checkout under `~/src/<owner>/<repo>` when available.
|
||||
If the upstream remote is not writable, create or reuse the user's fork with
|
||||
`gh repo fork`, push a topic branch there, and open the PR against upstream.
|
||||
- Include concise verification notes in the PR body, especially the exact build
|
||||
or smoke-test command and any generated artifact name. Mention local-only
|
||||
build configuration separately if it was needed, and do not commit machine
|
||||
paths or helper state unless the user explicitly asks for it.
|
||||
|
||||
## Launch Notes
|
||||
|
||||
- BSManager may inherit Beat Saber launch arguments configured in Steam.
|
||||
|
||||
@@ -11,76 +11,93 @@ The first implementation focuses on safe local workflows:
|
||||
- apply exactly that plan and record install state
|
||||
- uninstall only files recorded in install state
|
||||
|
||||
Default BSManager instance roots:
|
||||
Default BSManager instance root:
|
||||
|
||||
```text
|
||||
/home/pleb/Windows/Users/pleb/BSManager/BSInstances
|
||||
/home/pleb/.local/share/BSManager/BSInstances
|
||||
```
|
||||
|
||||
Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. To search
|
||||
multiple explicit roots, separate them with `:`.
|
||||
|
||||
## Managing Multiple Installs
|
||||
|
||||
The helper is intended to manage both the local Linux BSManager install and the
|
||||
mounted Windows install. Lockfiles and registry entries are shared by Beat Saber
|
||||
version, but install state is target-specific. When the same instance name
|
||||
exists under both roots, such as `1.44.1`, use an explicit `--instances-root`
|
||||
and a separate state directory for each target.
|
||||
|
||||
Suggested repo-local convention:
|
||||
Default plugin-helper state directory:
|
||||
|
||||
```text
|
||||
.state/ local Linux BSManager state
|
||||
.state-windows/ mounted Windows BSManager state
|
||||
$XDG_STATE_HOME/plugin-helper
|
||||
```
|
||||
|
||||
Examples:
|
||||
If `XDG_STATE_HOME` is not set, the state directory defaults to:
|
||||
|
||||
```text
|
||||
~/.local/state/plugin-helper
|
||||
```
|
||||
|
||||
Override the instance root with `--instances-root`,
|
||||
`PLUGIN_HELPER_INSTANCES_ROOT`, or `plugin-helper.local.toml`. To search
|
||||
multiple explicit roots, separate them with `:`.
|
||||
|
||||
Override the state directory with `--state-dir`, `PLUGIN_HELPER_STATE_DIR`, or
|
||||
`plugin-helper.local.toml`.
|
||||
|
||||
## Local Configuration
|
||||
|
||||
This checkout is intended to manage the local Linux BSManager install. If you
|
||||
also manage a Windows install, use a separate clone on that partition and point
|
||||
both clones at the same state directory only when you intentionally want one
|
||||
shared source of truth.
|
||||
|
||||
Copy the example config and adjust paths if needed:
|
||||
|
||||
|
||||
```sh
|
||||
PYTHONPATH=src python -m plugin_helper \
|
||||
--instances-root /home/pleb/.local/share/BSManager/BSInstances \
|
||||
--state-dir .state \
|
||||
installed --instance 1.44.1
|
||||
|
||||
PYTHONPATH=src python -m plugin_helper \
|
||||
--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances \
|
||||
--state-dir .state-windows \
|
||||
installed --instance 1.44.1
|
||||
cp plugin-helper.toml.example plugin-helper.local.toml
|
||||
```
|
||||
|
||||
Do not reuse the same state directory for both targets when their instance names
|
||||
match. The current state layout is keyed by instance name, so sharing one state
|
||||
directory would mix bootstrap records, generated plans, backups, and installed
|
||||
file records for different game trees.
|
||||
`plugin-helper.local.toml` is ignored by git and uses top-level fields:
|
||||
|
||||
```toml
|
||||
instances_root = "~/.local/share/BSManager/BSInstances"
|
||||
state_dir = "~/.local/state/plugin-helper"
|
||||
```
|
||||
|
||||
For repo-local state, set:
|
||||
|
||||
```toml
|
||||
state_dir = ".state"
|
||||
```
|
||||
|
||||
For a shared Windows-partition state directory, set the same `state_dir` in both
|
||||
clones, for example:
|
||||
|
||||
```toml
|
||||
state_dir = "~/Windows/Users/pleb/ops/plugin-helper/.state"
|
||||
```
|
||||
|
||||
CLI flags override environment variables, environment variables override local
|
||||
config, and local config overrides built-in defaults.
|
||||
|
||||
## Commands
|
||||
|
||||
For normal use, run the menu from the repo root. Use repo-local state so the
|
||||
menu sees the same plans, downloads, and install records used by the helper
|
||||
workflow:
|
||||
For normal use, run the Textual menu from the repo root:
|
||||
|
||||
```sh
|
||||
PYTHONPATH=src python -m plugin_helper --state-dir .state menu
|
||||
PYTHONPATH=src python -m plugin_helper
|
||||
```
|
||||
|
||||
That is equivalent to `PYTHONPATH=src python -m plugin_helper menu` when run
|
||||
from an interactive terminal.
|
||||
|
||||
The menu reads `plugin-helper.local.toml` when present, shows each discovered
|
||||
Beat Saber install with its resolved state directory, and lets you toggle
|
||||
managed plugins with arrow keys and Space. In the plugin table, use `d` to
|
||||
disable all currently enabled managed plugins and `e` to enable all currently
|
||||
disabled managed plugins.
|
||||
|
||||
The individual subcommands are mostly for automation and debugging. If you use
|
||||
them, pass `--state-dir .state` unless you intentionally want the default live
|
||||
state outside this repo or are intentionally targeting the Windows install with
|
||||
`.state-windows`.
|
||||
them, pass `--state-dir` directly only when you intentionally want to override
|
||||
the configured state directory for one command.
|
||||
|
||||
Install assets are currently expected to already exist locally, usually under:
|
||||
|
||||
```text
|
||||
.state/instances/<instance>/downloads/<plugin-id>/
|
||||
```
|
||||
|
||||
For a second target-specific state directory, copy or re-download the same
|
||||
locked assets under that state root before planning. For example:
|
||||
|
||||
```text
|
||||
.state-windows/instances/<instance>/downloads/<plugin-id>/
|
||||
<state-dir>/instances/<instance>/downloads/<plugin-id>/
|
||||
```
|
||||
|
||||
## Beat Saber Data Backups
|
||||
|
||||
@@ -151,11 +151,11 @@ Purpose: restore in-game song discovery and playlist management.
|
||||
| Plugin | Upstream | Status | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| BeatSaverDownloader | [beatmods zip](https://beatmods.com/cdn/mod/a740c6e68a9b5d1dfda3cc8e81f7cf06.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 6.0.7, version id 2217, zipHash `a740c6e68a9b5d1dfda3cc8e81f7cf06`; BeatMods preferred repo `Top-Cat/BeatSaverDownloader` exposes no release assets through the GitHub releases API | IPA loaded BeatSaver Downloader 6.0.7 and started its internal webserver. Warning: it probed for missing `BetterSongList.dll` with IPA library-loader `CRITICAL` lines, then continued. |
|
||||
| PlaylistManager | [github](https://github.com/rithik-b/PlaylistManager) | <span style="color:#3fb950; font-weight:600">verified</span> | Local PR82 build from `.state/build/playlistmanager-pr82-skilltest`, artifact `PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip`; replaces failed BeatMods 1.7.3 compatibility trial | IPA loaded PlaylistManager 1.7.4, installed `PlaylistManagerAppInstaller`, and reached `MainSystemInit` during smoketest. The old `IPlatformUserModel` / `PlatformUserModel` failure did not recur. |
|
||||
| PlaylistManager | [github](https://github.com/rithik-b/PlaylistManager) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | Local PR82 build from `.state/build/playlistmanager-pr82-skilltest`, artifact `PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip`; removed from the 1.44.1 lock after DiTails smoketest failure | IPA loaded PlaylistManager 1.7.4, but later smoketest coverage showed it was still incompatible with this stack. Removed from both live BS installs and do not reinstall until a compatible build is available. |
|
||||
| BeatSaverUpdater | [github](https://github.com/ibillingsley/BeatSaverUpdater) | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `ibillingsley/BeatSaverUpdater` tag `1.2.11`, asset `BeatSaverUpdater-1.2.11-bs1.39.1-3698f98.zip`; BeatMods version id 2352, zipHash `d9ea8dd0cbaac66cbb02fa59a548e42b` | GitHub asset is byte-identical to the BeatMods CDN zip. IPA loaded BeatSaverUpdater 1.2.11. |
|
||||
| BeatSaverVoting | [beatmods zip](https://beatmods.com/cdn/mod/bc002ed1a43e2c6d3a10d0750e5d94b4.zip) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | BeatMods 2.4.6, version id 2159, zipHash `bc002ed1a43e2c6d3a10d0750e5d94b4`; most recent blessed entry found was for Beat Saber 1.40.8, with no BeatMods verified entry for 1.44.1, 1.44.0, 1.43.0, 1.42.0, or 1.41.1 | IPA discovered and loaded BeatSaverVoting 2.4.6, but BS Utils caught a menu event failure from `BeatSaverVoting` caused by `TypeLoadException` resolving `IPlatformUserModel` from `PlatformUserModel`. Removed from the live instance after the failed smoketest. |
|
||||
| BeatSaberPlaylistsLib | [beatmods zip](https://beatmods.com/cdn/mod/a3418b75ed7294a3856f3eca12bbd672.zip) | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 1.7.2, version id 2175, zipHash `a3418b75ed7294a3856f3eca12bbd672`; BeatMods preferred repo `Meivyn/BeatSaberPlaylistsLib` exposes no release assets through the GitHub releases API | IPA loaded BeatSaberPlaylistsLib 1.7.2. |
|
||||
| BeatSaverSharp | [beatmods zip](https://beatmods.com/cdn/mod/be37e13e93d9ac7da4efbdc3f514fa8f.zip) | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 3.4.5, version id 1831, zipHash `be37e13e93d9ac7da4efbdc3f514fa8f`; BeatMods preferred repo `lolPants/BeatSaverSharp` was inaccessible through the GitHub releases API | IPA loaded BeatSaverSharp 3.4.5. |
|
||||
| BeatSaverSharp | [github](https://github.com/Auros/BeatSaverSharper), [beatmods zip](https://beatmods.com/cdn/mod/be37e13e93d9ac7da4efbdc3f514fa8f.zip) | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 3.4.5, version id 1831, zipHash `be37e13e93d9ac7da4efbdc3f514fa8f`; source repo updated to `Auros/BeatSaverSharper`; locked package remains the BeatMods CDN artifact | IPA loaded BeatSaverSharp 3.4.5. |
|
||||
| ScoreSaberSharp | [beatmods zip](https://beatmods.com/cdn/mod/8713168c598577ee7c73fa3cf0e26f5c.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 0.1.0, version id 445, zipHash `8713168c598577ee7c73fa3cf0e26f5c`; BeatMods lists `scoresaber.com` rather than a GitHub release source | IPA loaded ScoreSaberSharp 0.1.0. Warning: bare manifest does not declare files. |
|
||||
| BS Utils | [beatmods zip](https://beatmods.com/cdn/mod/918d13ac2821a3a17b2819f8861453e9.zip) | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 1.14.3, version id 2563, zipHash `918d13ac2821a3a17b2819f8861453e9`; BeatMods preferred repo `Kylemc1413/Beat-Saber-Utils` exposes no matching 1.14.3 GitHub release asset | IPA loaded BS Utils 1.14.3. |
|
||||
| Ini Parser | [beatmods zip](https://beatmods.com/cdn/mod/5df74ad1c6b120fecdc615dd55f15b88.zip) | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 2.5.9, version id 1352, zipHash `5df74ad1c6b120fecdc615dd55f15b88` | IPA loaded INI Parser 2.5.9. |
|
||||
@@ -188,8 +188,8 @@ Purpose: add small gameplay helpers two or three at a time.
|
||||
| HitsoundTweaks | [github](https://github.com/GalaxyMaster2/HitsoundTweaks) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | GitHub `GalaxyMaster2/HitsoundTweaks` tag `v1.1.9`, asset `HitsoundTweaks-1.1.9-bs1.40.3-4ad8461.zip`; GitHub release digest matched downloaded asset | IPA loaded HitsoundTweaks 1.1.9, but SiraUtil failed to apply the `AudioTimeSyncController_dspTimeOffset_Patch` affinity patch with `InvalidProgramException`. Removed from the live instance after the failed smoke. BeatMods verified search on 2026-06-29 found no `HitsoundTweaks` entry for Beat Saber 1.42.0, 1.42.1, 1.43.0, 1.44.0, or 1.44.1. |
|
||||
| KeepMyOverridesPls | [github](https://github.com/qqrz997/KeepMyOverridesPls) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `qqrz997/KeepMyOverridesPls` tag `v1.1.3-b`, asset `KeepMyOverridesPls-1.1.3-bs1.40.6-487d417.zip`; GitHub release digest matched downloaded asset | IPA loaded KeepMyOverridesPls 1.1.3, installed its app installer, wrote config, and the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.40.6 and plugin has no start/exit methods. |
|
||||
| SoundReplacer | [github](https://github.com/Meivyn/SoundReplacer), [beatmods zip](https://beatmods.com/cdn/mod/7d7a869996e10249d1f85f95e060319b.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 2.0.1, version id 2213, zipHash `7d7a869996e10249d1f85f95e060319b`; GitHub releases API returned no releases on 2026-06-29 | IPA loaded SoundReplacer 2.0.1, installed its app and menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.29.4. |
|
||||
| KeyRemapper | [github](https://github.com/lyyQwQ/KeyRemapper) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `lyyQwQ/KeyRemapper` tag `0.3.0`, asset `KeyRemapper-0.3.0-bs1.39.1-8e4c11a.zip`; GitHub release digest matched downloaded asset | IPA loaded KeyRemapper 0.3.0, initialized config, installed menu bindings, registered the Key Remapper button, and the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.39.1 and FPFC smoke logged dummy input manager because runtime was null. |
|
||||
| SquatToBegin | [github](https://github.com/kinsi55/BeatSaber_SquatToBegin) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `kinsi55/BeatSaber_SquatToBegin` tag `v0.0.7`, asset `SquatToBegin.dll`; GitHub release digest matched downloaded asset | IPA loaded SquatToBegin 0.0.7 and the game reached `MainSystemInit`. Warnings: manifest reports Beat Saber 1.20.0 despite the GitHub release being labeled for 1.39.1+, and plugin has no start/exit methods. Verify squat gate behavior outside FPFC when practical. |
|
||||
| KeyRemapper | [github](https://github.com/lyyQwQ/KeyRemapper), [pr](https://github.com/lyyQwQ/KeyRemapper/pull/6) | <span style="color:#f85149; font-weight:600">currently failed</span> | Local build from `/home/pleb/src/lyyQwQ/KeyRemapper` commit `23bb523`, asset `KeyRemapper-0.3.0-bs1.44.0-8e4c11a.zip`, SHA-256 `b8aaaa72712ae10cec98f51f79b3b87fe7a588b0d3393dd087b7d84975246e6c`; PR 6 retargets the pause/menu Affinity patch from removed `UnityXRHelper` to `DevicelessVRHelper` and sets manifest gameVersion to 1.44.0. PR 6 was converted to draft after the failed manual smoke. | Replaces the prior GitHub `0.3.0` Beat Saber 1.39.1 asset. Helper installed `Plugins/KeyRemapper.dll` SHA-256 `07839dd28fa4bfbd88a3b72a8f77ebd22e70ac5acdc1239c47b9deabf775d8cd` and `Plugins/KeyRemapper.pdb` SHA-256 `e118c71519b632027f958d902d5ebcd9efb4851530091639cdbfaa96d43d4cb8`. User manual smoke on 2026-07-01 found the configured `UserData/KeyRemapper.json` pause bindings (`L_X`, `L_Y`, `R_A`, `R_B`) were not remapped. Windows logs show KeyRemapper loaded and read config without a KeyRemapper exception; `2026.07.01.09.53.35.log.gz` found both controllers and installed `KeyRemapper.Installers.GameplayInstaller`, but gameplay then flooded `MissingMethodException: AudioTimeSyncController/InitData .AudioTimeSyncController.get_state()`. TODO: add explicit logging around `GameplayInstaller`'s `IVRPlatformHelper.vrPlatformSDK == VRPlatformSDK.OpenXR` gate and verify whether that gate prevents `RemapBaseGameMenuButton`/`RestartHandler` from binding despite `UnityXRInputManager` being selected from `XRSettings.loadedDeviceName`. |
|
||||
| SquatToBegin | [github](https://github.com/kinsi55/BeatSaber_SquatToBegin) | <span style="color:#d29922; font-weight:600">installed; manual resmoke pending</span> | Local build from `/home/pleb/src/kinsi55/BeatSaber_SquatToBegin` at commit `8703b84` plus local 1.44 compatibility fixes; asset `SquatToBegin.dll`, SHA-256 `0bd6b5ee48cae370f255707afb9e452e08a68802f81493dd9a442b6a9f7b98bd`. BeatMods verified search on 2026-07-01 found no SquatToBegin entry for Beat Saber 1.44.1, 1.44.0, 1.43.0, 1.42.1, 1.42.0, 1.40.0, 1.39.1, or 1.20.0. | Replaces the prior GitHub `v0.0.7` DLL and the first local build. IPA loaded SquatToBegin 0.0.7, but the automated smoke did not reach the menu because Steam platform initialization failed. User manual smoke on 2026-07-01 found the squat gate did not arm and songs started immediately. Follow-up build keeps the 1.44 `IAudioTimeSource.State.Playing` fix and arms the gate from gameplay scene setup instead of relying on the stale level-selection prefix; verify VR behavior manually. |
|
||||
| JDFixer | [github](https://github.com/zeph-yr/JDFixer), [pr](https://github.com/zeph-yr/JDFixer/pull/26) | <span style="color:#d29922; font-weight:600">installed; smoke blocked</span> | Local build from PR 26 commit `3fce6ce465911bdd5e8e00411bc4672c54a317f7`, asset `JDFixer.dll`; SHA-256 `16b7dad9906d838dab40ce48a9b304be4847f18e700ddd31f2293d1065f4529d` | IPA loaded JDFixer 7.4.0 and the prior `OnEnable` Harmony failure did not recur; JDFixer logged config/donate activity. The smoke did not reach the main menu because Steam platform initialization failed (`SteamAPI Init failed`, Steam likely not running). Replaces failed GitHub `zeph-yr/JDFixer` tag `v.7.4.0`, asset `JDFixer.dll`, SHA-256 `a83ae3f68921a9698616ecd89d08b7397a550c2464a7871b6c65506ce0c7d360`; that release loaded JDFixer 7.4.0, but `OnEnable` failed with a Harmony patching exception because `StandardLevelScenesTransitionSetupDataSOPatch::TargetMethod()` returned null. |
|
||||
|
||||
### Batch 6: UI and Song Browser
|
||||
@@ -200,7 +200,7 @@ Purpose: restore song-list, menu, and visualization conveniences.
|
||||
| --- | --- | --- | --- | --- |
|
||||
| BetterSongList | [github](https://github.com/kinsi55/BeatSaber_BetterSongList) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `kinsi55/BeatSaber_BetterSongList` tag `v0.4.3`, asset `BetterSongList.dll`; GitHub release digest matched downloaded asset | IPA loaded BetterSongList 0.4.3 and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. |
|
||||
| HitScoreVisualizer | [github](https://github.com/ErisApps/HitScoreVisualizer) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `ErisApps/HitScoreVisualizer` tag `3.7.3`, asset `HitScoreVisualizer-3.7.3-bs1.42.0-a565cbb.zip`; GitHub release digest matched downloaded asset | IPA loaded HitScoreVisualizer 3.7.3, installed app/menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. |
|
||||
| DiTails | [github](https://github.com/Auros/DiTails) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | GitHub `Auros/DiTails` tag `1.1.3`, asset `DiTails-v1.1.3-g1.42.0-271d394.zip`; GitHub release digest matched downloaded asset; BeatMods also verifies DiTails 1.1.3 for 1.44.1 as version id 2609, zipHash `437904f6db78a2ee928738d7d254a93f` | IPA loaded DiTails 1.1.3, but menu initialization failed in `DiTails.Managers.DetailContextManager.Initialize()` with `NullReferenceException`. Removed from the live instance after the failed smoke. |
|
||||
| DiTails | [github](https://github.com/Auros/DiTails) | <span style="color:#d29922; font-weight:600">installed; smoke blocked</span> | Local build from `/home/pleb/src/Auros/DiTails` commit `601e3c4` on branch `fix-1.44-artwork-initialization`, asset `DiTails-1.1.3-bs1.44.1-601e3c4.zip`, SHA-256 `b8735f24545f1a50392865bf3013b930bee8e4ba7feac824a2482e2f1deeba1e`; replaces failed GitHub `Auros/DiTails` tag `1.1.3`, asset `DiTails-v1.1.3-g1.42.0-271d394.zip` and BeatMods 1.1.3 trial | Helper installed `Plugins/DiTails.dll` SHA-256 `5c856ffdfeab54982b84cb2e3033c970622668c553396460499d2343363d1d9d` in the mounted Windows instance. IPA loaded DiTails 1.1.3 and the previous `DetailContextManager.Initialize()` `NullReferenceException` did not recur before startup was blocked by `SteamAPI Init failed` because Steam was not running. |
|
||||
| HideTheLogo | [github](https://github.com/TheBlackParrot/HideTheLogo) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `TheBlackParrot/HideTheLogo` tag `1.0.3`, asset `HideTheLogo-1.0.3-bs1.40.3-c968d91.zip` | IPA loaded HideTheLogo 1.0.3, logged `yeet`, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.40.3. |
|
||||
| SongChartVisualizer | [github](https://github.com/NuggoDEV/SongChartVisualizer), [beatmods zip](https://beatmods.com/cdn/mod/5d3fc025fe098277667fc0846e1b8fe3.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 1.1.11, version id 2249, zipHash `5d3fc025fe098277667fc0846e1b8fe3`; GitHub releases API returned no releases on 2026-06-29 | IPA loaded SongChartVisualizer 1.1.11, installed app/menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.39.1. |
|
||||
| Setlist | | <span style="color:#f85149; font-weight:600">skipped after local-build trial</span> | Local build from `/home/pleb/ops/beatsaber/setlist` commit `14d21ad` with working tree modifications; first SHA-256 `01ecba3cfa697488faddf6eb8bfcc1aedff5d95f4b5a9f673f70b0ff150f5ab9`, rebuilt SHA-256 `57f07f5d99505ee35d45b3914484434fed98113c84cb94e74c6b362abd2216a1` after manifest dependency fix | First smoke ignored Setlist because `BeatLeader@^0.9.0` did not accept installed BeatLeader 0.10.0; after widening to `>=0.9.0`, IPA loaded Setlist 0.1.0 but only logged `No playlists loaded` and did not produce the expected `platformUserId`/playlist ownership lines. Removed from the live instance per skip instruction. |
|
||||
|
||||
@@ -39,7 +39,7 @@ bs-manager installs these mostly as dependency closure. BeatMods records depende
|
||||
- Required by: most UI/config mods here, including SongCore, SiraUtil, PlaylistManager, BeatSaverDownloader, Chroma, Vivify, ScoreSaber, and BeatLeader.
|
||||
- BeatSaberPlaylistsLib https://github.com/Meivyn/BeatSaberPlaylistsLib
|
||||
- Required by: PlaylistManager.
|
||||
- BeatSaverSharp https://github.com/lolPants/BeatSaverSharp
|
||||
- BeatSaverSharp https://github.com/Auros/BeatSaverSharper
|
||||
- Required by: BeatSaverDownloader, BeatSaverUpdater, DiTails, PlaylistManager.
|
||||
- BS Utils https://github.com/Kylemc1413/Beat-Saber-Utils
|
||||
- Required by: BeatSaverDownloader, BeatSaverVoting, BeatLeader.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Windows Compatibility Tracker
|
||||
|
||||
Started: 2026-07-01
|
||||
|
||||
This note tracks the changes needed to run `plugin-helper` natively on Windows
|
||||
11 with Python 3.13, separate from the Linux helper that manages mounted
|
||||
Windows instances.
|
||||
|
||||
## Target Setup
|
||||
|
||||
- Install Python with:
|
||||
|
||||
```powershell
|
||||
winget install --id Python.Python.3.13 --exact
|
||||
```
|
||||
|
||||
- Use a separate checkout or working copy on the Windows partition.
|
||||
- Use a Windows-specific config file:
|
||||
|
||||
```powershell
|
||||
py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows instances
|
||||
```
|
||||
|
||||
- Keep native Windows state local to that checkout, such as `.state/`, so it
|
||||
does not mix with the Linux `.state` or mounted-Windows `.state-windows`
|
||||
directories.
|
||||
|
||||
## Current Compatibility Notes
|
||||
|
||||
- Core scan, plan, apply, uninstall, disable, and enable flows are mostly
|
||||
platform-neutral. They use `pathlib`, `zipfile`, `shutil`, JSON/TOML, and
|
||||
local file hashes.
|
||||
- Native Windows defaults should not reuse Linux-mounted paths such as
|
||||
`/home/pleb/Windows/...`. A Windows-specific TOML file handles this for normal
|
||||
use.
|
||||
- Multiple `--instances-root` values use `os.pathsep`; that means `;` on
|
||||
Windows and `:` on Linux. Documentation should make this platform-specific.
|
||||
- The Textual TUI dependency supports Windows and Python 3.13, but the best
|
||||
terminal target is Windows Terminal or a modern PowerShell host.
|
||||
|
||||
## Work Items
|
||||
|
||||
- Add native Windows bootstrap support.
|
||||
- Current bootstrap assumes Proton.
|
||||
- Native Windows should run `IPA.exe -n` directly from the Beat Saber instance.
|
||||
- Timeout cleanup needs Windows-compatible process handling instead of
|
||||
POSIX process groups.
|
||||
- Decide whether `bootstrap-check` should accept a recorded native Windows
|
||||
bootstrap state without a Proton launch history.
|
||||
- Add Windows-aware default paths or keep requiring `--config
|
||||
plugin-helper.windows.toml` for native use.
|
||||
- Update README examples for PowerShell:
|
||||
- editable install
|
||||
- `--config plugin-helper.windows.toml`
|
||||
- Windows path-list separator `;`
|
||||
- Add or adjust tests for Windows behavior.
|
||||
- Skip or rewrite the POSIX-only `_run_ipa` timeout test on Windows.
|
||||
- Add tests for native Windows config path resolution.
|
||||
- Add tests for native bootstrap command construction.
|
||||
- Review backup and restore helpers on native Windows.
|
||||
- `sync_windows_data_repo` and `restore_windows_data_repo` should work with
|
||||
ordinary Windows paths.
|
||||
- The older tar-based `backup_userdata` helper uses `NamedTemporaryFile` in a
|
||||
way that may not be Windows-friendly if it becomes part of the CLI later.
|
||||
|
||||
## First Manual Smoke
|
||||
|
||||
From the Windows checkout:
|
||||
|
||||
```powershell
|
||||
py -3.13 -m pip install -e .
|
||||
py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows instances
|
||||
py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows installed --instance 1.44.1
|
||||
```
|
||||
|
||||
If instance discovery fails, verify the BSManager instance root in
|
||||
`plugin-helper.windows.toml`.
|
||||
+18
-18
@@ -163,12 +163,12 @@ reason = "BeatMods verified BeatSaberPlaylistsLib 1.7.2 for Beat Saber 1.44.1 as
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsaversharp"
|
||||
repo = "lolPants/BeatSaverSharp"
|
||||
repo = "Auros/BeatSaverSharper"
|
||||
tag = "beatmods-3.4.5"
|
||||
asset = "BeatSaverSharp-3.4.5.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. BeatMods upstream URL returned inaccessible via the GitHub releases API, so this remains a BeatMods CDN fallback."
|
||||
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."
|
||||
|
||||
[[plugins]]
|
||||
id = "scoresabersharp"
|
||||
@@ -232,15 +232,6 @@ 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."
|
||||
|
||||
[[plugins]]
|
||||
id = "playlistmanager"
|
||||
repo = "rithik-b/PlaylistManager"
|
||||
tag = "pr-82-localbuild-da1ad17"
|
||||
asset = "PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip"
|
||||
sha256 = "57b449e614db1d5214cd3a88000d52f5a989c8521390a9f4819b1c62f20f16fa"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "Local build from .state/build/playlistmanager-pr82-skilltest, artifact PlaylistManager/bin/Release/net48/zip/PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip. Use this PR82 build instead of the failed BeatMods 1.7.3 compatibility trial for Beat Saber 1.44.1."
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsaverupdater"
|
||||
repo = "ibillingsley/BeatSaverUpdater"
|
||||
@@ -289,20 +280,20 @@ reason = "User-provided GitHub repository URL https://github.com/qqrz997/KeepMyO
|
||||
[[plugins]]
|
||||
id = "keyremapper"
|
||||
repo = "lyyQwQ/KeyRemapper"
|
||||
tag = "0.3.0"
|
||||
asset = "KeyRemapper-0.3.0-bs1.39.1-8e4c11a.zip"
|
||||
sha256 = "962e3f6e18bebdf101e6575fa7b8b7e0d92179bab4f0825d122078f1842f7380"
|
||||
tag = "pr-6-23bb523-1.44-fix"
|
||||
asset = "KeyRemapper-0.3.0-bs1.44.0-8e4c11a.zip"
|
||||
sha256 = "b8aaaa72712ae10cec98f51f79b3b87fe7a588b0d3393dd087b7d84975246e6c"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "User-provided GitHub releases URL https://github.com/lyyQwQ/KeyRemapper/releases. Latest non-draft, non-prerelease release 0.3.0 exposes this asset; GitHub release digest matched the downloaded asset. Release notes label compatibility as Beat Saber 1.39.1 with BSIPA/BSML/SiraUtil dependencies."
|
||||
reason = "Local build from /home/pleb/src/lyyQwQ/KeyRemapper commit 23bb523, submitted upstream as PR https://github.com/lyyQwQ/KeyRemapper/pull/6. This updates the menu remap Affinity patch target from removed UnityXRHelper to DevicelessVRHelper and sets manifest gameVersion to 1.44.0 for Beat Saber 1.44 compatibility."
|
||||
|
||||
[[plugins]]
|
||||
id = "squattobegin"
|
||||
repo = "kinsi55/BeatSaber_SquatToBegin"
|
||||
tag = "v0.0.7"
|
||||
tag = "localbuild-8703b84-1.44-fix"
|
||||
asset = "SquatToBegin.dll"
|
||||
sha256 = "8426a64f6a3224b8cd79b9ee86347727a5a43c40a0fda116bf9c613f145fc18e"
|
||||
sha256 = "0bd6b5ee48cae370f255707afb9e452e08a68802f81493dd9a442b6a9f7b98bd"
|
||||
install_strategy = "dll-to-plugins"
|
||||
reason = "User-provided GitHub releases URL https://github.com/kinsi55/BeatSaber_SquatToBegin/releases. Latest non-draft, non-prerelease release v0.0.7 is labeled for Beat Saber 1.39.1+ and exposes this direct DLL asset; GitHub release digest matched the downloaded asset."
|
||||
reason = "Local build from /home/pleb/src/kinsi55/BeatSaber_SquatToBegin at commit 8703b84 plus local 1.44 compatibility fixes: change AudioTimeSyncController.State.Playing to IAudioTimeSource.State.Playing and arm the squat gate from gameplay scene setup instead of relying on the stale level-selection prefix. BeatMods verified search on 2026-07-01 found no SquatToBegin entry for Beat Saber 1.44.1, 1.44.0, 1.43.0, 1.42.1, 1.42.0, 1.40.0, 1.39.1, or 1.20.0."
|
||||
|
||||
[[plugins]]
|
||||
id = "introskip"
|
||||
@@ -340,6 +331,15 @@ 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."
|
||||
|
||||
[[plugins]]
|
||||
id = "ditails"
|
||||
repo = "Auros/DiTails"
|
||||
tag = "localbuild-601e3c4-1.44-fix"
|
||||
asset = "DiTails-1.1.3-bs1.44.1-601e3c4.zip"
|
||||
sha256 = "b8735f24545f1a50392865bf3013b930bee8e4ba7feac824a2482e2f1deeba1e"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "Local build from /home/pleb/src/Auros/DiTails commit 601e3c4 on branch fix-1.44-artwork-initialization. Use this build instead of the failed upstream 1.1.3 / BeatMods DiTails trial, which loaded but failed menu initialization with a NullReferenceException on Beat Saber 1.44.1."
|
||||
|
||||
[[plugins]]
|
||||
id = "hidethelogo"
|
||||
repo = "TheBlackParrot/HideTheLogo"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
instances_root = "~/.local/share/BSManager/BSInstances"
|
||||
state_dir = "~/.local/state/plugin-helper"
|
||||
|
||||
# To keep state inside this checkout instead:
|
||||
# state_dir = ".state"
|
||||
#
|
||||
# To share one state directory between Linux and a Windows-partition checkout:
|
||||
# state_dir = "~/Windows/Users/pleb/ops/plugin-helper/.state"
|
||||
+3
-1
@@ -10,7 +10,9 @@ readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
authors = [{ name = "plugin-helper contributors" }]
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"textual>=8.2,<9",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
plugin-helper = "plugin_helper.cli:main"
|
||||
|
||||
@@ -407,7 +407,7 @@ required = true
|
||||
[[plugins]]
|
||||
id = "beatsaversharp"
|
||||
name = "BeatSaverSharp"
|
||||
repo = "lolPants/BeatSaverSharp"
|
||||
repo = "Auros/BeatSaverSharper"
|
||||
asset_patterns = ["BeatSaverSharp-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "library"
|
||||
|
||||
+107
-161
@@ -4,18 +4,19 @@ import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
from .config import instances_roots, repo_root, state_root
|
||||
from .config import repo_root, resolve_runtime_config
|
||||
from .bootstrap import run_bootstrap
|
||||
from .bsipa import check_bsipa_health
|
||||
from .checker import check_lock
|
||||
from .github import fetch_releases
|
||||
from .installer import apply_plan, uninstall_plugin
|
||||
from .installer import apply_plan, disable_plugin, uninstall_plugin
|
||||
from .instances import get_instance, list_instances
|
||||
from .models import load_lockfile, load_registry
|
||||
from .models import Lockfile, Registry
|
||||
from .operations import enable_disabled_plugin
|
||||
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 .updates import check_updates
|
||||
@@ -26,68 +27,6 @@ def _json(data: Any) -> None:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def installed_plugins_report(
|
||||
*,
|
||||
installed_state: dict[str, Any],
|
||||
registry: Registry,
|
||||
lockfile: Lockfile,
|
||||
) -> dict[str, Any]:
|
||||
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
|
||||
plugins: list[dict[str, Any]] = []
|
||||
for plugin_id, plugin_state in sorted(installed_state.get("plugins", {}).items()):
|
||||
registry_plugin = registry.get(plugin_id)
|
||||
locked = locked_by_id.get(plugin_id)
|
||||
files = plugin_state.get("files", [])
|
||||
plugins.append(
|
||||
{
|
||||
"id": plugin_id,
|
||||
"name": registry_plugin.name if registry_plugin else plugin_id,
|
||||
"version": locked.tag if locked and locked.tag else "(not locked)",
|
||||
"asset": locked.asset if locked and locked.asset else "(unknown)",
|
||||
"repo": (locked.repo if locked and locked.repo else None)
|
||||
or (registry_plugin.repo if registry_plugin else None)
|
||||
or "(unknown)",
|
||||
"installedAt": plugin_state.get("installedAt", "(unknown)"),
|
||||
"fileCount": len(files),
|
||||
"files": files,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"instance": installed_state.get("instance", lockfile.instance),
|
||||
"beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version),
|
||||
"plugins": plugins,
|
||||
}
|
||||
|
||||
|
||||
def print_installed_plugins(report: dict[str, Any]) -> None:
|
||||
plugins = report["plugins"]
|
||||
print(f"{report['instance']} managed plugins ({len(plugins)})")
|
||||
if not plugins:
|
||||
print("No plugins have been installed by plugin-helper yet.")
|
||||
return
|
||||
|
||||
headers = ("Plugin", "Version", "Asset", "Files", "Installed")
|
||||
rows = [
|
||||
(
|
||||
f"{plugin['name']} ({plugin['id']})",
|
||||
plugin["version"],
|
||||
plugin["asset"],
|
||||
str(plugin["fileCount"]),
|
||||
plugin["installedAt"],
|
||||
)
|
||||
for plugin in plugins
|
||||
]
|
||||
widths = [
|
||||
max(len(headers[index]), *(len(row[index]) for row in rows))
|
||||
for index in range(len(headers))
|
||||
]
|
||||
header = " ".join(label.ljust(widths[index]) for index, label in enumerate(headers))
|
||||
print(header)
|
||||
print(" ".join("-" * width for width in widths))
|
||||
for row in rows:
|
||||
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
|
||||
|
||||
|
||||
def print_updates(report: dict[str, Any]) -> None:
|
||||
plugins = report["plugins"]
|
||||
summary = report["summary"]
|
||||
@@ -129,7 +68,7 @@ def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = Fal
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="plugin-helper")
|
||||
_add_common(parser)
|
||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||
subcommands = parser.add_subparsers(dest="command")
|
||||
|
||||
subcommands.add_parser(
|
||||
"instances",
|
||||
@@ -236,6 +175,25 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
uninstall.add_argument("plugin")
|
||||
uninstall.add_argument("--force", action="store_true", help="Delete even when current file hashes differ")
|
||||
|
||||
disable = subcommands.add_parser(
|
||||
"disable",
|
||||
help="Remove a managed plugin's files from the game but keep state/assets for re-enable",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
disable.add_argument("--instance", required=True)
|
||||
disable.add_argument("plugin")
|
||||
disable.add_argument("--force", action="store_true", help="Disable even when current file hashes differ")
|
||||
|
||||
enable = subcommands.add_parser(
|
||||
"enable",
|
||||
help="Reinstall a disabled locked plugin from local assets",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
enable.add_argument("--instance", required=True)
|
||||
enable.add_argument("--registry", default="registry/plugins.toml")
|
||||
enable.add_argument("--lockfile")
|
||||
enable.add_argument("plugin")
|
||||
|
||||
backup = subcommands.add_parser(
|
||||
"backup-userdata",
|
||||
help="Copy UserData and Windows AppData into the adjacent backups repo",
|
||||
@@ -265,112 +223,69 @@ def _common_parent() -> argparse.ArgumentParser:
|
||||
return parent
|
||||
|
||||
|
||||
def _ask_choice(
|
||||
def _run_menu(
|
||||
*,
|
||||
title: str,
|
||||
choices: list[tuple[str, str] | tuple[str, str, str]],
|
||||
input_func: Callable[[str], str] | None = None,
|
||||
) -> str | None:
|
||||
ask = input_func or input
|
||||
print()
|
||||
print(title)
|
||||
for index, choice in enumerate(choices, start=1):
|
||||
label = choice[1]
|
||||
print(f" {index}. {label}")
|
||||
if len(choice) > 2:
|
||||
print(f" {choice[2]}")
|
||||
print(" q. Quit")
|
||||
|
||||
while True:
|
||||
answer = ask("> ").strip().lower()
|
||||
if answer in {"q", "quit", "exit"}:
|
||||
return None
|
||||
if answer.isdigit():
|
||||
index = int(answer)
|
||||
if 1 <= index <= len(choices):
|
||||
return choices[index - 1][0]
|
||||
print("Choose a listed number, or q to quit.")
|
||||
|
||||
|
||||
def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str], str] | None = None) -> int:
|
||||
ask = input_func or input
|
||||
instances = list_instances(inst_roots)
|
||||
if not instances:
|
||||
print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}")
|
||||
runtime: Any,
|
||||
explicit_instances_root: bool,
|
||||
explicit_state_dir: bool,
|
||||
) -> int:
|
||||
try:
|
||||
from .tui import InstallationChoice, PluginHelperTui
|
||||
except ImportError as exc:
|
||||
print(f"Textual is required for the menu TUI: {exc}")
|
||||
print("Install project dependencies, for example: python -m pip install -e .")
|
||||
return 1
|
||||
|
||||
instances_by_choice = {str(index): item for index, item in enumerate(instances, start=1)}
|
||||
instance_choices = [(str(index), f"{item.name} {item.path}") for index, item in enumerate(instances, start=1)]
|
||||
action_choices = [
|
||||
("installed", "Show managed installs", "Lists plugins recorded in plugin-helper state with locked versions and files."),
|
||||
("updates", "Check locked plugin updates", "Looks at GitHub releases for newer assets matching locked plugins."),
|
||||
("scan", "Scan installed files", "Counts files currently present in Plugins/, Libs/, and IPA/Pending/."),
|
||||
("check", "Check lockfile and assets", "Validates registry entries, lockfile data, local assets, and SHA-256 values."),
|
||||
("bootstrap", "Bootstrap BSIPA", "Fetches the locked BSIPA archive, installs it, runs IPA.exe -n, and records bootstrap files."),
|
||||
("bootstrap-check", "Check BSIPA bootstrap", "Verifies recorded bootstrap state and the latest BSIPA log evidence."),
|
||||
("plan", "Create install plan", "Writes a dry-run JSON plan for locked plugin files before anything is applied."),
|
||||
("apply", "Apply a plan by path", "Installs exactly the file changes from a previously generated plan JSON."),
|
||||
("backup-userdata", "Back up UserData", "Copies UserData and AppData into the adjacent backups repo."),
|
||||
("restore-userdata", "Restore UserData", "Restores UserData and AppData from the backups repo into an instance."),
|
||||
("change", "Choose another version", "Returns to the Beat Saber version picker."),
|
||||
]
|
||||
|
||||
selected_instance_key = _ask_choice(
|
||||
title="Choose Beat Saber version",
|
||||
choices=instance_choices,
|
||||
input_func=ask,
|
||||
choices: list[InstallationChoice] = []
|
||||
for index, root in enumerate(runtime.instances_roots, start=1):
|
||||
install_label = str(root) if len(runtime.instances_roots) > 1 else "Default"
|
||||
for instance in list_instances(root):
|
||||
choices.append(
|
||||
InstallationChoice(
|
||||
install_id=f"root-{index}",
|
||||
install_label=install_label,
|
||||
instance_name=instance.name,
|
||||
instance_path=instance.path,
|
||||
state_root=runtime.state_root,
|
||||
)
|
||||
if selected_instance_key is None:
|
||||
return 0
|
||||
selected_instance = instances_by_choice[selected_instance_key]
|
||||
|
||||
while True:
|
||||
selected_action = _ask_choice(
|
||||
title=f"Choose action for {selected_instance.name}",
|
||||
choices=action_choices,
|
||||
input_func=ask,
|
||||
)
|
||||
if selected_action is None:
|
||||
return 0
|
||||
if selected_action == "change":
|
||||
selected_instance_key = _ask_choice(
|
||||
title="Choose Beat Saber version",
|
||||
choices=instance_choices,
|
||||
input_func=ask,
|
||||
if not choices:
|
||||
searched = ", ".join(str(root) for root in runtime.instances_roots)
|
||||
print(f"No Beat Saber instances found under {searched}")
|
||||
return 1
|
||||
|
||||
setup_hint = None
|
||||
if not runtime.config_loaded and not explicit_instances_root and not explicit_state_dir:
|
||||
setup_hint = (
|
||||
f"No {runtime.config_path.name} found; using default roots and default state. "
|
||||
f"Copy plugin-helper.toml.example to {runtime.config_path.name} to set a custom state dir."
|
||||
)
|
||||
if selected_instance_key is None:
|
||||
return 0
|
||||
selected_instance = instances_by_choice[selected_instance_key]
|
||||
continue
|
||||
|
||||
command = [
|
||||
"--instances-root",
|
||||
str(selected_instance.path.parent),
|
||||
"--state-dir",
|
||||
str(st_root),
|
||||
selected_action,
|
||||
]
|
||||
if selected_action == "apply":
|
||||
plan_path = ask("Plan path> ").strip()
|
||||
if not plan_path:
|
||||
print("No plan path entered.")
|
||||
continue
|
||||
command.append(plan_path)
|
||||
else:
|
||||
command.extend(["--instance", selected_instance.name])
|
||||
|
||||
print()
|
||||
status = run(command)
|
||||
print(f"Command exited with status {status}")
|
||||
app = PluginHelperTui(choices=choices, repo_root=repo_root(), setup_hint=setup_hint)
|
||||
result = app.run()
|
||||
return int(result or 0)
|
||||
|
||||
|
||||
def run(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
inst_roots = instances_roots(getattr(args, "instances_root", None))
|
||||
st_root = state_root(getattr(args, "state_dir", None))
|
||||
|
||||
try:
|
||||
if args.command is None:
|
||||
if sys.stdin.isatty() and sys.stdout.isatty():
|
||||
args.command = "menu"
|
||||
else:
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
explicit_instances_root = getattr(args, "instances_root", None) is not None
|
||||
explicit_state_dir = getattr(args, "state_dir", None) is not None
|
||||
runtime = resolve_runtime_config(
|
||||
instances_root_value=getattr(args, "instances_root", None),
|
||||
state_dir_value=getattr(args, "state_dir", None),
|
||||
)
|
||||
inst_roots = runtime.instances_roots
|
||||
st_root = runtime.state_root
|
||||
|
||||
if args.command == "instances":
|
||||
found = list_instances(inst_roots)
|
||||
if not found:
|
||||
@@ -389,7 +304,11 @@ def run(argv: list[str] | None = None) -> int:
|
||||
return 0
|
||||
|
||||
if args.command == "menu":
|
||||
return _run_menu(inst_roots, st_root)
|
||||
return _run_menu(
|
||||
runtime=runtime,
|
||||
explicit_instances_root=explicit_instances_root,
|
||||
explicit_state_dir=explicit_state_dir,
|
||||
)
|
||||
|
||||
if args.command == "scan":
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
@@ -566,6 +485,33 @@ def run(argv: list[str] | None = None) -> int:
|
||||
print(f" {item['path']}: {item['reason']}")
|
||||
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)
|
||||
print(f"Disabled: {args.plugin}")
|
||||
print(f"Removed: {len(result['removed'])}")
|
||||
if result["skipped"]:
|
||||
print("Skipped:")
|
||||
for item in result["skipped"]:
|
||||
print(f" {item['path']}: {item['reason']}")
|
||||
return 0 if result["stateUpdated"] else 2
|
||||
|
||||
if args.command == "enable":
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
result = enable_disabled_plugin(
|
||||
instance=args.instance,
|
||||
instance_path=instance.path,
|
||||
state_root=st_root,
|
||||
plugin_id=args.plugin,
|
||||
registry=args.registry,
|
||||
lockfile=args.lockfile,
|
||||
)
|
||||
print(f"Enabled: {args.plugin}")
|
||||
print(f"Plan: {result['planPath']}")
|
||||
print(f"Applied: {len(result['applied'])}")
|
||||
print(f"State: {result['statePath']}")
|
||||
return 0
|
||||
|
||||
if args.command == "backup-userdata":
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
root = repo_root()
|
||||
|
||||
+119
-9
@@ -1,33 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
WINDOWS_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances")
|
||||
LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances"
|
||||
DEFAULT_INSTANCES_ROOTS = (WINDOWS_INSTANCES_ROOT, LOCAL_INSTANCES_ROOT)
|
||||
DEFAULT_INSTANCES_ROOT = WINDOWS_INSTANCES_ROOT
|
||||
DEFAULT_INSTANCES_ROOT = LOCAL_INSTANCES_ROOT
|
||||
LOCAL_CONFIG_NAME = "plugin-helper.local.toml"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalConfig:
|
||||
instances_roots: list[Path] | None
|
||||
state_root: Path | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeConfig:
|
||||
instances_roots: list[Path]
|
||||
state_root: Path
|
||||
config_path: Path
|
||||
config_loaded: bool
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def instances_root(value: str | None = None) -> Path:
|
||||
return instances_roots(value)[0]
|
||||
|
||||
|
||||
def instances_roots(value: str | None = None) -> list[Path]:
|
||||
def instances_roots(value: str | None = None, *, base: Path | None = None) -> list[Path]:
|
||||
raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
|
||||
if raw:
|
||||
return [Path(item).expanduser() for item in raw.split(os.pathsep) if item]
|
||||
return list(DEFAULT_INSTANCES_ROOTS)
|
||||
return _resolve_path_list(raw, base or repo_root())
|
||||
return [DEFAULT_INSTANCES_ROOT]
|
||||
|
||||
|
||||
def state_root(value: str | None = None) -> Path:
|
||||
if value:
|
||||
return Path(value).expanduser()
|
||||
return _resolve_path(value, repo_root())
|
||||
env_state = os.environ.get("PLUGIN_HELPER_STATE_DIR")
|
||||
if env_state:
|
||||
return _resolve_path(env_state, repo_root())
|
||||
xdg_state = os.environ.get("XDG_STATE_HOME")
|
||||
base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state"
|
||||
return base / "plugin-helper"
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
def default_config_path(root: Path | None = None) -> Path:
|
||||
return (root or repo_root()) / LOCAL_CONFIG_NAME
|
||||
|
||||
|
||||
def _resolve_path(value: str | Path, base: Path) -> Path:
|
||||
path = Path(value).expanduser()
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (base / path).resolve()
|
||||
|
||||
|
||||
def _resolve_path_list(value: str, base: Path) -> list[Path]:
|
||||
return [_resolve_path(item, base) for item in value.split(os.pathsep) if item]
|
||||
|
||||
|
||||
def load_local_config(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[LocalConfig, Path, bool]:
|
||||
repo = root or repo_root()
|
||||
path = _resolve_path(config_path, repo) if config_path else default_config_path(repo)
|
||||
if not path.exists():
|
||||
if config_path:
|
||||
raise FileNotFoundError(f"plugin-helper config not found: {path}")
|
||||
return LocalConfig(instances_roots=None, state_root=None), path, False
|
||||
|
||||
with path.open("rb") as handle:
|
||||
data: dict[str, Any] = tomllib.load(handle)
|
||||
|
||||
base = path.parent
|
||||
instances_value = data.get("instances_root")
|
||||
state_value = data.get("state_dir")
|
||||
return (
|
||||
LocalConfig(
|
||||
instances_roots=_resolve_path_list(instances_value, base) if instances_value else None,
|
||||
state_root=_resolve_path(state_value, base) if state_value else None,
|
||||
),
|
||||
path,
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def _env_instances_roots(root: Path) -> list[Path] | None:
|
||||
value = os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
|
||||
return _resolve_path_list(value, root) if value else None
|
||||
|
||||
|
||||
def _env_state_root(root: Path) -> Path | None:
|
||||
value = os.environ.get("PLUGIN_HELPER_STATE_DIR")
|
||||
return _resolve_path(value, root) if value else None
|
||||
|
||||
|
||||
def _default_state_root() -> Path:
|
||||
xdg_state = os.environ.get("XDG_STATE_HOME")
|
||||
base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state"
|
||||
return base / "plugin-helper"
|
||||
|
||||
|
||||
def _default_instances_roots() -> list[Path]:
|
||||
return [DEFAULT_INSTANCES_ROOT]
|
||||
|
||||
|
||||
def resolve_runtime_config(
|
||||
*,
|
||||
instances_root_value: str | None = None,
|
||||
state_dir_value: str | None = None,
|
||||
root: Path | None = None,
|
||||
) -> RuntimeConfig:
|
||||
repo = root or repo_root()
|
||||
local_config, loaded_path, loaded = load_local_config(root=repo)
|
||||
|
||||
resolved_instances = (
|
||||
_resolve_path_list(instances_root_value, repo)
|
||||
if instances_root_value
|
||||
else _env_instances_roots(repo)
|
||||
or local_config.instances_roots
|
||||
or _default_instances_roots()
|
||||
)
|
||||
resolved_state = (
|
||||
_resolve_path(state_dir_value, repo)
|
||||
if state_dir_value
|
||||
else _env_state_root(repo)
|
||||
or local_config.state_root
|
||||
or _default_state_root()
|
||||
)
|
||||
|
||||
return RuntimeConfig(
|
||||
instances_roots=resolved_instances,
|
||||
state_root=resolved_state,
|
||||
config_path=loaded_path,
|
||||
config_loaded=loaded,
|
||||
)
|
||||
|
||||
@@ -77,6 +77,7 @@ def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]:
|
||||
"size": target.stat().st_size,
|
||||
}
|
||||
)
|
||||
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)
|
||||
@@ -110,3 +111,37 @@ def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugi
|
||||
installed_state.get("plugins", {}).pop(plugin_id, None)
|
||||
save_installed_state(state_root, instance, installed_state)
|
||||
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)
|
||||
plugin_state = installed_state.get("plugins", {}).get(plugin_id)
|
||||
if not plugin_state:
|
||||
if plugin_id in installed_state.get("disabledPlugins", {}):
|
||||
raise KeyError(f"plugin is already disabled: {plugin_id}")
|
||||
raise KeyError(f"plugin is not recorded in install state: {plugin_id}")
|
||||
|
||||
removed: list[str] = []
|
||||
skipped: list[dict[str, str]] = []
|
||||
for item in plugin_state.get("files", []):
|
||||
rel_path = ensure_relative(item["path"]).as_posix()
|
||||
target = ensure_inside(instance_path, instance_path / rel_path)
|
||||
if not target.exists():
|
||||
removed.append(rel_path)
|
||||
continue
|
||||
current_sha = sha256_file(target)
|
||||
if current_sha != item.get("sha256") and not force:
|
||||
skipped.append({"path": rel_path, "reason": "hash mismatch"})
|
||||
continue
|
||||
target.unlink()
|
||||
removed.append(rel_path)
|
||||
|
||||
if skipped and not force:
|
||||
return {"removed": removed, "skipped": skipped, "stateUpdated": False}
|
||||
|
||||
disabled_state = dict(plugin_state)
|
||||
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)
|
||||
return {"removed": removed, "skipped": skipped, "stateUpdated": True}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import repo_root
|
||||
from .installer import apply_plan
|
||||
from .models import load_lockfile, load_registry
|
||||
from .planner import create_plan
|
||||
from .state import load_installed_state
|
||||
|
||||
|
||||
def enable_disabled_plugin(
|
||||
*,
|
||||
instance: str,
|
||||
instance_path: Path,
|
||||
state_root: Path,
|
||||
plugin_id: str,
|
||||
registry: str = "registry/plugins.toml",
|
||||
lockfile: str | None = None,
|
||||
repo: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
installed_state = load_installed_state(state_root, instance)
|
||||
if plugin_id not in installed_state.get("disabledPlugins", {}):
|
||||
raise KeyError(f"plugin is not recorded as disabled: {plugin_id}")
|
||||
|
||||
root = repo or repo_root()
|
||||
registry_path = (root / registry).resolve() if not Path(registry).is_absolute() else Path(registry)
|
||||
lock_path = Path(lockfile) if lockfile else root / "locks" / f"{instance}.lock.toml"
|
||||
if not lock_path.is_absolute():
|
||||
lock_path = (root / lock_path).resolve()
|
||||
loaded_lockfile = load_lockfile(lock_path)
|
||||
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
|
||||
raise KeyError(f"plugin is disabled but not locked for this instance: {plugin_id}")
|
||||
|
||||
plan, path = create_plan(
|
||||
instance=instance,
|
||||
instance_path=instance_path,
|
||||
beat_saber_version=loaded_lockfile.beat_saber_version,
|
||||
registry=load_registry(registry_path),
|
||||
lockfile=loaded_lockfile,
|
||||
state_root=state_root,
|
||||
repo_root=root,
|
||||
selected={plugin_id},
|
||||
)
|
||||
result = apply_plan(plan, state_root)
|
||||
return {"planPath": str(path), **result}
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .models import Lockfile, Registry
|
||||
|
||||
|
||||
def installed_plugins_report(
|
||||
*,
|
||||
installed_state: dict[str, Any],
|
||||
registry: Registry,
|
||||
lockfile: Lockfile,
|
||||
) -> dict[str, Any]:
|
||||
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
|
||||
plugins: list[dict[str, Any]] = []
|
||||
state_plugins = [
|
||||
(plugin_id, plugin_state, "enabled")
|
||||
for plugin_id, plugin_state in installed_state.get("plugins", {}).items()
|
||||
]
|
||||
state_plugins.extend(
|
||||
(plugin_id, plugin_state, "disabled")
|
||||
for plugin_id, plugin_state in installed_state.get("disabledPlugins", {}).items()
|
||||
)
|
||||
for plugin_id, plugin_state, status in sorted(state_plugins):
|
||||
registry_plugin = registry.get(plugin_id)
|
||||
locked = locked_by_id.get(plugin_id)
|
||||
files = plugin_state.get("files", [])
|
||||
plugins.append(
|
||||
{
|
||||
"id": plugin_id,
|
||||
"name": registry_plugin.name if registry_plugin else plugin_id,
|
||||
"version": locked.tag if locked and locked.tag else "(not locked)",
|
||||
"asset": locked.asset if locked and locked.asset else "(unknown)",
|
||||
"repo": (locked.repo if locked and locked.repo else None)
|
||||
or (registry_plugin.repo if registry_plugin else None)
|
||||
or "(unknown)",
|
||||
"installedAt": plugin_state.get("installedAt", "(unknown)"),
|
||||
"disabledAt": plugin_state.get("disabledAt"),
|
||||
"status": status,
|
||||
"fileCount": len(files),
|
||||
"files": files,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"instance": installed_state.get("instance", lockfile.instance),
|
||||
"beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version),
|
||||
"plugins": plugins,
|
||||
}
|
||||
|
||||
|
||||
def print_installed_plugins(report: dict[str, Any]) -> None:
|
||||
plugins = report["plugins"]
|
||||
print(f"{report['instance']} managed plugins ({len(plugins)})")
|
||||
if not plugins:
|
||||
print("No plugins have been installed by plugin-helper yet.")
|
||||
return
|
||||
|
||||
headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed")
|
||||
rows = [
|
||||
(
|
||||
f"{plugin['name']} ({plugin['id']})",
|
||||
plugin["status"],
|
||||
plugin["version"],
|
||||
plugin["asset"],
|
||||
str(plugin["fileCount"]),
|
||||
plugin["disabledAt"] or plugin["installedAt"],
|
||||
)
|
||||
for plugin in plugins
|
||||
]
|
||||
widths = [
|
||||
max(len(headers[index]), *(len(row[index]) for row in rows))
|
||||
for index in range(len(headers))
|
||||
]
|
||||
header = " ".join(label.ljust(widths[index]) for index, label in enumerate(headers))
|
||||
print(header)
|
||||
print(" ".join("-" * width for width in widths))
|
||||
for row in rows:
|
||||
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.widgets import DataTable, Footer, Header, Static
|
||||
|
||||
from .installer import disable_plugin
|
||||
from .models import load_lockfile, load_registry
|
||||
from .operations import enable_disabled_plugin
|
||||
from .reports import installed_plugins_report
|
||||
from .state import load_installed_state
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstallationChoice:
|
||||
install_id: str
|
||||
install_label: str
|
||||
instance_name: str
|
||||
instance_path: Path
|
||||
state_root: Path
|
||||
|
||||
|
||||
class PluginHelperTui(App[int]):
|
||||
CSS = """
|
||||
#title {
|
||||
padding: 0 1;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
#status {
|
||||
padding: 0 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
BINDINGS = [
|
||||
Binding("enter", "select", "Select", priority=True),
|
||||
Binding("space", "toggle_plugin", "Toggle", priority=True),
|
||||
Binding("d", "disable_all_plugins", "Disable all", priority=True),
|
||||
Binding("e", "enable_all_plugins", "Enable all", priority=True),
|
||||
Binding("r", "refresh", "Refresh"),
|
||||
Binding("b", "back", "Back"),
|
||||
Binding("q", "quit", "Quit"),
|
||||
Binding("ctrl+q", "quit", "Quit", show=False, priority=True),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
choices: list[InstallationChoice],
|
||||
repo_root: Path,
|
||||
setup_hint: str | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.choices = choices
|
||||
self.repo_root = repo_root
|
||||
self.setup_hint = setup_hint
|
||||
self.mode = "installations"
|
||||
self.selected_installation: InstallationChoice | None = None
|
||||
self.plugin_rows: list[dict[str, Any]] = []
|
||||
self.status_message = ""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(show_clock=False)
|
||||
yield Static("", id="title")
|
||||
yield DataTable(id="table")
|
||||
yield Static("", id="status")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
table = self.query_one(DataTable)
|
||||
table.cursor_type = "row"
|
||||
self._show_installations()
|
||||
|
||||
def action_select(self) -> None:
|
||||
if self.mode != "installations":
|
||||
return
|
||||
index = self._cursor_index(len(self.choices))
|
||||
if index is None:
|
||||
return
|
||||
self.selected_installation = self.choices[index]
|
||||
self._show_plugins()
|
||||
|
||||
def action_back(self) -> None:
|
||||
if self.mode == "plugins":
|
||||
self._show_installations()
|
||||
|
||||
def action_refresh(self) -> None:
|
||||
if self.mode == "plugins":
|
||||
self._show_plugins()
|
||||
else:
|
||||
self._show_installations()
|
||||
|
||||
def action_toggle_plugin(self) -> None:
|
||||
if self.mode != "plugins" or self.selected_installation is None:
|
||||
return
|
||||
index = self._cursor_index(len(self.plugin_rows))
|
||||
if index is None:
|
||||
return
|
||||
plugin = self.plugin_rows[index]
|
||||
plugin_id = plugin["id"]
|
||||
target = self.selected_installation
|
||||
try:
|
||||
if plugin["status"] == "enabled":
|
||||
result = disable_plugin(
|
||||
target.instance_name,
|
||||
target.instance_path,
|
||||
target.state_root,
|
||||
plugin_id,
|
||||
force=False,
|
||||
)
|
||||
if not result["stateUpdated"]:
|
||||
self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}")
|
||||
return
|
||||
self._set_status(f"Disabled {plugin_id}; removed {len(result['removed'])} files.")
|
||||
elif plugin["status"] == "disabled":
|
||||
result = enable_disabled_plugin(
|
||||
instance=target.instance_name,
|
||||
instance_path=target.instance_path,
|
||||
state_root=target.state_root,
|
||||
plugin_id=plugin_id,
|
||||
repo=self.repo_root,
|
||||
)
|
||||
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
|
||||
else:
|
||||
self._set_status(f"Cannot toggle {plugin_id}: unknown status {plugin['status']}.")
|
||||
return
|
||||
except Exception as exc:
|
||||
self._set_status(f"Could not toggle {plugin_id}: {exc}")
|
||||
return
|
||||
self._show_plugins(preserve_status=True)
|
||||
|
||||
def action_disable_all_plugins(self) -> None:
|
||||
if self.mode != "plugins" or self.selected_installation is None:
|
||||
return
|
||||
target = self.selected_installation
|
||||
enabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "enabled"]
|
||||
changed = 0
|
||||
errors: list[str] = []
|
||||
for plugin in enabled:
|
||||
plugin_id = plugin["id"]
|
||||
try:
|
||||
result = disable_plugin(
|
||||
target.instance_name,
|
||||
target.instance_path,
|
||||
target.state_root,
|
||||
plugin_id,
|
||||
force=False,
|
||||
)
|
||||
if result["stateUpdated"]:
|
||||
changed += 1
|
||||
else:
|
||||
errors.append(f"{plugin_id}: {self._format_skipped(result['skipped'])}")
|
||||
except Exception as exc:
|
||||
errors.append(f"{plugin_id}: {exc}")
|
||||
self._set_bulk_status("Disabled", changed, errors)
|
||||
self._show_plugins(preserve_status=True)
|
||||
|
||||
def action_enable_all_plugins(self) -> None:
|
||||
if self.mode != "plugins" or self.selected_installation is None:
|
||||
return
|
||||
target = self.selected_installation
|
||||
disabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "disabled"]
|
||||
changed = 0
|
||||
errors: list[str] = []
|
||||
for plugin in disabled:
|
||||
plugin_id = plugin["id"]
|
||||
try:
|
||||
enable_disabled_plugin(
|
||||
instance=target.instance_name,
|
||||
instance_path=target.instance_path,
|
||||
state_root=target.state_root,
|
||||
plugin_id=plugin_id,
|
||||
repo=self.repo_root,
|
||||
)
|
||||
changed += 1
|
||||
except Exception as exc:
|
||||
errors.append(f"{plugin_id}: {exc}")
|
||||
self._set_bulk_status("Enabled", changed, errors)
|
||||
self._show_plugins(preserve_status=True)
|
||||
|
||||
def _show_installations(self) -> None:
|
||||
self.mode = "installations"
|
||||
self.plugin_rows = []
|
||||
self._set_title("Choose Beat Saber Installation")
|
||||
table = self.query_one(DataTable)
|
||||
table.clear(columns=True)
|
||||
table.add_columns("Install", "Version", "Instance Path", "State Dir")
|
||||
for choice in self.choices:
|
||||
table.add_row(
|
||||
choice.install_label,
|
||||
choice.instance_name,
|
||||
str(choice.instance_path),
|
||||
str(choice.state_root),
|
||||
)
|
||||
if self.setup_hint:
|
||||
self._set_status(self.setup_hint)
|
||||
else:
|
||||
self._set_status("Enter selects an installation. q quits.")
|
||||
|
||||
def _show_plugins(self, *, preserve_status: bool = False) -> None:
|
||||
if self.selected_installation is None:
|
||||
self._show_installations()
|
||||
return
|
||||
target = self.selected_installation
|
||||
self.mode = "plugins"
|
||||
self._set_title(f"{target.install_label} / {target.instance_name}")
|
||||
table = self.query_one(DataTable)
|
||||
table.clear(columns=True)
|
||||
table.add_columns("Status", "Name", "ID", "Version", "Files", "Asset")
|
||||
|
||||
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),
|
||||
registry=load_registry(self.repo_root / "registry" / "plugins.toml"),
|
||||
lockfile=lockfile,
|
||||
)
|
||||
self.plugin_rows = report["plugins"]
|
||||
except Exception as exc:
|
||||
self.plugin_rows = []
|
||||
if not preserve_status:
|
||||
self._set_status(f"Could not load plugins: {exc}")
|
||||
return
|
||||
|
||||
for plugin in self.plugin_rows:
|
||||
table.add_row(
|
||||
self._status_marker(plugin["status"]),
|
||||
plugin["name"],
|
||||
plugin["id"],
|
||||
plugin["version"],
|
||||
str(plugin["fileCount"]),
|
||||
plugin["asset"],
|
||||
)
|
||||
if not preserve_status:
|
||||
if self.plugin_rows:
|
||||
self._set_status("Space toggles selected. d disables all. e enables all. b returns to installations.")
|
||||
else:
|
||||
self._set_status("No managed plugins recorded for this installation.")
|
||||
|
||||
def _cursor_index(self, row_count: int) -> int | None:
|
||||
table = self.query_one(DataTable)
|
||||
row = table.cursor_coordinate.row
|
||||
if 0 <= row < row_count:
|
||||
return row
|
||||
return None
|
||||
|
||||
def _set_title(self, message: str) -> None:
|
||||
self.query_one("#title", Static).update(message)
|
||||
|
||||
def _set_status(self, message: str) -> None:
|
||||
self.status_message = message
|
||||
self.query_one("#status", Static).update(message)
|
||||
|
||||
def _set_bulk_status(self, verb: str, changed: int, errors: list[str]) -> None:
|
||||
if errors:
|
||||
preview = "; ".join(errors[:3])
|
||||
suffix = f"; {len(errors) - 3} more" if len(errors) > 3 else ""
|
||||
self._set_status(f"{verb} {changed} plugins; {len(errors)} failed: {preview}{suffix}")
|
||||
else:
|
||||
self._set_status(f"{verb} {changed} plugins.")
|
||||
|
||||
@staticmethod
|
||||
def _status_marker(status: str) -> Text:
|
||||
return Text("[x]" if status == "enabled" else "[ ]", no_wrap=True)
|
||||
|
||||
@staticmethod
|
||||
def _format_skipped(skipped: list[dict[str, str]]) -> str:
|
||||
if not skipped:
|
||||
return "no files changed"
|
||||
return "; ".join(f"{item['path']} {item['reason']}" for item in skipped)
|
||||
+415
-44
@@ -9,17 +9,23 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from zipfile import ZipFile
|
||||
|
||||
from rich.text import Text
|
||||
from textual.coordinate import Coordinate
|
||||
from textual.widgets import DataTable
|
||||
|
||||
from plugin_helper.bootstrap import _run_ipa
|
||||
from plugin_helper.beatmods import by_version_id, normalize_mods
|
||||
from plugin_helper.checker import check_lock
|
||||
from plugin_helper.cli import installed_plugins_report, run
|
||||
from plugin_helper.config import load_local_config, resolve_runtime_config
|
||||
from plugin_helper.fsutil import sha256_file
|
||||
from plugin_helper.installer import apply_plan, uninstall_plugin
|
||||
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 Lockfile, LockedPlugin, Registry, RegistryPlugin
|
||||
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
|
||||
from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state, save_installed_state
|
||||
from plugin_helper.tui import InstallationChoice, PluginHelperTui
|
||||
from plugin_helper.updates import check_updates
|
||||
from plugin_helper.userdata import (
|
||||
backup_userdata,
|
||||
@@ -119,58 +125,132 @@ class PluginHelperTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "ambiguous"):
|
||||
get_instance([windows, local], "1.44.1")
|
||||
|
||||
def test_menu_selects_instance_and_action(self) -> None:
|
||||
def test_local_config_loads_top_level_paths(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
instance = root / "instances" / "1.40.8"
|
||||
state = root / "state"
|
||||
(instance / "Beat Saber_Data").mkdir(parents=True)
|
||||
(instance / "Plugins").mkdir()
|
||||
|
||||
answers = iter(["1", "3", "q"])
|
||||
output = StringIO()
|
||||
with patch("builtins.input", side_effect=lambda _: next(answers)), patch("sys.stdout", output):
|
||||
status = run(
|
||||
[
|
||||
"--instances-root",
|
||||
str(root / "instances"),
|
||||
"--state-dir",
|
||||
str(state),
|
||||
"menu",
|
||||
]
|
||||
config = root / "plugin-helper.local.toml"
|
||||
config.write_text(
|
||||
"""
|
||||
instances_root = "~/BSInstances"
|
||||
state_dir = ".state"
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIn("Counts files currently present", output.getvalue())
|
||||
local_config, loaded_path, loaded = load_local_config(config, root=root)
|
||||
|
||||
def test_menu_routes_duplicate_instance_names_by_selected_root(self) -> None:
|
||||
self.assertTrue(loaded)
|
||||
self.assertEqual(loaded_path, config)
|
||||
self.assertEqual(local_config.instances_roots, [Path("~/BSInstances").expanduser()])
|
||||
self.assertEqual(local_config.state_root, root / ".state")
|
||||
|
||||
def test_runtime_explicit_overrides_env_and_config(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
first_root = root / "a-root"
|
||||
second_root = root / "z-root"
|
||||
first = first_root / "1.44.1"
|
||||
second = second_root / "1.44.1"
|
||||
state = root / "state"
|
||||
(first / "Beat Saber_Data").mkdir(parents=True)
|
||||
(second / "Beat Saber_Data").mkdir(parents=True)
|
||||
(second / "Plugins").mkdir()
|
||||
(second / "Plugins" / "Example.dll").write_bytes(b"dll")
|
||||
|
||||
answers = iter(["2", "3", "q"])
|
||||
output = StringIO()
|
||||
with patch("builtins.input", side_effect=lambda _: next(answers)), patch("sys.stdout", output):
|
||||
status = run(
|
||||
[
|
||||
"--instances-root",
|
||||
os.pathsep.join([str(first_root), str(second_root)]),
|
||||
"--state-dir",
|
||||
str(state),
|
||||
"menu",
|
||||
]
|
||||
(root / "plugin-helper.local.toml").write_text(
|
||||
"""
|
||||
instances_root = "config-root"
|
||||
state_dir = "config-state"
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"PLUGIN_HELPER_INSTANCES_ROOT": str(root / "env-root"),
|
||||
"PLUGIN_HELPER_STATE_DIR": str(root / "env-state"),
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
runtime = resolve_runtime_config(
|
||||
instances_root_value=str(root / "explicit-root"),
|
||||
state_dir_value="explicit-state",
|
||||
root=root,
|
||||
)
|
||||
|
||||
self.assertEqual(runtime.instances_roots, [root / "explicit-root"])
|
||||
self.assertEqual(runtime.state_root, root / "explicit-state")
|
||||
|
||||
def test_runtime_env_overrides_config(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "plugin-helper.local.toml").write_text(
|
||||
"""
|
||||
instances_root = "config-root"
|
||||
state_dir = "config-state"
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"PLUGIN_HELPER_INSTANCES_ROOT": f"{root / 'env-root-a'}{os.pathsep}{root / 'env-root-b'}",
|
||||
"PLUGIN_HELPER_STATE_DIR": str(root / "env-state"),
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
runtime = resolve_runtime_config(root=root)
|
||||
|
||||
self.assertEqual(runtime.instances_roots, [root / "env-root-a", root / "env-root-b"])
|
||||
self.assertEqual(runtime.state_root, root / "env-state")
|
||||
|
||||
def test_runtime_uses_local_config_before_defaults(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "plugin-helper.local.toml").write_text(
|
||||
"""
|
||||
instances_root = "config-root"
|
||||
state_dir = "config-state"
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
runtime = resolve_runtime_config(root=root)
|
||||
|
||||
self.assertEqual(runtime.instances_roots, [root / "config-root"])
|
||||
self.assertEqual(runtime.state_root, root / "config-state")
|
||||
|
||||
def test_runtime_default_state_uses_xdg_state_home(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
xdg = root / "xdg-state"
|
||||
|
||||
with patch.dict(os.environ, {"XDG_STATE_HOME": str(xdg)}, clear=True):
|
||||
runtime = resolve_runtime_config(root=root)
|
||||
|
||||
self.assertEqual(runtime.state_root, xdg / "plugin-helper")
|
||||
|
||||
def test_runtime_default_state_uses_home_local_state(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
runtime = resolve_runtime_config(root=root)
|
||||
|
||||
self.assertEqual(runtime.state_root, Path.home() / ".local" / "state" / "plugin-helper")
|
||||
|
||||
def test_no_args_prints_help_when_not_interactive(self) -> None:
|
||||
output = StringIO()
|
||||
with patch("sys.stdin.isatty", return_value=False), patch("sys.stdout", output):
|
||||
status = run([])
|
||||
|
||||
self.assertEqual(status, 2)
|
||||
self.assertIn("usage: plugin-helper", output.getvalue())
|
||||
self.assertIn("menu", output.getvalue())
|
||||
|
||||
def test_no_command_defaults_to_menu_when_interactive(self) -> None:
|
||||
with (
|
||||
patch("sys.stdin.isatty", return_value=True),
|
||||
patch("sys.stdout.isatty", return_value=True),
|
||||
patch("plugin_helper.cli._run_menu", return_value=0) as run_menu,
|
||||
):
|
||||
status = run([])
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIn("1.44.1: 1 files", output.getvalue())
|
||||
run_menu.assert_called_once()
|
||||
|
||||
def test_run_ipa_timeout_returns_control(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -242,6 +322,127 @@ class PluginHelperTests(unittest.TestCase):
|
||||
self.assertEqual(removed["removed"], ["Plugins/Example.dll"])
|
||||
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
||||
|
||||
def test_disable_plugin_removes_files_but_keeps_disabled_state(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
instance = work / "instances" / "1.40.8"
|
||||
state = work / "state"
|
||||
instance.mkdir(parents=True)
|
||||
(instance / "Beat Saber_Data").mkdir()
|
||||
(instance / "Plugins").mkdir()
|
||||
|
||||
target = instance / "Plugins" / "Example.dll"
|
||||
target.write_bytes(b"managed dll")
|
||||
installed = {
|
||||
"instance": "1.40.8",
|
||||
"plugins": {
|
||||
"example": {
|
||||
"installedAt": "2026-06-14T17:18:40Z",
|
||||
"files": [
|
||||
{
|
||||
"path": "Plugins/Example.dll",
|
||||
"sha256": sha256_file(target),
|
||||
"size": target.stat().st_size,
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
from plugin_helper.state import save_installed_state
|
||||
|
||||
save_installed_state(state, "1.40.8", installed)
|
||||
|
||||
result = disable_plugin("1.40.8", instance, state, "example")
|
||||
|
||||
self.assertEqual(result["removed"], ["Plugins/Example.dll"])
|
||||
self.assertFalse(target.exists())
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
self.assertNotIn("example", updated["plugins"])
|
||||
self.assertIn("example", updated["disabledPlugins"])
|
||||
self.assertEqual(updated["disabledPlugins"]["example"]["files"][0]["path"], "Plugins/Example.dll")
|
||||
|
||||
def test_enable_command_reinstalls_disabled_plugin_from_asset(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
instance_root = work / "instances"
|
||||
instance = instance_root / "1.40.8"
|
||||
state = work / "state"
|
||||
registry_dir = work / "registry"
|
||||
locks_dir = work / "locks"
|
||||
registry_dir.mkdir()
|
||||
locks_dir.mkdir()
|
||||
instance.mkdir(parents=True)
|
||||
(instance / "Beat Saber_Data").mkdir()
|
||||
(instance / "Plugins").mkdir()
|
||||
|
||||
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
|
||||
asset.write_bytes(b"managed dll")
|
||||
(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",
|
||||
)
|
||||
disabled = {
|
||||
"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,
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
from plugin_helper.state import save_installed_state
|
||||
|
||||
save_installed_state(state, "1.40.8", disabled)
|
||||
|
||||
with patch("plugin_helper.operations.repo_root", return_value=work):
|
||||
status = run(
|
||||
[
|
||||
"--instances-root",
|
||||
str(instance_root),
|
||||
"--state-dir",
|
||||
str(state),
|
||||
"enable",
|
||||
"--instance",
|
||||
"1.40.8",
|
||||
"example",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
self.assertIn("example", updated["plugins"])
|
||||
self.assertNotIn("example", updated["disabledPlugins"])
|
||||
|
||||
def test_zip_to_pending_targets_ipa_pending(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
@@ -850,5 +1051,175 @@ class PluginHelperTests(unittest.TestCase):
|
||||
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new")
|
||||
|
||||
|
||||
def _make_tui_fixture(root: Path, *, disabled: bool = False, hash_mismatch: bool = False) -> tuple[PluginHelperTui, Path, Path]:
|
||||
repo = root / "repo"
|
||||
instance_root = root / "instances"
|
||||
instance = instance_root / "1.40.8"
|
||||
state = root / "state"
|
||||
(repo / "registry").mkdir(parents=True)
|
||||
(repo / "locks").mkdir()
|
||||
(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")
|
||||
(repo / "registry" / "plugins.toml").write_text(
|
||||
"""
|
||||
[[plugins]]
|
||||
id = "example"
|
||||
name = "Example"
|
||||
repo = "owner/example"
|
||||
asset_patterns = ["*.dll"]
|
||||
install_strategy = "dll-to-plugins"
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(repo / "locks" / "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",
|
||||
)
|
||||
|
||||
target = instance / "Plugins" / "Example.dll"
|
||||
if disabled:
|
||||
plugins = {}
|
||||
disabled_plugins = {
|
||||
"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}],
|
||||
}
|
||||
}
|
||||
else:
|
||||
target.write_bytes(b"changed dll" if hash_mismatch else b"managed dll")
|
||||
plugins = {
|
||||
"example": {
|
||||
"installedAt": "2026-06-14T17:18:40Z",
|
||||
"files": [{"path": "Plugins/Example.dll", "sha256": sha256_file(asset), "size": asset.stat().st_size}],
|
||||
}
|
||||
}
|
||||
disabled_plugins = {}
|
||||
save_installed_state(
|
||||
state,
|
||||
"1.40.8",
|
||||
{"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins},
|
||||
)
|
||||
choice = InstallationChoice(
|
||||
install_id="test",
|
||||
install_label="Test Install",
|
||||
instance_name="1.40.8",
|
||||
instance_path=instance,
|
||||
state_root=state,
|
||||
)
|
||||
return PluginHelperTui(choices=[choice], repo_root=repo), instance, state
|
||||
|
||||
|
||||
class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_installation_picker_shows_duplicate_instances_with_state_dirs(self) -> None:
|
||||
choices = [
|
||||
InstallationChoice(
|
||||
install_id="linux",
|
||||
install_label="Linux",
|
||||
instance_name="1.44.1",
|
||||
instance_path=Path("/tmp/linux/1.44.1"),
|
||||
state_root=Path("/tmp/state-linux"),
|
||||
),
|
||||
InstallationChoice(
|
||||
install_id="windows",
|
||||
install_label="Windows",
|
||||
instance_name="1.44.1",
|
||||
instance_path=Path("/tmp/windows/1.44.1"),
|
||||
state_root=Path("/tmp/state-windows"),
|
||||
),
|
||||
]
|
||||
app = PluginHelperTui(choices=choices, repo_root=Path("/tmp/repo"))
|
||||
|
||||
async with app.run_test():
|
||||
table = app.query_one(DataTable)
|
||||
self.assertEqual(table.row_count, 2)
|
||||
self.assertEqual(app.mode, "installations")
|
||||
|
||||
async def test_space_disables_enabled_plugin_without_id_prompt(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, instance, state = _make_tui_fixture(Path(tmp))
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("enter")
|
||||
self.assertEqual(app.plugin_rows[0]["status"], "enabled")
|
||||
table = app.query_one(DataTable)
|
||||
self.assertEqual(table.get_cell_at(Coordinate(0, 0)), Text("[x]", no_wrap=True))
|
||||
await pilot.press("space")
|
||||
|
||||
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
self.assertNotIn("example", updated["plugins"])
|
||||
self.assertIn("example", updated["disabledPlugins"])
|
||||
|
||||
async def test_space_enables_disabled_plugin_from_asset(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("enter")
|
||||
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
|
||||
await pilot.press("space")
|
||||
|
||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
self.assertIn("example", updated["plugins"])
|
||||
self.assertNotIn("example", updated["disabledPlugins"])
|
||||
|
||||
async def test_disable_all_disables_enabled_plugins(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, instance, state = _make_tui_fixture(Path(tmp))
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("enter")
|
||||
await pilot.press("d")
|
||||
|
||||
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
self.assertNotIn("example", updated["plugins"])
|
||||
self.assertIn("example", updated["disabledPlugins"])
|
||||
self.assertIn("Disabled 1 plugins", app.status_message)
|
||||
|
||||
async def test_enable_all_enables_disabled_plugins(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("enter")
|
||||
await pilot.press("e")
|
||||
|
||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
self.assertIn("example", updated["plugins"])
|
||||
self.assertNotIn("example", updated["disabledPlugins"])
|
||||
self.assertIn("Enabled 1 plugins", app.status_message)
|
||||
|
||||
async def test_space_reports_hash_mismatch_without_state_update(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, instance, state = _make_tui_fixture(Path(tmp), hash_mismatch=True)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("enter")
|
||||
await pilot.press("space")
|
||||
|
||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
|
||||
updated = load_installed_state(state, "1.40.8")
|
||||
self.assertIn("example", updated["plugins"])
|
||||
self.assertNotIn("example", updated.get("disabledPlugins", {}))
|
||||
self.assertIn("hash mismatch", app.status_message)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user