diff --git a/.agents/skills/beatsaber-plugin-update-auditor/SKILL.md b/.agents/skills/beatsaber-plugin-update-auditor/SKILL.md new file mode 100644 index 0000000..152dab5 --- /dev/null +++ b/.agents/skills/beatsaber-plugin-update-auditor/SKILL.md @@ -0,0 +1,78 @@ +--- +name: beatsaber-plugin-update-auditor +description: Audit Beat Saber plugin-helper locks for available plugin updates and follow-up work. Use when the user asks to check all Beat Saber plugins for updates, compare locked mods against GitHub or BeatMods, review plugin/version compatibility tracking notes, identify PR/local/manual builds that need follow-up, or skip paid/private Patreon and Discord plugin sources during update checks. +--- + +# Beat Saber Plugin Update Auditor + +Use this skill from the `plugin-helper` repo to produce an update audit, not to +blindly update plugins. Keep public, private, and experimental sources distinct. + +## Workflow + +1. Confirm repo context: + + ```bash + test -f pyproject.toml && test -d src/plugin_helper && test -d locks && test -d registry/plugins + git status --short + ``` + +2. Choose the instance from the user request. If omitted, prefer `1.44.1` only + when that is clearly the active migration context; otherwise list instances: + + ```bash + PYTHONPATH=src .venv/bin/python -m plugin_helper instances + ``` + +3. Run the update audit with repo-local state unless the user explicitly targets + another configured profile: + + ```bash + PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state updates --instance + PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state updates --instance --json + ``` + + The command checks ordinary GitHub locks, `beatmods-*` locks via BeatMods + verified metadata, skips `patreon/` and `discord/` sources, and marks local, + PR, manual, failed-trial, or pending-smoke entries as review items. + +4. Read the compatibility tracker before proposing updates for review items: + + ```bash + sed -n '1,380p' docs/notes/install-and-verify-plugins-1.44.1.md + sed -n '1,220p' docs/notes/naluluna-mod-assistant.md + ``` + + Follow up on rows marked failed, omitted, currently failed, smoke blocked, + manual resmoke pending, local build, PR build, or local/private. Do not + reinstall failed or omitted plugins unless the notes identify a newer + compatible source. + +5. For each public update candidate, inspect source notes before changing the + lock. Prefer upstream GitHub release artifacts when available; use BeatMods + CDN only for verified fallback cases, BeatMods-only packages, inaccessible + upstream assets, or framework/library dependencies. + +6. Do not attempt automated public update checks for: + + - `patreon/NalulunaModAssistant` or Naluluna bundles + - `discord/BeatSaberPlus` or BeatSaberPlus/ChatPlexSDK bundles + - other paid or closed-source plugin zips + + Report them as skipped/manual follow-up only. + +7. If the user asks to apply an update, switch to the `beatsaber-plugin-manager` + workflow for download, lock update, planning, apply, and smoketest. + +## Output + +Summarize in four groups: + +- Public updates found: plugin, current version, candidate version, source. +- Current public locks: count only unless the user asks for every row. +- Review follow-ups: local builds, PR builds, failed trials, blocked smokes, + or notes saying compatibility work is pending. +- Skipped private sources: Patreon/Discord paid or closed-source packages. + +Mention BeatMods/GitHub API errors separately from "no update found"; those are +unknowns, not proof that the plugin is current. diff --git a/.agents/skills/beatsaber-plugin-update-auditor/agents/openai.yaml b/.agents/skills/beatsaber-plugin-update-auditor/agents/openai.yaml new file mode 100644 index 0000000..8582b96 --- /dev/null +++ b/.agents/skills/beatsaber-plugin-update-auditor/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Beat Saber Plugin Update Auditor" + short_description: "Audit plugin locks for GitHub and BeatMods updates." + default_prompt: "Check the locked Beat Saber plugins for updates and review items." diff --git a/README.md b/README.md index be37b2b..f1f2177 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,18 @@ Install assets are currently expected to already exist locally, usually under: /instances//downloads// ``` +Use `updates` to audit the version lock against obvious public update sources: + +```sh +PYTHONPATH=src python -m plugin_helper --state-dir .state updates --instance 1.44.1 +``` + +The command checks GitHub release assets for ordinary repository-backed locks +and BeatMods verified metadata for `beatmods-*` locks. Patreon and Discord +paid/private sources are skipped, and local builds, PR builds, failed +compatibility trials, and manual installs are marked for review instead of being +treated as routine updates. + ## Beat Saber Data Backups `backup-userdata` copies the mounted Windows `UserData` folder and Beat Saber diff --git a/src/plugin_helper/beatmods.py b/src/plugin_helper/beatmods.py index ad50e98..3b00e24 100644 --- a/src/plugin_helper/beatmods.py +++ b/src/plugin_helper/beatmods.py @@ -1,7 +1,10 @@ from __future__ import annotations +import json from dataclasses import dataclass from typing import Any +from urllib.parse import urlencode +from urllib.request import Request, urlopen @dataclass(frozen=True) @@ -49,6 +52,24 @@ def normalize_mods(payload: Any) -> list[BeatModsEntry]: return [normalize_entry(entry) for entry in extract_mods(payload)] +def fetch_verified_mods(game_version: str) -> list[dict[str, Any]]: + query = urlencode( + { + "status": "verified", + "gameVersion": game_version, + "gameName": "BeatSaber", + "platform": "steampc", + } + ) + request = Request( + f"https://beatmods.com/api/mods?{query}", + headers={"User-Agent": "Mozilla/5.0 plugin-helper"}, + ) + with urlopen(request, timeout=20) as response: + data = json.loads(response.read().decode("utf-8")) + return extract_mods(data) + + def by_version_id(entries: list[BeatModsEntry]) -> dict[int, BeatModsEntry]: return {entry.version_id: entry for entry in entries if entry.version_id is not None} diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index 9b4544c..07018f8 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any from .config import repo_root, resolve_runtime_config +from .beatmods import fetch_verified_mods from .bootstrap import run_bootstrap from .bsipa import check_bsipa_health from .checker import check_lock @@ -33,12 +34,13 @@ def print_updates(report: dict[str, Any]) -> None: print( f"{report['instance']} updates: " f"{summary['updates']} available, {summary['current']} current, " + f"{summary.get('reviews', 0)} review, {summary.get('skipped', 0)} skipped, " f"{summary['warnings']} warnings, {summary['errors']} errors" ) if not plugins: return - headers = ("Plugin", "Current", "Latest", "Asset", "Status") + headers = ("Plugin", "Current", "Latest", "Asset", "Status", "Notes") rows = [ ( f"{plugin['name']} ({plugin['id']})", @@ -46,6 +48,7 @@ def print_updates(report: dict[str, Any]) -> None: plugin.get("latestTag") or "(unknown)", plugin.get("latestAsset") or plugin.get("currentAsset") or "(unknown)", plugin["status"], + ", ".join(plugin.get("reviewReasons") or plugin.get("messages") or ()), ) for plugin in plugins ] @@ -142,7 +145,7 @@ def build_parser() -> argparse.ArgumentParser: updates = subcommands.add_parser( "updates", - help="Check GitHub for newer matching releases for locked plugins", + help="Check GitHub and BeatMods for newer matching releases for locked plugins", parents=[_common_parent()], ) updates.add_argument("--instance", required=True) @@ -406,6 +409,7 @@ def run(argv: list[str] | None = None) -> int: registry=load_registry(registry_path), lockfile=load_lockfile(lock_path), fetch_releases=fetch_releases, + fetch_beatmods=fetch_verified_mods, selected=set(args.plugin) if args.plugin else None, include_prerelease=args.include_prerelease, ) diff --git a/src/plugin_helper/updates.py b/src/plugin_helper/updates.py index 935a13b..e3a36e6 100644 --- a/src/plugin_helper/updates.py +++ b/src/plugin_helper/updates.py @@ -4,10 +4,28 @@ import fnmatch import re from typing import Any, Callable +from .beatmods import BeatModsEntry, normalize_mods from .models import Lockfile, Registry FetchReleases = Callable[[str], list[dict[str, Any]]] +FetchBeatMods = Callable[[str], list[dict[str, Any]]] + +PRIVATE_SOURCE_PREFIXES = ("discord/", "patreon/") +REVIEW_PATTERNS = ( + "local build", + "localbuild", + "github pr", + "/pull/", + " pr ", + "manual", + "compatibility trial", + "failed compatibility", + "currently failed", + "smoke blocked", + "resmoke pending", + "do not reinstall", +) def _release_tag(release: dict[str, Any]) -> str: @@ -23,6 +41,71 @@ def _asset_name(asset: dict[str, Any]) -> str: return str(asset.get("name") or "") +def _source_kind(repo: str | None) -> str: + if not repo: + return "missing" + lowered = repo.lower() + if lowered.startswith(PRIVATE_SOURCE_PREFIXES): + return "private" + if re.fullmatch(r"[^/\s]+/[^/\s]+", repo): + return "github" + return "unknown" + + +def _is_beatmods_locked(tag: str | None, reason: str | None) -> bool: + return str(tag or "").startswith("beatmods-") or "beatmods" in str(reason or "").lower() + + +def _current_beatmods_version(tag: str | None, reason: str | None, asset: str | None) -> str | None: + if tag and tag.startswith("beatmods-"): + return tag.removeprefix("beatmods-") + for text in (reason, asset): + if not text: + continue + match = re.search(r"\bBeatMods(?:\s+verified)?\s+[^0-9]*(\d+(?:\.\d+){1,4})\b", text, re.I) + if match: + return match.group(1) + match = re.search(r"-(\d+(?:\.\d+){1,4})(?:\.zip|\+|-|$)", text) + if match: + return match.group(1) + return None + + +def _normalized_name(text: str) -> str: + return re.sub(r"[^a-z0-9]+", "", text.lower()) + + +def _beatmods_match( + entries: list[BeatModsEntry], + *, + plugin_id: str, + plugin_name: str, +) -> BeatModsEntry | None: + wanted = {_normalized_name(plugin_id), _normalized_name(plugin_name)} + for entry in entries: + if _normalized_name(entry.name) in wanted: + return entry + return None + + +def _review_reasons(tag: str | None, reason: str | None) -> list[str]: + text = f"{tag or ''} {reason or ''}".lower() + reasons: list[str] = [] + if any(pattern in text for pattern in ("local build", "localbuild")): + reasons.append("local build") + if any(pattern in text for pattern in ("github pr", "/pull/", " pr ")) or str(tag or "").startswith("pr-"): + reasons.append("pull request build") + if "failed" in text or "compatibility trial" in text: + reasons.append("compatibility failure history") + if "smoke blocked" in text or "resmoke pending" in text or "currently failed" in text: + reasons.append("verification follow-up pending") + if "manual" in text: + reasons.append("manual install notes") + if not reasons and any(pattern in text for pattern in REVIEW_PATTERNS): + reasons.append("special handling noted") + return reasons + + def _semver_key(tag: str) -> tuple[int, tuple[int, ...], str]: match = re.search(r"(\d+(?:\.\d+){0,3})", tag) if not match: @@ -99,18 +182,22 @@ def check_updates( registry: Registry, lockfile: Lockfile, fetch_releases: FetchReleases, + fetch_beatmods: FetchBeatMods | None = None, selected: set[str] | None = None, include_prerelease: bool = False, ) -> dict[str, Any]: selected_ids = selected or {plugin.id for plugin in lockfile.plugins} plugins: list[dict[str, Any]] = [] - summary = {"current": 0, "updates": 0, "warnings": 0, "errors": 0} + summary = {"current": 0, "updates": 0, "warnings": 0, "errors": 0, "skipped": 0, "reviews": 0} + beatmods_entries: list[BeatModsEntry] | None = None + beatmods_error: Exception | None = None for locked in lockfile.plugins: if locked.id not in selected_ids: continue registry_plugin = registry.get(locked.id) repo = locked.repo or (registry_plugin.repo if registry_plugin else None) + review_reasons = _review_reasons(locked.tag, locked.reason) entry: dict[str, Any] = { "id": locked.id, "name": registry_plugin.name if registry_plugin else locked.id, @@ -123,13 +210,106 @@ def check_updates( "latestAssetSha256": None, "status": "unknown", "messages": [], + "review": bool(review_reasons), + "reviewReasons": review_reasons, } + if review_reasons: + summary["reviews"] += 1 + + source_kind = _source_kind(repo) + if source_kind == "private": + entry["status"] = "skipped" + entry["messages"].append("paid/private source; check manually outside public update APIs") + summary["skipped"] += 1 + plugins.append(entry) + continue + + needs_manual_review = any( + reason in review_reasons + for reason in ( + "local build", + "pull request build", + "verification follow-up pending", + "manual install notes", + ) + ) + if review_reasons and (needs_manual_review or not _is_beatmods_locked(locked.tag, locked.reason)): + entry["status"] = "review" + entry["messages"].append("special install or compatibility history; review notes before updating") + plugins.append(entry) + continue + + if _is_beatmods_locked(locked.tag, locked.reason): + if fetch_beatmods is None: + entry["status"] = "warning" + entry["messages"].append("BeatMods check unavailable") + summary["warnings"] += 1 + plugins.append(entry) + continue + if beatmods_entries is None and beatmods_error is None: + try: + beatmods_entries = normalize_mods(fetch_beatmods(lockfile.beat_saber_version)) + except Exception as exc: + beatmods_error = exc + if beatmods_error is not None: + entry["status"] = "error" + entry["messages"].append(f"BeatMods: {beatmods_error}") + summary["errors"] += 1 + plugins.append(entry) + continue + + beatmods_match = _beatmods_match( + beatmods_entries or [], + plugin_id=locked.id, + plugin_name=entry["name"], + ) + if beatmods_match is not None and beatmods_match.mod_version: + current_version = _current_beatmods_version(locked.tag, locked.reason, locked.asset) + entry["latestTag"] = f"beatmods-{beatmods_match.mod_version}" + entry["latestAsset"] = ( + f"{beatmods_match.name}-{beatmods_match.mod_version}.zip" + if beatmods_match.name + else None + ) + entry["latestBeatModsVersionId"] = beatmods_match.version_id + entry["latestBeatModsZipHash"] = beatmods_match.zip_hash + entry["latestAssetUrl"] = ( + f"https://beatmods.com/cdn/mod/{beatmods_match.zip_hash}.zip" + if beatmods_match.zip_hash + else None + ) + if current_version == beatmods_match.mod_version: + entry["status"] = "current" + summary["current"] += 1 + else: + entry["status"] = "update" + entry["messages"].append( + f"BeatMods verified {beatmods_match.mod_version} for Beat Saber {lockfile.beat_saber_version}" + ) + summary["updates"] += 1 + plugins.append(entry) + continue + + if source_kind != "github": + entry["status"] = "warning" + entry["messages"].append("no matching BeatMods verified entry found") + summary["warnings"] += 1 + plugins.append(entry) + continue + entry["messages"].append("no matching BeatMods verified entry found; falling back to GitHub") + if not repo: entry["status"] = "warning" entry["messages"].append("missing repository") summary["warnings"] += 1 plugins.append(entry) continue + if source_kind != "github": + entry["status"] = "warning" + entry["messages"].append("unsupported repository source") + summary["warnings"] += 1 + plugins.append(entry) + continue try: releases = fetch_releases(repo) diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 6c5111a..9863093 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -1703,6 +1703,129 @@ instance = "1.40.8" self.assertEqual(result["plugins"][0]["status"], "update") self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new") + def test_update_check_reports_beatmods_update(self) -> None: + registry = Registry( + { + "songcore": RegistryPlugin( + id="songcore", + name="SongCore", + repo="Kylemc1413/SongCore", + asset_patterns=("SongCore-*.zip",), + install_strategy="bsipa-zip", + ) + } + ) + lockfile = Lockfile( + beat_saber_version="1.44.1", + instance="1.44.1", + plugins=( + LockedPlugin( + id="songcore", + repo="Kylemc1413/SongCore", + tag="beatmods-3.16.0", + asset="SongCore-3.16.0.zip", + sha256="old", + ), + ), + ) + + result = check_updates( + registry=registry, + lockfile=lockfile, + fetch_releases=lambda repo: [], + fetch_beatmods=lambda game_version: [ + { + "mod": {"id": 1, "name": "SongCore", "gitUrl": "https://github.com/Kylemc1413/SongCore"}, + "latest": {"id": 2600, "modVersion": "3.17.0", "zipHash": "abc"}, + } + ], + ) + + self.assertEqual(result["summary"]["updates"], 1) + self.assertEqual(result["plugins"][0]["status"], "update") + self.assertEqual(result["plugins"][0]["latestTag"], "beatmods-3.17.0") + self.assertEqual(result["plugins"][0]["latestBeatModsZipHash"], "abc") + + def test_update_check_skips_private_sources(self) -> None: + registry = Registry( + { + "naluluna": RegistryPlugin( + id="naluluna", + name="Naluluna", + repo="patreon/NalulunaModAssistant", + install_strategy="root-zip", + ) + } + ) + lockfile = Lockfile( + beat_saber_version="1.44.1", + instance="1.44.1", + plugins=( + LockedPlugin( + id="naluluna", + repo="patreon/NalulunaModAssistant", + tag="installed-1.44.1", + asset="Naluluna-1.44.1-installed-bundle.zip", + sha256="hash", + ), + ), + ) + + result = check_updates( + registry=registry, + lockfile=lockfile, + fetch_releases=lambda repo: self.fail("private sources should not query GitHub"), + fetch_beatmods=lambda game_version: self.fail("private sources should not query BeatMods"), + ) + + self.assertEqual(result["summary"]["skipped"], 1) + self.assertEqual(result["plugins"][0]["status"], "skipped") + self.assertIn("paid/private", result["plugins"][0]["messages"][0]) + + def test_update_check_marks_local_pr_build_for_review(self) -> None: + registry = Registry( + { + "jdfixer": RegistryPlugin( + id="jdfixer", + name="JDFixer", + repo="zeph-yr/JDFixer", + asset_patterns=("JDFixer.dll",), + install_strategy="dll-to-plugins", + ) + } + ) + lockfile = Lockfile( + beat_saber_version="1.44.1", + instance="1.44.1", + plugins=( + LockedPlugin( + id="jdfixer", + repo="zeph-yr/JDFixer", + tag="pr-26-3fce6ce", + asset="JDFixer.dll", + sha256="hash", + reason=( + "Local build from GitHub PR https://github.com/zeph-yr/JDFixer/pull/26. " + "Use this PR build instead of the failed upstream release asset." + ), + ), + ), + ) + + result = check_updates( + registry=registry, + lockfile=lockfile, + fetch_releases=lambda repo: self.fail("review builds should not be treated as routine GitHub checks"), + ) + + self.assertEqual(result["summary"]["reviews"], 1) + self.assertEqual(result["plugins"][0]["status"], "review") + self.assertEqual(result["plugins"][0]["reviewReasons"], [ + "local build", + "pull request build", + "compatibility failure history", + ]) + def _make_tui_fixture( root: Path,