from __future__ import annotations import os from collections.abc import Callable from dataclasses import replace from pathlib import Path from typing import Any from urllib.request import Request, urlopen from .fsutil import sha256_file from .installer import apply_plan from .models import LockedPlugin, Lockfile, Registry, replace_locked_plugins, write_lockfile from .planner import create_plan from .state import plugin_downloads_dir from .updates import FetchBeatMods, FetchReleases, check_updates DownloadAsset = Callable[[str, Path], dict[str, Any]] ApplyPlan = Callable[[dict[str, Any], Path], dict[str, Any]] def _download_asset(url: str, destination: Path) -> dict[str, Any]: destination.parent.mkdir(parents=True, exist_ok=True) headers = {"User-Agent": "plugin-helper"} token = os.environ.get("GITHUB_TOKEN") if token: headers["Authorization"] = f"Bearer {token}" request = Request(url, headers=headers) with urlopen(request, timeout=60) as response: destination.write_bytes(response.read()) return {"url": url, "path": str(destination), "sha256": sha256_file(destination)} def _reason_for_update(plugin: dict[str, Any], *, actual_sha256: str, beat_saber_version: str) -> str: if plugin.get("latestBeatModsVersionId") is not None: return ( f"BeatMods verified {plugin['name']} " f"{str(plugin.get('latestTag') or '').removeprefix('beatmods-')} " f"for Beat Saber {beat_saber_version} as version id {plugin['latestBeatModsVersionId']}, " f"zipHash {plugin.get('latestBeatModsZipHash')}. " f"Downloaded from BeatMods CDN and verified SHA-256 {actual_sha256}." ) digest = plugin.get("latestAssetSha256") digest_note = ( f"GitHub release digest matched {digest}." if digest else f"GitHub release did not publish a digest; computed SHA-256 {actual_sha256}." ) release = f" release {plugin.get('latestUrl')}" if plugin.get("latestUrl") else "" return ( f"Updated from GitHub {plugin.get('repo')} tag {plugin.get('latestTag')}{release}, " f"asset {plugin.get('latestAsset')}. {digest_note}" ) def _updated_locked_plugin( locked: LockedPlugin, plugin: dict[str, Any], *, actual_sha256: str, beat_saber_version: str, ) -> LockedPlugin: return replace( locked, tag=plugin.get("latestTag"), asset=plugin.get("latestAsset"), sha256=actual_sha256, download_url=plugin.get("latestAssetUrl"), reason=_reason_for_update( plugin, actual_sha256=actual_sha256, beat_saber_version=beat_saber_version, ), ) def run_update( *, instance: str, instance_path: Path, registry: Registry, lockfile: Lockfile, lock_path: Path, state_root: Path, repo_root: Path, selected: set[str], fetch_releases: FetchReleases, fetch_beatmods: FetchBeatMods | None = None, include_prerelease: bool = False, dry_run: bool = False, apply: bool = False, download_asset: DownloadAsset = _download_asset, apply_plan_func: ApplyPlan = apply_plan, install_id: str | None = None, ) -> dict[str, Any]: if not selected: raise ValueError("update requires at least one --plugin") report = check_updates( registry=registry, lockfile=lockfile, fetch_releases=fetch_releases, fetch_beatmods=fetch_beatmods, selected=selected, include_prerelease=include_prerelease, ) locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins} reported_ids = {plugin["id"] for plugin in report["plugins"]} updated: list[dict[str, Any]] = [] refused: list[dict[str, Any]] = [] downloads: list[dict[str, Any]] = [] replacements: dict[str, LockedPlugin] = {} for missing_id in sorted(selected - reported_ids): refused.append( { "plugin": missing_id, "status": "error", "messages": ["plugin is not locked for this instance"], "reviewReasons": [], } ) for plugin in report["plugins"]: plugin_id = plugin["id"] if plugin["status"] != "update": refused.append( { "plugin": plugin_id, "status": plugin["status"], "messages": plugin.get("messages", []), "reviewReasons": plugin.get("reviewReasons", []), } ) continue if not plugin.get("latestAsset") or not plugin.get("latestAssetUrl") or not plugin.get("latestTag"): refused.append( { "plugin": plugin_id, "status": "error", "messages": ["update candidate is missing tag, asset, or download URL"], "reviewReasons": [], } ) continue if plugin_id not in locked_by_id: refused.append( { "plugin": plugin_id, "status": "error", "messages": ["plugin is not locked for this instance"], "reviewReasons": [], } ) continue if dry_run: updated.append( { "plugin": plugin_id, "fromTag": plugin.get("currentTag"), "toTag": plugin.get("latestTag"), "asset": plugin.get("latestAsset"), "url": plugin.get("latestAssetUrl"), "dryRun": True, } ) continue destination = plugin_downloads_dir(state_root, instance, plugin_id) / plugin["latestAsset"] downloaded = download_asset(plugin["latestAssetUrl"], destination) actual_sha = str(downloaded.get("sha256") or sha256_file(destination)) expected_sha = plugin.get("latestAssetSha256") if expected_sha and actual_sha != expected_sha: destination.unlink(missing_ok=True) raise ValueError(f"{plugin_id}: downloaded asset sha256 mismatch") downloads.append( { "plugin": plugin_id, "asset": plugin["latestAsset"], "path": str(destination), "url": plugin["latestAssetUrl"], "sha256": actual_sha, } ) replacements[plugin_id] = _updated_locked_plugin( locked_by_id[plugin_id], plugin, actual_sha256=actual_sha, beat_saber_version=report["beatSaberVersion"], ) updated.append( { "plugin": plugin_id, "fromTag": plugin.get("currentTag"), "toTag": plugin.get("latestTag"), "asset": plugin.get("latestAsset"), "sha256": actual_sha, } ) result: dict[str, Any] = { "instance": report["instance"], "beatSaberVersion": report["beatSaberVersion"], "lockfile": str(lock_path), "updated": updated, "refused": refused, "downloads": downloads, "planPath": None, "applied": [], "dryRun": dry_run, } if dry_run or not replacements: return result updated_lockfile = replace_locked_plugins(lockfile, replacements) plan, plan_path = create_plan( instance=instance, instance_path=instance_path, beat_saber_version=updated_lockfile.beat_saber_version, registry=registry, lockfile=updated_lockfile, state_root=state_root, repo_root=repo_root, selected=set(replacements), install_id=install_id, ) result["planPath"] = str(plan_path) if apply: apply_result = apply_plan_func(plan, state_root) result["applied"] = apply_result["applied"] result["statePath"] = apply_result["statePath"] write_lockfile(lock_path, updated_lockfile) return result