Drop packwiz entirely. The server now loads a filtered mod subset from a local ./server-mods volume instead of fetching a packwiz pack at boot. New flow: - tooling/classify-mods.py (tomllib only, no network) reads each jar's META-INF/neoforge.mods.toml and seeds mods-sides.json with a side (client|server|both; default both). Existing entries are preserved. - mods-sides.json: committed, keyed by jar filename, hand-editable override surface. Initial seed: 65 both, 11 client, 0 server (76 jars). - tooling/sync-server-mods.sh mirrors the both/server jars from $DISTRIBUTION_WEB_ROOT into ./server-mods/ (authoritative; prunes strays; halts on a missing jar). Replaces render-pack.sh. - compose: minecraft mounts ./server-mods:/mods:ro with REMOVE_OLD_MODS=TRUE (itzg auto-syncs /mods -> /data/mods). Removed PACKWIZ_URL, the pack. extra_host (auth. kept for authlib), and depends_on caddy (drasl kept). Both classify-mods.py and sync-server-mods.sh resolve their source dir as: CLI arg / MODS_DIST_DIR env / $DISTRIBUTION_WEB_ROOT (in that order). Removed: tooling/render-pack.sh + add-custom-mod.sh (packwiz tooling), pack/ (packwiz source), custom/ (76 jars now sourced from the distribution), the pack. caddy vhost + its mounts/alias, and the packwiz .gitignore block. Client distribution is UNCHANGED: HeliosLauncher still installs the full modset from distribution.json. Docs (CLAUDE.md, plan/04/05/14, runtime) updated; plan/04-packwiz.md stubbed as superseded by plan/04-mods.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
171 lines
6.2 KiB
Python
Executable File
171 lines
6.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# classify-mods.py — seed/update mods-sides.json from jar manifests (tomllib only).
|
|
#
|
|
# Enumerates the distribution's server-required jars and, for each jar NOT already
|
|
# a key in mods-sides.json, reads META-INF/neoforge.mods.toml and derives a side
|
|
# from the minecraft/neoforge dependency `side`:
|
|
# side == "CLIENT" -> client
|
|
# side == "SERVER" -> server
|
|
# "BOTH" / unspecified / no manifest / parse error -> both (the default)
|
|
# Existing entries are PRESERVED verbatim (never clobbers human edits); only new
|
|
# jars are added. No network calls. Pure stdlib (Python 3.11+ for tomllib).
|
|
#
|
|
# Source dir resolution (first match wins):
|
|
# 1. CLI arg: classify-mods.py <forgemods-required-dir-or-dist-root>
|
|
# 2. env MODS_DIST_DIR
|
|
# 3. $DISTRIBUTION_WEB_ROOT from .env / environment
|
|
# The path may point at either the dist ROOT (…/distribution[/dist]) or directly
|
|
# at the …/servers/ulicraft-1.21.1/forgemods/required dir — both are accepted.
|
|
|
|
import glob
|
|
import json
|
|
import os
|
|
import sys
|
|
import zipfile
|
|
|
|
try:
|
|
import tomllib
|
|
except ModuleNotFoundError:
|
|
sys.exit("classify-mods.py requires Python 3.11+ (tomllib is stdlib)")
|
|
|
|
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
SIDES_JSON = os.path.join(REPO_ROOT, "mods-sides.json")
|
|
REQUIRED_SUBPATH = os.path.join("servers", "ulicraft-1.21.1", "forgemods", "required")
|
|
MANIFEST = "META-INF/neoforge.mods.toml"
|
|
|
|
|
|
def read_env_dist_root():
|
|
"""Read DISTRIBUTION_WEB_ROOT from environment, else from repo .env."""
|
|
val = os.environ.get("DISTRIBUTION_WEB_ROOT")
|
|
if val:
|
|
return val
|
|
env_path = os.path.join(REPO_ROOT, ".env")
|
|
if os.path.isfile(env_path):
|
|
with open(env_path, encoding="utf-8") as fh:
|
|
for line in fh:
|
|
line = line.strip()
|
|
if line.startswith("DISTRIBUTION_WEB_ROOT="):
|
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
return None
|
|
|
|
|
|
def resolve_required_dir():
|
|
"""Resolve the forgemods/required dir from override or .env. Halt if missing."""
|
|
src = None
|
|
origin = None
|
|
if len(sys.argv) > 1 and sys.argv[1]:
|
|
src, origin = sys.argv[1], "CLI arg"
|
|
elif os.environ.get("MODS_DIST_DIR"):
|
|
src, origin = os.environ["MODS_DIST_DIR"], "MODS_DIST_DIR env"
|
|
else:
|
|
src = read_env_dist_root()
|
|
origin = "$DISTRIBUTION_WEB_ROOT"
|
|
if not src:
|
|
sys.exit(
|
|
"no source dir: pass a path, set MODS_DIST_DIR, or DISTRIBUTION_WEB_ROOT in .env"
|
|
)
|
|
candidates = [os.path.join(src, REQUIRED_SUBPATH), src]
|
|
for cand in candidates:
|
|
if os.path.isdir(cand) and os.path.basename(os.path.normpath(cand)) == "required":
|
|
return cand, origin
|
|
for cand in candidates:
|
|
if os.path.isdir(cand):
|
|
return cand, origin
|
|
sys.exit(
|
|
f"forgemods/required dir not found (from {origin}={src!r}). "
|
|
f"Tried: {', '.join(candidates)}"
|
|
)
|
|
|
|
|
|
def derive_side(jar_path):
|
|
"""Return client|server|both from the jar's minecraft/neoforge dep side."""
|
|
try:
|
|
with zipfile.ZipFile(jar_path) as z:
|
|
data = z.read(MANIFEST)
|
|
manifest = tomllib.loads(data.decode("utf-8"))
|
|
except (KeyError, zipfile.BadZipFile, tomllib.TOMLDecodeError, OSError, UnicodeDecodeError):
|
|
return "both"
|
|
deps = manifest.get("dependencies", {})
|
|
if not isinstance(deps, dict):
|
|
return "both"
|
|
decided = None
|
|
for dep_list in deps.values():
|
|
if not isinstance(dep_list, list):
|
|
continue
|
|
for d in dep_list:
|
|
if not isinstance(d, dict):
|
|
continue
|
|
if d.get("modId") in ("minecraft", "neoforge"):
|
|
side = d.get("side")
|
|
if side == "CLIENT":
|
|
return "client"
|
|
if side == "SERVER":
|
|
return "server"
|
|
# BOTH or unspecified — keep looking; a later CLIENT/SERVER wins.
|
|
decided = "both"
|
|
return decided or "both"
|
|
|
|
|
|
def main():
|
|
required_dir, origin = resolve_required_dir()
|
|
jars = sorted(glob.glob(os.path.join(required_dir, "*.jar")))
|
|
if not jars:
|
|
sys.exit(f"no jars found in {required_dir} (from {origin})")
|
|
|
|
if os.path.isfile(SIDES_JSON):
|
|
with open(SIDES_JSON, encoding="utf-8") as fh:
|
|
sides = json.load(fh)
|
|
if not isinstance(sides, dict):
|
|
sys.exit(f"{SIDES_JSON} is not a JSON object")
|
|
else:
|
|
sides = {}
|
|
|
|
existing_count = len(sides)
|
|
added = {}
|
|
defaulted_both = [] # newly-added jars that fell back to "both"
|
|
for jar in jars:
|
|
name = os.path.basename(jar)
|
|
if name in sides:
|
|
continue
|
|
side = derive_side(jar)
|
|
sides[name] = side
|
|
added[name] = side
|
|
if side == "both":
|
|
defaulted_both.append(name)
|
|
|
|
# Stable, human-diffable output.
|
|
ordered = {k: sides[k] for k in sorted(sides)}
|
|
with open(SIDES_JSON, "w", encoding="utf-8") as fh:
|
|
json.dump(ordered, fh, indent=2, ensure_ascii=False)
|
|
fh.write("\n")
|
|
|
|
# Warn about stale keys (in mods-sides.json but no jar in the dist dir).
|
|
present = {os.path.basename(j) for j in jars}
|
|
stale = sorted(k for k in sides if k not in present)
|
|
|
|
counts = {"client": 0, "server": 0, "both": 0}
|
|
for v in sides.values():
|
|
if v in counts:
|
|
counts[v] += 1
|
|
|
|
print(f"source: {required_dir} (from {origin})")
|
|
print(f"jars in dist: {len(jars)} | pre-existing entries: {existing_count} | added: {len(added)}")
|
|
print(f"sides totals — both: {counts['both']} client: {counts['client']} server: {counts['server']}")
|
|
if added:
|
|
print(f"\nnewly classified ({len(added)}):")
|
|
for name, side in sorted(added.items()):
|
|
print(f" {side:6} {name}")
|
|
if defaulted_both:
|
|
print(f"\nDEFAULTED to 'both' ({len(defaulted_both)}) — review for cosmetic-client mods:")
|
|
for name in defaulted_both:
|
|
print(f" {name}")
|
|
if stale:
|
|
print(f"\nWARNING — {len(stale)} key(s) in mods-sides.json have no jar in the dist dir:")
|
|
for name in stale:
|
|
print(f" {name}")
|
|
print(f"\nwrote {SIDES_JSON}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|