feat(mods): replace packwiz with distribution-fed custom mod volume
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>
This commit is contained in:
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# add-custom-mod.sh — add LOCAL jar(s) to the packwiz pack as "external files".
|
||||
#
|
||||
# Implements the "Other external files" flow from
|
||||
# https://packwiz.infra.link/tutorials/creating/adding-mods/ : a <slug>.pw.toml
|
||||
# that points at a stable download URL + sha256, then refresh. Here the metadata
|
||||
# is written as a TEMPLATE (<slug>.pw.toml.tmpl) with a ${BASE_DOMAIN}
|
||||
# placeholder and rendered by render-config.sh, so the domain is never frozen.
|
||||
#
|
||||
# Since the jars are local (no public URL), we HOST them on the caddy pack
|
||||
# server: each jar is copied to the top-level ./custom/ dir (OUTSIDE ./pack, so
|
||||
# packwiz refresh won't index it as a direct file). Caddy bind-mounts ./custom
|
||||
# at /srv/pack/custom, so it is served at
|
||||
# http://pack.${BASE_DOMAIN}/custom/<filename>
|
||||
# which is exactly what minecraft fetches via PACKWIZ_URL on install.
|
||||
#
|
||||
# Usage:
|
||||
# tooling/add-custom-mod.sh <mod.jar> [--name "Nice Name"] [--side S] [--force]
|
||||
# tooling/add-custom-mod.sh <dir/> [--side S] [--force] # all *.jar in dir
|
||||
# # (--name not allowed)
|
||||
# S = both|client|server (default both)
|
||||
#
|
||||
# Single-jar name defaults to the filename without .jar; folder mode derives
|
||||
# each name from its filename. packwiz refresh runs ONCE at the end.
|
||||
# Cautious: halts on missing tool/var, bad side, whitespace in a filename, an
|
||||
# empty folder, or an existing metadata/custom jar (unless --force).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------
|
||||
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
slugify() { # lowercase, non-alnum -> hyphen, squeeze + trim hyphens
|
||||
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
|
||||
| sed -E 's/[^a-z0-9]+/-/g; s/-+/-/g; s/^-//; s/-$//'
|
||||
}
|
||||
|
||||
# ---- args ------------------------------------------------------------------
|
||||
TARGET=""; NAME=""; SIDE="both"; FORCE=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--name) NAME="${2:?--name needs a value}"; shift 2 ;;
|
||||
--side) SIDE="${2:?--side needs a value}"; shift 2 ;;
|
||||
--force) FORCE=1; shift ;;
|
||||
-h|--help) sed -n '2,24p' "$0"; exit 0 ;;
|
||||
-*) die "unknown flag: $1" ;;
|
||||
*) [ -z "$TARGET" ] || die "only one jar or dir at a time (got extra: $1)"; TARGET="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$TARGET" ] || die "no jar/dir given. usage: tooling/add-custom-mod.sh <mod.jar|dir/> [--side both|client|server]"
|
||||
[ -e "$TARGET" ] || die "path not found: $TARGET"
|
||||
case "$SIDE" in both|client|server) ;; *) die "invalid --side '$SIDE' (both|client|server)" ;; esac
|
||||
|
||||
# ---- preflight -------------------------------------------------------------
|
||||
command -v packwiz >/dev/null 2>&1 || die "missing required tool: packwiz (https://packwiz.infra.link)"
|
||||
command -v sha256sum >/dev/null 2>&1 || die "missing required tool: sha256sum"
|
||||
command -v envsubst >/dev/null 2>&1 || die "missing required tool: envsubst (gettext)"
|
||||
[ -f .env ] || die ".env not found (copy .env.example and fill it)"
|
||||
# shellcheck disable=SC1091
|
||||
set -a; . ./.env; set +a
|
||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset in .env}"
|
||||
[ -f pack/pack.toml ] || die "pack/pack.toml not found — run from a packwiz pack (see plan/04-packwiz.md)"
|
||||
|
||||
# ---- collect jars ----------------------------------------------------------
|
||||
declare -a JARS=()
|
||||
if [ -d "$TARGET" ]; then
|
||||
[ -z "$NAME" ] || die "--name not allowed with a folder (names are derived per-jar)"
|
||||
shopt -s nullglob
|
||||
for f in "$TARGET"/*.jar; do JARS+=("$f"); done
|
||||
shopt -u nullglob
|
||||
[ "${#JARS[@]}" -gt 0 ] || die "no .jar files in folder: $TARGET"
|
||||
else
|
||||
JARS=("$TARGET")
|
||||
fi
|
||||
|
||||
mkdir -p custom pack/mods
|
||||
|
||||
# ---- per-jar processing (no refresh; refresh once at the end) --------------
|
||||
add_one() { # $1 = jar path. Uses NAME (single-jar only), SIDE, FORCE, BASE_DOMAIN.
|
||||
local jar="$1" filename name slug meta dest url hash
|
||||
[ -f "$jar" ] || die "not a file: $jar"
|
||||
filename="$(basename -- "$jar")"
|
||||
case "$filename" in *.jar) ;; *) die "not a .jar: $filename" ;; esac
|
||||
case "$filename" in *[[:space:]]*) die "filename has whitespace: '$filename' — rename it (URL-unsafe)" ;; esac
|
||||
|
||||
name="$NAME"; [ -n "$name" ] || name="${filename%.jar}"
|
||||
slug="$(slugify "$name")"
|
||||
[ -n "$slug" ] || die "could not derive a slug from name '$name'"
|
||||
|
||||
# Metadata is a TEMPLATE: the download URL keeps a literal ${BASE_DOMAIN}
|
||||
# placeholder, rendered to the real .pw.toml by render-config.sh at deploy so
|
||||
# the domain is never frozen into a committed file.
|
||||
meta="pack/mods/${slug}.pw.toml.tmpl"
|
||||
dest="custom/${filename}"
|
||||
url_tmpl='http://pack.${BASE_DOMAIN}/custom/'"${filename}"
|
||||
|
||||
if [ "$FORCE" -ne 1 ]; then
|
||||
[ -e "$meta" ] && die "metadata exists: $meta (use --force to overwrite)"
|
||||
[ -e "$dest" ] && die "hosted jar exists: $dest (use --force to overwrite)"
|
||||
fi
|
||||
|
||||
hash="$(sha256sum -- "$jar" | cut -d' ' -f1)"
|
||||
# Skip the copy when the source already IS the hosted file (regen-in-place,
|
||||
# e.g. `add-custom-mod.sh custom --force` to rewrite metadata).
|
||||
[ "$dest" -ef "$jar" ] || cp -- "$jar" "$dest"
|
||||
cat > "$meta" <<EOF
|
||||
name = "${name}"
|
||||
filename = "${filename}"
|
||||
side = "${SIDE}"
|
||||
|
||||
[download]
|
||||
url = "${url_tmpl}"
|
||||
hash-format = "sha256"
|
||||
hash = "${hash}"
|
||||
EOF
|
||||
echo " + ${filename} -> http://pack.${BASE_DOMAIN}/custom/${filename} (${SIDE}, sha256 ${hash:0:12}…)"
|
||||
}
|
||||
|
||||
log "adding ${#JARS[@]} jar(s), side=${SIDE}"
|
||||
for j in "${JARS[@]}"; do add_one "$j"; done
|
||||
|
||||
# ---- render + refresh once -------------------------------------------------
|
||||
# render-pack.sh renders all pack templates (BASE_DOMAIN only) + rebuilds the
|
||||
# packwiz index. Single source of pack-render logic, shared with render-config.
|
||||
log "rendering metadata + refreshing index"
|
||||
tooling/render-pack.sh
|
||||
log "done — ${#JARS[@]} mod(s) added to the pack"
|
||||
echo " commit: custom/*.jar + pack/mods/*.pw.toml.tmpl (rendered .pw.toml/index are gitignored)"
|
||||
170
tooling/classify-mods.py
Executable file
170
tooling/classify-mods.py
Executable file
@@ -0,0 +1,170 @@
|
||||
#!/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()
|
||||
@@ -29,6 +29,6 @@ render() { # $1=template $2=output
|
||||
render drasl/config/config.toml.tmpl drasl/config/config.toml
|
||||
render nmsr/config.toml.tmpl nmsr/config.toml
|
||||
|
||||
# pack templates (mods/*.pw.toml.tmpl, CustomSkinLoader, …) + packwiz index.
|
||||
# Delegated to render-pack.sh (BASE_DOMAIN only — single source of pack render).
|
||||
tooling/render-pack.sh
|
||||
# Server mods are no longer rendered here — they come from the distribution repo
|
||||
# via tooling/sync-server-mods.sh (run separately in the deploy flow). See
|
||||
# plan/04-mods.md.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# render-pack.sh — render every pack template (pack/**/*.tmpl) by injecting
|
||||
# ${BASE_DOMAIN}, then rebuild the packwiz index.
|
||||
#
|
||||
# Pack templates are domain-dependent build output (download URLs, skin API
|
||||
# roots, …). This renderer needs ONLY ${BASE_DOMAIN}, so it can run while
|
||||
# curating mods before the full deploy config exists.
|
||||
# Both tooling/render-config.sh (deploy) and tooling/add-custom-mod.sh call it.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
[ -f .env ] && { set -a; . ./.env; set +a; }
|
||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
|
||||
command -v envsubst >/dev/null 2>&1 || { echo "missing required tool: envsubst (gettext)" >&2; exit 1; }
|
||||
command -v packwiz >/dev/null 2>&1 || { echo "missing required tool: packwiz" >&2; exit 1; }
|
||||
[ -f pack/pack.toml ] || { echo "pack/pack.toml not found" >&2; exit 1; }
|
||||
|
||||
shopt -s globstar nullglob
|
||||
tmpls=(pack/**/*.tmpl)
|
||||
shopt -u globstar nullglob
|
||||
|
||||
for t in "${tmpls[@]}"; do
|
||||
# Substitute ONLY ${BASE_DOMAIN} so any other literal $-syntax is preserved.
|
||||
envsubst '${BASE_DOMAIN}' < "$t" > "${t%.tmpl}"
|
||||
echo "rendered ${t%.tmpl}"
|
||||
done
|
||||
|
||||
# index.toml is gitignored build output; seed a minimal one so packwiz refresh
|
||||
# (which won't bootstrap a missing index) has something to populate.
|
||||
[ -f pack/index.toml ] || printf 'hash-format = "sha256"\n' > pack/index.toml
|
||||
( cd pack && packwiz refresh )
|
||||
echo "packwiz index refreshed (${#tmpls[@]} template(s))"
|
||||
108
tooling/sync-server-mods.sh
Executable file
108
tooling/sync-server-mods.sh
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# sync-server-mods.sh — mirror the server's mod subset into ./server-mods/.
|
||||
# Replaces the old render-pack.sh (packwiz). Copies every jar mapped `both` or
|
||||
# `server` in mods-sides.json from the distribution's forgemods/required dir into
|
||||
# ./server-mods/, and removes any stray server-mods/*.jar not in the selection
|
||||
# (authoritative mirror). The minecraft container mounts ./server-mods at /mods
|
||||
# (REMOVE_OLD_MODS=TRUE), so /data/mods ends up matching exactly.
|
||||
#
|
||||
# Cautious by design: halts (non-zero) on a missing mods-sides.json or a selected
|
||||
# jar absent from the source dir — never a silent partial sync.
|
||||
#
|
||||
# Source dir resolution (first match wins):
|
||||
# 1. CLI arg: sync-server-mods.sh <forgemods-required-dir-or-dist-root>
|
||||
# 2. env MODS_DIST_DIR
|
||||
# 3. $DISTRIBUTION_WEB_ROOT from .env
|
||||
# The path may point at the dist ROOT (…/distribution[/dist]) or directly at
|
||||
# …/servers/ulicraft-1.21.1/forgemods/required — both are accepted.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
[ -f .env ] && { set -a; . ./.env; set +a; }
|
||||
|
||||
REQUIRED_SUBPATH="servers/ulicraft-1.21.1/forgemods/required"
|
||||
SIDES_JSON="mods-sides.json"
|
||||
DEST="server-mods"
|
||||
|
||||
# ── Resolve source dir ──────────────────────────────────────────────
|
||||
if [ "${1:-}" != "" ]; then
|
||||
SRC="$1"; ORIGIN="CLI arg"
|
||||
elif [ "${MODS_DIST_DIR:-}" != "" ]; then
|
||||
SRC="$MODS_DIST_DIR"; ORIGIN="MODS_DIST_DIR env"
|
||||
elif [ "${DISTRIBUTION_WEB_ROOT:-}" != "" ]; then
|
||||
SRC="$DISTRIBUTION_WEB_ROOT"; ORIGIN="\$DISTRIBUTION_WEB_ROOT"
|
||||
else
|
||||
echo "no source dir: pass a path, set MODS_DIST_DIR, or DISTRIBUTION_WEB_ROOT in .env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "$SRC/$REQUIRED_SUBPATH" ]; then
|
||||
REQ="$SRC/$REQUIRED_SUBPATH"
|
||||
elif [ "$(basename "$SRC")" = "required" ] && [ -d "$SRC" ]; then
|
||||
REQ="$SRC"
|
||||
else
|
||||
echo "forgemods/required dir not found (from $ORIGIN=$SRC)" >&2
|
||||
echo "tried: $SRC/$REQUIRED_SUBPATH and $SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ -f "$SIDES_JSON" ] || { echo "$SIDES_JSON not found — run tooling/classify-mods.py first" >&2; exit 1; }
|
||||
|
||||
# ── Selected jars (both|server) ─────────────────────────────────────
|
||||
select_jars() {
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -r 'to_entries[] | select(.value == "both" or .value == "server") | .key' "$SIDES_JSON"
|
||||
else
|
||||
python3 - "$SIDES_JSON" <<'PY'
|
||||
import json, sys
|
||||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||
sides = json.load(fh)
|
||||
for name, side in sides.items():
|
||||
if side in ("both", "server"):
|
||||
print(name)
|
||||
PY
|
||||
fi
|
||||
}
|
||||
|
||||
mapfile -t SELECTED < <(select_jars | sort)
|
||||
[ "${#SELECTED[@]}" -gt 0 ] || { echo "no jars selected (both/server) in $SIDES_JSON — nothing to sync" >&2; exit 1; }
|
||||
|
||||
# ── Halt if any selected jar is missing from the source ─────────────
|
||||
missing=()
|
||||
for jar in "${SELECTED[@]}"; do
|
||||
[ -f "$REQ/$jar" ] || missing+=("$jar")
|
||||
done
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "ERROR: ${#missing[@]} selected jar(s) missing from source ($REQ):" >&2
|
||||
printf ' %s\n' "${missing[@]}" >&2
|
||||
echo "refusing to do a partial sync — fix mods-sides.json or the distribution." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Mirror: copy selected, prune everything else ────────────────────
|
||||
mkdir -p "$DEST"
|
||||
|
||||
# Prune stray jars not in the current selection.
|
||||
declare -A keep
|
||||
for jar in "${SELECTED[@]}"; do keep["$jar"]=1; done
|
||||
pruned=0
|
||||
shopt -s nullglob
|
||||
for existing in "$DEST"/*.jar; do
|
||||
base="$(basename "$existing")"
|
||||
if [ -z "${keep[$base]:-}" ]; then
|
||||
rm -f -- "$existing"
|
||||
pruned=$((pruned + 1))
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
# Copy selection (cp -p preserves mtime so unchanged jars don't re-touch).
|
||||
copied=0
|
||||
for jar in "${SELECTED[@]}"; do
|
||||
cp -p -- "$REQ/$jar" "$DEST/$jar"
|
||||
copied=$((copied + 1))
|
||||
done
|
||||
|
||||
echo "source: $REQ (from $ORIGIN)"
|
||||
echo "synced $copied jar(s) into $DEST/ (pruned $pruned stray)."
|
||||
Reference in New Issue
Block a user