#!/usr/bin/env python3 """ Linux-side port of Build-Pack.ps1 + Deploy-Brass.ps1 (publish step). Reads pack/pack.lock.json + walks pack/overrides/, rebuilds manifest.json, mirrors every override file to the live web root, and republishes the manifest. The launcher will then see fresh hashes on its next sync. Run on glados with passwordless sudo. Idempotent. Web root: /srv/services/sites/bns (mounted into Caddy as /srv/sites/bns). """ import hashlib import json import shutil import subprocess import sys from datetime import datetime, timezone from pathlib import Path REPO = Path("/home/matt/dev/brass-and-sigil") LOCK_PATH = REPO / "pack" / "pack.lock.json" OVERRIDES = REPO / "pack" / "overrides" WEB_ROOT = Path("/srv/services/sites/bns/pack") WEB_FILES = WEB_ROOT / "files" WEB_MANIFEST = WEB_ROOT / "manifest.json" SELF_HOST_BASE = "https://bns.sijbers.uk/pack/files" MANAGED_ROOTS = ["mods", "config", "resourcepacks", "shaderpacks", "kubejs", "defaultconfigs", "patchouli_books"] # bnstoolkit ships client+server surface (screens, HUD, network bridge), # so it must NOT inherit the default "server-only" tag that Build-Pack.ps1 # stamps on every pack/overrides/mods/*.jar. brassandsigil_tweaks is # data-only (worldgen modifiers / recipe overrides) so server-side is # correct for it. CLIENT_AND_SERVER_LOCAL_MODS = {"bnstoolkit"} def sha1_of(p: Path) -> str: h = hashlib.sha1() with p.open("rb") as f: for chunk in iter(lambda: f.read(1 << 16), b""): h.update(chunk) return h.hexdigest() def main(): with LOCK_PATH.open() as f: lock = json.load(f) files = [] total_bytes = 0 # 1. Mods from the lockfile (Modrinth-hosted) for m in lock["mods"]: entry = { "path": m["path"], "url": m["url"], "sha1": m["sha1"], "size": m["size"], } side = m.get("side") if side and side != "both": entry["side"] = side files.append(entry) total_bytes += m["size"] # 2. Local overrides for root in MANAGED_ROOTS: root_dir = OVERRIDES / root if not root_dir.exists(): continue for p in sorted(root_dir.rglob("*")): if not p.is_file(): continue rel = p.relative_to(OVERRIDES).as_posix() sha1 = sha1_of(p) size = p.stat().st_size entry = { "path": rel, "url": f"{SELF_HOST_BASE}/{rel}", "sha1": sha1, "size": size, } if root == "mods": # See CLIENT_AND_SERVER_LOCAL_MODS — bnstoolkit must be loaded # on both sides; the other locally-built mods are server-only. stem = p.name.split("-")[0].split("_")[0] # before first - or _ if not any(p.name.startswith(prefix) for prefix in CLIENT_AND_SERVER_LOCAL_MODS): entry["side"] = "server" files.append(entry) total_bytes += size # 3. Compose manifest matching the existing schema manifest = { "name": lock["name"], "version": lock["version"], "minecraft": {"version": lock["minecraft"]}, "loader": {"type": lock["loader"]["type"], "version": lock["loader"]["version"]}, "files": files, } for opt in ("defaultServer", "defaultShader", "panelUrl"): if opt in lock: manifest[opt] = lock[opt] # Carry the launcher metadata from the existing published manifest # (we can't read a Windows .exe's FileVersion from Linux easily, and # the launcher version isn't related to pack content). try: with WEB_MANIFEST.open() as f: existing = json.load(f) for k in ("launcherVersion", "launcherUrl"): if k in existing: manifest[k] = existing[k] except Exception: pass # 4. Mirror override files to web root (uses sudo + cp -a). Only update # files whose hash differs from what's already published to keep the # rsync footprint tiny. print(f"\nMirroring {sum(1 for r in MANAGED_ROOTS if (OVERRIDES / r).exists())} override roots to {WEB_FILES}\n") changed = 0 skipped = 0 for root in MANAGED_ROOTS: root_dir = OVERRIDES / root if not root_dir.exists(): continue target_root = WEB_FILES / root for p in root_dir.rglob("*"): if not p.is_file(): continue rel = p.relative_to(OVERRIDES) target = WEB_FILES / rel try: if target.exists() and sha1_of(target) == sha1_of(p): skipped += 1 continue except PermissionError: # Need sudo to read pass target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(p, target) changed += 1 print(f" changed: {changed}, skipped (matching hash): {skipped}\n") # 5. Write the manifest atomically (write to .new, then move) new_manifest = WEB_MANIFEST.with_suffix(".json.new") bak_manifest = WEB_MANIFEST.with_suffix(".json.bak") if WEB_MANIFEST.exists(): shutil.copy2(WEB_MANIFEST, bak_manifest) with new_manifest.open("w") as f: json.dump(manifest, f, indent=2, ensure_ascii=False) f.write("\n") new_manifest.replace(WEB_MANIFEST) print(f"Manifest written: {WEB_MANIFEST}") print(f" Version: {lock['version']}") print(f" Files: {len(files)}") print(f" Total: {total_bytes / 1024 / 1024:.1f} MB download") print() # Quick verify rel = "kubejs/server_scripts/economy/00_tier_system.js" entry = next((f for f in files if f["path"] == rel), None) if entry: print(f" {rel} -> sha1={entry['sha1'][:12]}... size={entry['size']}") if __name__ == "__main__": main()