Enhance script for handling updates

This commit is contained in:
pleb
2026-07-09 19:08:08 -07:00
parent 1dbe80a36f
commit ef673a76de
5 changed files with 822 additions and 2 deletions
+60
View File
@@ -20,6 +20,7 @@ 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 .update_runner import run_update
from .updates import check_updates
from .userdata import restore_windows_data_repo, sync_windows_data_repo
@@ -155,6 +156,21 @@ def build_parser() -> argparse.ArgumentParser:
updates.add_argument("--include-prerelease", action="store_true", help="Include prerelease GitHub releases")
updates.add_argument("--json", action="store_true", help="Print full JSON update output")
update = subcommands.add_parser(
"update",
help="Download selected update candidates, update the lock, and create an install plan",
parents=[_common_parent()],
)
update.add_argument("--instance", required=True)
update.add_argument("--registry", default="registry/plugins")
update.add_argument("--lockfile")
update.add_argument("--plugin", action="append", required=True, help="Update this locked plugin id; repeatable")
update.add_argument("--include-prerelease", action="store_true", help="Include prerelease GitHub releases")
update.add_argument("--json", action="store_true", help="Print full JSON update output")
update_mode = update.add_mutually_exclusive_group()
update_mode.add_argument("--apply", action="store_true", help="Apply the generated update plan")
update_mode.add_argument("--dry-run", action="store_true", help="Show selected updates without downloading or writing")
plan = subcommands.add_parser(
"plan",
help="Create a dry-run install plan from the catalog and version lock",
@@ -419,6 +435,50 @@ def run(argv: list[str] | None = None) -> int:
print_updates(result)
return 2 if result["summary"]["errors"] else 0
if args.command == "update":
instance = get_instance(inst_roots, args.instance)
root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve()
result = run_update(
instance=args.instance,
instance_path=instance.path,
registry=load_registry(registry_path),
lockfile=load_lockfile(lock_path),
lock_path=lock_path,
state_root=st_root,
repo_root=root,
selected=set(args.plugin),
fetch_releases=fetch_releases,
fetch_beatmods=fetch_verified_mods,
include_prerelease=args.include_prerelease,
dry_run=args.dry_run,
apply=args.apply,
)
if args.json:
_json(result)
else:
action = "Would update" if result["dryRun"] else "Updated"
print(f"{action}: {len(result['updated'])}")
for item in result["updated"]:
print(f" {item['plugin']}: {item.get('fromTag')} -> {item.get('toTag')} ({item.get('asset')})")
if result["refused"]:
print("Refused:")
for item in result["refused"]:
notes = ", ".join(item.get("reviewReasons") or item.get("messages") or ())
suffix = f": {notes}" if notes else ""
print(f" {item['plugin']}: {item['status']}{suffix}")
if result.get("planPath"):
print(f"Plan: {result['planPath']}")
if not result["dryRun"] and result["updated"]:
print(f"Lockfile: {result['lockfile']}")
if args.apply:
print(f"Applied: {len(result['applied'])}")
print(f"State: {result.get('statePath')}")
return 0 if not result["refused"] else 2
if args.command == "bootstrap":
instance = get_instance(inst_roots, args.instance)
root = repo_root()
+42 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any
@@ -53,6 +53,47 @@ class Lockfile:
plugins: tuple[LockedPlugin, ...]
def _quote_toml_string(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def lockfile_to_toml(lockfile: Lockfile) -> str:
lines = [
f"beat_saber_version = {_quote_toml_string(lockfile.beat_saber_version)}",
f"instance = {_quote_toml_string(lockfile.instance)}",
"",
]
for plugin in lockfile.plugins:
lines.append("[[plugins]]")
lines.append(f"id = {_quote_toml_string(plugin.id)}")
if plugin.repo is not None:
lines.append(f"repo = {_quote_toml_string(plugin.repo)}")
if plugin.tag is not None:
lines.append(f"tag = {_quote_toml_string(plugin.tag)}")
if plugin.asset is not None:
lines.append(f"asset = {_quote_toml_string(plugin.asset)}")
if plugin.sha256 is not None:
lines.append(f"sha256 = {_quote_toml_string(plugin.sha256)}")
if plugin.install_strategy is not None:
lines.append(f"install_strategy = {_quote_toml_string(plugin.install_strategy)}")
if plugin.reason is not None:
lines.append(f"reason = {_quote_toml_string(plugin.reason)}")
lines.append("")
return "\n".join(lines)
def write_lockfile(path: Path, lockfile: Lockfile) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(lockfile_to_toml(lockfile), encoding="utf-8")
def replace_locked_plugins(lockfile: Lockfile, replacements: dict[str, LockedPlugin]) -> Lockfile:
return replace(
lockfile,
plugins=tuple(replacements.get(plugin.id, plugin) for plugin in lockfile.plugins),
)
def _load_toml(path: Path) -> dict[str, Any]:
with path.open("rb") as handle:
return tomllib.load(handle)
+236
View File
@@ -0,0 +1,236 @@
from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import replace
from pathlib import Path
from typing import Any
from urllib.request import Request, urlopen
from .fsutil import sha256_file
from .installer import apply_plan
from .models import LockedPlugin, Lockfile, Registry, replace_locked_plugins, write_lockfile
from .planner import create_plan
from .state import plugin_downloads_dir
from .updates import FetchBeatMods, FetchReleases, check_updates
DownloadAsset = Callable[[str, Path], dict[str, Any]]
ApplyPlan = Callable[[dict[str, Any], Path], dict[str, Any]]
def _download_asset(url: str, destination: Path) -> dict[str, Any]:
destination.parent.mkdir(parents=True, exist_ok=True)
headers = {"User-Agent": "plugin-helper"}
token = os.environ.get("GITHUB_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
request = Request(url, headers=headers)
with urlopen(request, timeout=60) as response:
destination.write_bytes(response.read())
return {"url": url, "path": str(destination), "sha256": sha256_file(destination)}
def _reason_for_update(plugin: dict[str, Any], *, actual_sha256: str, beat_saber_version: str) -> str:
if plugin.get("latestBeatModsVersionId") is not None:
return (
f"BeatMods verified {plugin['name']} "
f"{str(plugin.get('latestTag') or '').removeprefix('beatmods-')} "
f"for Beat Saber {beat_saber_version} as version id {plugin['latestBeatModsVersionId']}, "
f"zipHash {plugin.get('latestBeatModsZipHash')}. "
f"Downloaded from BeatMods CDN and verified SHA-256 {actual_sha256}."
)
digest = plugin.get("latestAssetSha256")
digest_note = (
f"GitHub release digest matched {digest}."
if digest
else f"GitHub release did not publish a digest; computed SHA-256 {actual_sha256}."
)
release = f" release {plugin.get('latestUrl')}" if plugin.get("latestUrl") else ""
return (
f"Updated from GitHub {plugin.get('repo')} tag {plugin.get('latestTag')}{release}, "
f"asset {plugin.get('latestAsset')}. {digest_note}"
)
def _updated_locked_plugin(
locked: LockedPlugin,
plugin: dict[str, Any],
*,
actual_sha256: str,
beat_saber_version: str,
) -> LockedPlugin:
return replace(
locked,
tag=plugin.get("latestTag"),
asset=plugin.get("latestAsset"),
sha256=actual_sha256,
reason=_reason_for_update(
plugin,
actual_sha256=actual_sha256,
beat_saber_version=beat_saber_version,
),
)
def run_update(
*,
instance: str,
instance_path: Path,
registry: Registry,
lockfile: Lockfile,
lock_path: Path,
state_root: Path,
repo_root: Path,
selected: set[str],
fetch_releases: FetchReleases,
fetch_beatmods: FetchBeatMods | None = None,
include_prerelease: bool = False,
dry_run: bool = False,
apply: bool = False,
download_asset: DownloadAsset = _download_asset,
apply_plan_func: ApplyPlan = apply_plan,
) -> dict[str, Any]:
if not selected:
raise ValueError("update requires at least one --plugin")
report = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=fetch_releases,
fetch_beatmods=fetch_beatmods,
selected=selected,
include_prerelease=include_prerelease,
)
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
reported_ids = {plugin["id"] for plugin in report["plugins"]}
updated: list[dict[str, Any]] = []
refused: list[dict[str, Any]] = []
downloads: list[dict[str, Any]] = []
replacements: dict[str, LockedPlugin] = {}
for missing_id in sorted(selected - reported_ids):
refused.append(
{
"plugin": missing_id,
"status": "error",
"messages": ["plugin is not locked for this instance"],
"reviewReasons": [],
}
)
for plugin in report["plugins"]:
plugin_id = plugin["id"]
if plugin["status"] != "update":
refused.append(
{
"plugin": plugin_id,
"status": plugin["status"],
"messages": plugin.get("messages", []),
"reviewReasons": plugin.get("reviewReasons", []),
}
)
continue
if not plugin.get("latestAsset") or not plugin.get("latestAssetUrl") or not plugin.get("latestTag"):
refused.append(
{
"plugin": plugin_id,
"status": "error",
"messages": ["update candidate is missing tag, asset, or download URL"],
"reviewReasons": [],
}
)
continue
if plugin_id not in locked_by_id:
refused.append(
{
"plugin": plugin_id,
"status": "error",
"messages": ["plugin is not locked for this instance"],
"reviewReasons": [],
}
)
continue
if dry_run:
updated.append(
{
"plugin": plugin_id,
"fromTag": plugin.get("currentTag"),
"toTag": plugin.get("latestTag"),
"asset": plugin.get("latestAsset"),
"url": plugin.get("latestAssetUrl"),
"dryRun": True,
}
)
continue
destination = plugin_downloads_dir(state_root, instance, plugin_id) / plugin["latestAsset"]
downloaded = download_asset(plugin["latestAssetUrl"], destination)
actual_sha = str(downloaded.get("sha256") or sha256_file(destination))
expected_sha = plugin.get("latestAssetSha256")
if expected_sha and actual_sha != expected_sha:
destination.unlink(missing_ok=True)
raise ValueError(f"{plugin_id}: downloaded asset sha256 mismatch")
downloads.append(
{
"plugin": plugin_id,
"asset": plugin["latestAsset"],
"path": str(destination),
"url": plugin["latestAssetUrl"],
"sha256": actual_sha,
}
)
replacements[plugin_id] = _updated_locked_plugin(
locked_by_id[plugin_id],
plugin,
actual_sha256=actual_sha,
beat_saber_version=report["beatSaberVersion"],
)
updated.append(
{
"plugin": plugin_id,
"fromTag": plugin.get("currentTag"),
"toTag": plugin.get("latestTag"),
"asset": plugin.get("latestAsset"),
"sha256": actual_sha,
}
)
result: dict[str, Any] = {
"instance": report["instance"],
"beatSaberVersion": report["beatSaberVersion"],
"lockfile": str(lock_path),
"updated": updated,
"refused": refused,
"downloads": downloads,
"planPath": None,
"applied": [],
"dryRun": dry_run,
}
if dry_run or not replacements:
return result
updated_lockfile = replace_locked_plugins(lockfile, replacements)
plan, plan_path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=updated_lockfile.beat_saber_version,
registry=registry,
lockfile=updated_lockfile,
state_root=state_root,
repo_root=repo_root,
selected=set(replacements),
)
result["planPath"] = str(plan_path)
if apply:
apply_result = apply_plan_func(plan, state_root)
result["applied"] = apply_result["applied"]
result["statePath"] = apply_result["statePath"]
write_lockfile(lock_path, updated_lockfile)
return result