From 89c0c47bb13959a6c34e3c8a02866f3fdab5afc5 Mon Sep 17 00:00:00 2001 From: ReaperOfVeriod Date: Fri, 21 Aug 2026 10:58:38 +0200 Subject: [PATCH] Add one-command server mod updates: build_mods.py --push via update.sh (stop/sync/start, prunes stale jars) --- README.md | 15 +++++ server/README.md | 62 ++++++++++++++++--- server/build_mods.py | 28 ++++++++- server/deploy.sh | 13 +++- server/update.sh | 142 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 247 insertions(+), 13 deletions(-) create mode 100755 server/update.sh diff --git a/README.md b/README.md index 26c322d..f804b39 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,21 @@ cd ~/reaper-sky-server && ./run.sh The world generates on first boot — wait for `Done (...)` and join at `reaperofveriod.nl`. Full details in [`server/README.md`](server/README.md). +## Updating the server's mods (maintainers) + +After adding/removing mods in the Prism instance `Reaper's Sky - dev`, push +them to the live server with one command from the repo root: + +```sh +python3 server/build_mods.py --push +``` + +It regenerates `server/mods.json`, uploads it, then on the server: +**stop → sync mods (prune stale + download new, sha1-verified) → start**, and +waits until the log shows `Done (...)`. No git commit needed first. See +[`server/README.md`](server/README.md#updating-the-servers-mods) for details, +manual options and troubleshooting. + ## What's inside
diff --git a/server/README.md b/server/README.md index dd8a325..c4494a4 100644 --- a/server/README.md +++ b/server/README.md @@ -22,7 +22,7 @@ and verifies every mod by sha1 before installing. |------|--------| | System packages | `openjdk-21-jre-headless`, `curl`, `unzip` (needs root/`sudo`) | | NeoForge server | Official installer from Maven → server files + `libraries/` | -| Mods | Downloads the 90 server-safe mods pinned in `mods.json`, sha1-verified | +| Mods | Downloads the 90 server-safe mods pinned in `mods.json`, sha1-verified; prunes jars no longer in the pack | | Config | `eula.txt` (EULA accepted), `server.properties` (IP, motd, 8 G) | | Run script | `run.sh` with 8 GB + G1GC (`nogui`) | @@ -54,13 +54,57 @@ Stop the server cleanly with the console command `stop` (Ctrl-c also works). the server will crash with `Cannot assign requested address`. - `difficulty=hard`, `spawn-protection=0`, `view-distance=10` are set for the airship-focused, land-claim-friendly experience. -- The 19 client-only mods (Iris/shaders, Sodium, Minimap, Jade, JEI, etc.) are - intentionally excluded here — the server is pure gameplay logic. +- The 17 client-only mods (Iris/shaders, Sodium, Minimap, MouseTweaks, etc.) + are intentionally excluded here — everything gameplay-relevant runs on the + server too (including JEI and Jade). -## Updating the pack +## Updating the server's mods -1. Add/remove mods in the Prism instance `Reaper's Sky - dev`. -2. Rebuild + push the mrpack (see repo root `README.md`). -3. Re-run `server/build_mods.py` or regenerate `mods.json`, commit, push. -4. On the server: `git pull && sudo ./deploy.sh && ./run.sh` - (also remove any stale jars from `~/reaper-sky-server/mods/` first). \ No newline at end of file +### The easy way — one command from your PC + +If your PC has SSH key access to the server (`ssh root@ReapersSky` works +without a password), you can push mod changes to the live server with a single +command run **from the repo root on your PC**: + +```sh +python3 server/build_mods.py --push +``` + +This does everything: + +1. Scans the Prism instance `Reaper's Sky - dev` and regenerates + `server/mods.json` (skipping client-only mods). +2. Uploads that manifest to the server. +3. On the server: **stops** it cleanly, **syncs** `mods/` against the manifest + (deletes jars that are no longer in the pack, downloads + sha1-verifies new + or changed ones), then **starts** it again. +4. Waits for boot and prints `Server is UP` once the log shows `Done (...)`. + +No git commit/push is needed first — it works straight from your working tree, +so you can test changes on the server before committing them. + +Useful variations: + +```sh +python3 server/build_mods.py --push # full cycle: stop -> sync -> start +RS_SERVER_HOST=user@otherhost python3 server/build_mods.py --push # different box +``` + +### Manual / on-server way + +If you already have a `mods.json` on the server (e.g. uploaded by hand), run +this **on the server box**: + +```sh +bash ~/update.sh /path/to/mods.json # stop -> sync -> start +bash ~/update.sh /path/to/mods.json --no-start # sync only, leave stopped +``` + +`update.sh` is safe to re-run at any time: if the server isn't running it just +syncs; every jar is verified by sha1 before it counts as installed. + +### Full reinstall + +For a brand-new box (or to rebuild everything from scratch), use the one-shot +installer described at the top — it also prunes stale jars since it shares the +same sync logic. diff --git a/server/build_mods.py b/server/build_mods.py index 42520ab..cfb3eb8 100644 --- a/server/build_mods.py +++ b/server/build_mods.py @@ -8,13 +8,17 @@ For every .jar in the pack instance it: - drops the 19 client-only mods and writes the server manifest to mods.json. -Usage: python3 build_mods.py +Usage: python3 build_mods.py regenerate server/mods.json + python3 build_mods.py --push regenerate, then update the live server + (stop -> sync mods -> start, via SSH) """ -import hashlib, json, os, subprocess, sys, time +import argparse, hashlib, json, os, subprocess, sys, time INSTANCE = os.path.expanduser( "~/.local/share/PrismLauncher/instances/Reaper's Sky - dev/.minecraft/mods") OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mods.json") +UPDATE_SH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "update.sh") +SERVER_HOST = os.environ.get("RS_SERVER_HOST", "root@ReapersSky") MC_VERSION = "1.21.1" NEOFORGE_VERSION = "21.1.248" @@ -67,7 +71,25 @@ def resolve(jar, digest): return "" +def push(): + """Upload mods.json to the server and run update.sh there (stop/sync/start).""" + print(f"Uploading manifest to {SERVER_HOST}:/tmp/reaper-sky-mods.json ...") + with open(OUT, "rb") as f: + subprocess.run(["ssh", SERVER_HOST, "cat > /tmp/reaper-sky-mods.json"], + stdin=f, check=True) + print("Running update.sh on the server (stop -> sync -> start) ...") + with open(UPDATE_SH, "rb") as f: + subprocess.run(["ssh", SERVER_HOST, "bash -s -- /tmp/reaper-sky-mods.json"], + stdin=f, check=True) + + def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--push", action="store_true", + help="also update the live server: stop it, sync mods " + "(prune + download + sha1-verify), start it again") + args = parser.parse_args() + mods = [] for jar in sorted(os.listdir(INSTANCE)): if not jar.endswith(".jar"): @@ -85,6 +107,8 @@ def main(): with open(OUT, "w") as f: json.dump(manifest, f, indent=2) print(f"Wrote {len(mods)} server mods to {OUT}") + if args.push: + push() if __name__ == "__main__": diff --git a/server/deploy.sh b/server/deploy.sh index 7760646..8a95c3a 100755 --- a/server/deploy.sh +++ b/server/deploy.sh @@ -5,7 +5,7 @@ # Installs everything needed to run the Reaper's Sky NeoForge 21.1.248 server: # - OpenJDK 21 (JRE, headless) + curl/unzip # - NeoForge 21.1.248 server via its official installer -# - the 88 server-safe mods (pinned in mods.json, verified by sha1) +# - the server-safe mods (pinned in mods.json, verified by sha1) # - eula.txt + server.properties (IP reaperofveriod.nl) # - run.sh (8 GB, G1GC) # @@ -74,8 +74,10 @@ install_mods() { python3 - "$SCRIPT_DIR/mods.json" <<'PY' import json, sys, hashlib, subprocess, os manifest = json.load(open(sys.argv[1])) +keep = set() for m in manifest["mods"]: fname, url, want = m["file"], m["url"], m["sha1"] + keep.add(fname) tmp = fname + ".part" if os.path.exists(fname): h = hashlib.sha1() @@ -84,7 +86,9 @@ for m in manifest["mods"]: h.update(b) if h.hexdigest() == want: continue # already present & correct - subprocess.run(["curl", "-fL", "--retry", "3", "-o", tmp, url], check=True) + if not url: + raise SystemExit(f"no download URL for {fname}") + subprocess.run(["curl", "-fsSL", "--retry", "3", "-o", tmp, url], check=True) h = hashlib.sha1() with open(tmp, "rb") as fh: for b in iter(lambda: fh.read(65536), b""): @@ -94,6 +98,11 @@ for m in manifest["mods"]: raise SystemExit(f"sha1 mismatch for {fname}") os.rename(tmp, fname) print(f" + {fname}") +# remove jars that are no longer part of the pack +for f in sorted(os.listdir(".")): + if f.endswith(".jar") and f not in keep: + os.unlink(f) + print(f" - {f} (stale)") PY log "Mods ready: $(ls -1 "$SERVER_DIR/mods"/*.jar | wc -l) jars installed." } diff --git a/server/update.sh b/server/update.sh new file mode 100755 index 0000000..e894c6a --- /dev/null +++ b/server/update.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# +# Reaper's Sky - server mod updater. +# +# Brings the server's mods/ folder in line with a mods.json manifest: +# 1. gracefully stops the running server (tmux session "mc") +# 2. prunes jars that are not in the manifest, downloads + sha1-verifies +# the ones that are missing +# 3. boots the server again and waits until it reports "Done" +# +# Usage (on the server): +# bash update.sh [MANIFEST] [--no-start] +# +# MANIFEST path to mods.json (default: /tmp/reaper-sky-mods.json) +# --no-start sync only, leave the server stopped +# +# You normally do NOT run this by hand. From your PC run: +# python3 server/build_mods.py --push +# which regenerates mods.json from the client instance, uploads it to +# /tmp/reaper-sky-mods.json on the server and pipes this script over SSH. +set -euo pipefail + +SERVER_DIR="${DIR:-$HOME/reaper-sky-server}" +SESSION="mc" +MANIFEST="/tmp/reaper-sky-mods.json" +START=true + +for arg in "$@"; do + case "$arg" in + --no-start) START=false ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) MANIFEST="$arg" ;; + esac +done + +[ -f "$MANIFEST" ] || { echo "[update] ERROR: manifest not found: $MANIFEST" >&2; exit 1; } +[ -d "$SERVER_DIR" ] || { echo "[update] ERROR: server dir not found: $SERVER_DIR" >&2; exit 1; } + +log() { printf '\033[1;34m[update]\033[0m %s\n' "$*"; } +die() { printf '\033[1;31m[update] ERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +# "running" = the tmux session's pane is executing java right now. +running() { + tmux has-session -t "$SESSION" 2>/dev/null || return 1 + [ "$(tmux list-panes -t "$SESSION" -F '#{pane_current_command}' 2>/dev/null)" = "java" ] +} + +# --- 1. stop ------------------------------------------------------------------- +if running; then + log "Stopping server (session '$SESSION')..." + tmux send-keys -t "$SESSION" "stop" C-m + for _ in $(seq 1 90); do + running || break + sleep 1 + done + running && die "Server still running after 90s -- refusing to continue." + log "Server stopped." +else + log "Server not running -- skipping stop." +fi + +# --- 2. sync mods ---------------------------------------------------------------- +log "Syncing mods against $(basename "$MANIFEST")..." +mkdir -p "$SERVER_DIR/mods" +cd "$SERVER_DIR/mods" +python3 - "$MANIFEST" <<'PY' +import hashlib, json, os, subprocess, sys + +manifest = json.load(open(sys.argv[1])) +keep = set() +for m in manifest["mods"]: + fname, url, want = m["file"], m["url"], m["sha1"] + keep.add(fname) + if os.path.exists(fname): + h = hashlib.sha1() + with open(fname, "rb") as fh: + for b in iter(lambda: fh.read(65536), b""): + h.update(b) + if h.hexdigest() == want: + continue # already present & correct + print(f" ~ {fname} (hash mismatch -- re-downloading)") + else: + print(f" + {fname}") + if not url: + raise SystemExit(f"no download URL for {fname}") + tmp = fname + ".part" + subprocess.run(["curl", "-fsSL", "--retry", "3", "-o", tmp, url], check=True) + h = hashlib.sha1() + with open(tmp, "rb") as fh: + for b in iter(lambda: fh.read(65536), b""): + h.update(b) + if h.hexdigest() != want: + os.unlink(tmp) + raise SystemExit(f"sha1 mismatch for {fname}") + os.rename(tmp, fname) + +for f in sorted(os.listdir(".")): + if f.endswith(".jar") and f not in keep: + os.unlink(f) + print(f" - {f} (stale)") + +on_disk = len([f for f in os.listdir(".") if f.endswith(".jar")]) +print(f"[update] {len(keep)} mods in manifest, {on_disk} jars on disk") +PY +log "Mods synced." + +# --- 3. start + wait for boot ------------------------------------------------------ +start_server() { + if ! tmux has-session -t "$SESSION" 2>/dev/null; then + tmux new-session -d -s "$SESSION" -c "$SERVER_DIR" + fi + tmux send-keys -t "$SESSION" "cd '$SERVER_DIR' && ./run.sh" C-m +} + +if [ "$START" = false ]; then + log "--no-start given: leaving the server stopped." + exit 0 +fi + +LOG="$SERVER_DIR/logs/latest.log" +OFFSET=$(wc -l < "$LOG" 2>/dev/null || echo 0) # ignore pre-boot log content +log "Starting server (tmux session '$SESSION')..." +start_server + +log "Waiting for boot (up to 4 min)..." +for i in $(seq 1 240); do + cur=$(wc -l < "$LOG" 2>/dev/null || echo 0) + [ "$cur" -lt "$OFFSET" ] && OFFSET=0 # log was rotated/truncated + if [ "$cur" -gt "$OFFSET" ] && tail -n +"$((OFFSET + 1))" "$LOG" | grep -q "Done ("; then + log "Server is UP after ~${i}s." + log "Console: tmux attach -t $SESSION (detach: Ctrl-b d)" + exit 0 + fi + # pane exited or left java => boot failed / crashed + if ! tmux has-session -t "$SESSION" 2>/dev/null || ! running; then + sleep 5 # give the shell a moment to print any error + die "Server process exited during boot -- check: tail -50 $LOG" + fi + sleep 1 +done + +die "Timed out waiting for 'Done' after 4 min -- check: tail -50 $LOG"