Add plugin helper with agent skill for updating plugins
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zipfile import ZipFile
|
||||
|
||||
from .fsutil import ensure_relative, sha256_bytes, sha256_file
|
||||
from .models import Lockfile, Registry, VALID_STRATEGIES
|
||||
from .state import downloads_dir, plans_dir
|
||||
|
||||
|
||||
ALLOWED_BSIPA_TOP_LEVEL = {"IPA", "Libs", "Plugins"}
|
||||
|
||||
|
||||
def _now_slug() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def _find_asset(asset: str, state_root: Path, instance: str, repo_root: Path) -> Path | None:
|
||||
candidates = [
|
||||
Path(asset).expanduser(),
|
||||
downloads_dir(state_root, instance) / asset,
|
||||
repo_root / "assets" / asset,
|
||||
repo_root / "locks" / "assets" / asset,
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _zip_members(asset_path: Path) -> list[tuple[str, int, str]]:
|
||||
result: list[tuple[str, int, str]] = []
|
||||
with ZipFile(asset_path) as archive:
|
||||
for info in archive.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
rel = ensure_relative(info.filename).as_posix()
|
||||
data = archive.read(info)
|
||||
result.append((rel, len(data), sha256_bytes(data)))
|
||||
return result
|
||||
|
||||
|
||||
def _target_for_member(strategy: str, member: str) -> str:
|
||||
rel = ensure_relative(member).as_posix()
|
||||
if strategy == "zip-to-pending":
|
||||
return f"IPA/Pending/{rel}"
|
||||
if strategy == "root-zip":
|
||||
return rel
|
||||
if strategy == "bsipa-zip":
|
||||
top = rel.split("/", 1)[0]
|
||||
if top not in ALLOWED_BSIPA_TOP_LEVEL:
|
||||
raise ValueError(f"bsipa-zip member has unsupported top-level path: {rel}")
|
||||
return rel
|
||||
raise ValueError(f"unsupported zip strategy: {strategy}")
|
||||
|
||||
|
||||
def _asset_matches_patterns(name: str, patterns: tuple[str, ...]) -> bool:
|
||||
return not patterns or any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
|
||||
|
||||
|
||||
def create_plan(
|
||||
*,
|
||||
instance: str,
|
||||
instance_path: Path,
|
||||
beat_saber_version: str,
|
||||
registry: Registry,
|
||||
lockfile: Lockfile,
|
||||
state_root: Path,
|
||||
repo_root: Path,
|
||||
selected: set[str] | None = None,
|
||||
) -> tuple[dict[str, Any], Path]:
|
||||
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
|
||||
changes: list[dict[str, Any]] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
for locked in lockfile.plugins:
|
||||
if locked.id not in selected_ids:
|
||||
continue
|
||||
registry_plugin = registry.get(locked.id)
|
||||
strategy = locked.install_strategy or (registry_plugin.install_strategy if registry_plugin else "manual")
|
||||
if strategy not in VALID_STRATEGIES:
|
||||
raise ValueError(f"{locked.id}: invalid install strategy: {strategy}")
|
||||
if strategy == "manual":
|
||||
raise ValueError(f"{locked.id}: install_strategy is manual; add a concrete registry rule first")
|
||||
if not locked.asset:
|
||||
raise ValueError(f"{locked.id}: lock entry has no asset")
|
||||
if registry_plugin and not _asset_matches_patterns(Path(locked.asset).name, registry_plugin.asset_patterns):
|
||||
warnings.append(f"{locked.id}: asset does not match registry patterns")
|
||||
|
||||
asset_path = _find_asset(locked.asset, state_root, instance, repo_root)
|
||||
if not asset_path:
|
||||
raise FileNotFoundError(
|
||||
f"{locked.id}: asset not found: {locked.asset}; put it in {downloads_dir(state_root, instance)}"
|
||||
)
|
||||
asset_sha = sha256_file(asset_path)
|
||||
if locked.sha256 and locked.sha256 != asset_sha:
|
||||
raise ValueError(f"{locked.id}: asset sha256 mismatch for {asset_path}")
|
||||
|
||||
if strategy == "dll-to-plugins":
|
||||
if asset_path.suffix.lower() != ".dll":
|
||||
raise ValueError(f"{locked.id}: dll-to-plugins expects a .dll asset")
|
||||
changes.append(
|
||||
{
|
||||
"plugin": locked.id,
|
||||
"action": "copy",
|
||||
"source": str(asset_path),
|
||||
"sourceSha256": asset_sha,
|
||||
"target": f"Plugins/{asset_path.name}",
|
||||
"size": asset_path.stat().st_size,
|
||||
"sha256": asset_sha,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if asset_path.suffix.lower() != ".zip":
|
||||
raise ValueError(f"{locked.id}: {strategy} expects a .zip asset")
|
||||
for member, size, member_sha in _zip_members(asset_path):
|
||||
changes.append(
|
||||
{
|
||||
"plugin": locked.id,
|
||||
"action": "extract",
|
||||
"source": str(asset_path),
|
||||
"sourceSha256": asset_sha,
|
||||
"archiveMember": member,
|
||||
"target": _target_for_member(strategy, member),
|
||||
"size": size,
|
||||
"sha256": member_sha,
|
||||
}
|
||||
)
|
||||
|
||||
plan = {
|
||||
"schemaVersion": 1,
|
||||
"createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"instance": instance,
|
||||
"instancePath": str(instance_path),
|
||||
"beatSaberVersion": beat_saber_version,
|
||||
"warnings": warnings,
|
||||
"changes": changes,
|
||||
}
|
||||
plan_path = plans_dir(state_root, instance) / f"plan-{_now_slug()}.json"
|
||||
with plan_path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(plan, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
return plan, plan_path
|
||||
Reference in New Issue
Block a user