Initial commit: packwiz + NeoForge hosting kit
Deploys a self-hosted NeoForge server whose modpack clients pull automatically on launch, with mods served from an HTTP mirror of the server's own mods dir rather than Modrinth or CurseForge. - mc-service-setup.sh: minecraft user, NeoForge install, systemd service paired with a console FIFO socket so stops are graceful and the world saves - packwiz-setup.sh: packwiz install, pack init, mod index built from the mirror, and per-OS player setup guides generated with real URLs baked in - mc-refresh-restart.sh: re-sync after mod changes, ZFS snapshot, restart - deploy.sh: runs the above in order - build.sh: release zip, refusing to package scripts that don't parse Every entry point refuses to run when /minecraft is not a mountpoint, so an unmounted dataset cannot silently fill the root filesystem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main
commit
e738422dbe
|
|
@ -0,0 +1,2 @@
|
|||
# build output
|
||||
/dist/
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
# mc-packwiz-deploy
|
||||
|
||||
Self-hosted NeoForge Minecraft server with a packwiz modpack that clients pull
|
||||
automatically on every launch. Mods are served from an HTTP mirror of the
|
||||
server's own mods directory — no Modrinth or CurseForge dependency.
|
||||
|
||||
Everything lives on a ZFS dataset mounted at `/minecraft`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
./deploy.sh -n "Opengames" -a themiro \
|
||||
-u https://games.abdelbaki.eu/mods \
|
||||
-N 21.1.72 --accept-eula
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
sudo systemctl start minecraft
|
||||
journalctl -u minecraft -f
|
||||
```
|
||||
|
||||
Send the players `https://<your-host>/setup-windows-packwiz.html` or
|
||||
`setup-linux-packwiz.html`. Those are generated with your real pack URL and
|
||||
server address already filled in.
|
||||
|
||||
## What's in here
|
||||
|
||||
| Script | Runs as | Does |
|
||||
|---|---|---|
|
||||
| `deploy.sh` | you | Runs the two setup scripts in order |
|
||||
| `mc-service-setup.sh` | root | `minecraft` user, NeoForge server, systemd units |
|
||||
| `packwiz-setup.sh` | you | packwiz install, pack init, mod index, player guides |
|
||||
| `mc-refresh-restart.sh` | you | Re-sync the pack after changing mods, then restart |
|
||||
| `build.sh` | you | Builds the release zip |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Fedora (or any systemd distro; `dnf` calls need swapping otherwise)
|
||||
- A ZFS dataset mounted at `/minecraft`
|
||||
- Java 21
|
||||
- A web server already publishing `/minecraft` — `/mods` and `/packs` must be
|
||||
reachable over HTTP
|
||||
|
||||
Every script refuses to run if `/minecraft` isn't a mountpoint. An unmounted
|
||||
dataset leaves it as an empty directory on the root filesystem, and writing a
|
||||
world or a mod mirror there fills the root disk instead of the pool.
|
||||
|
||||
## Day-to-day
|
||||
|
||||
Add or remove a jar in `/minecraft/mods`, then:
|
||||
|
||||
```bash
|
||||
./mc-refresh-restart.sh
|
||||
```
|
||||
|
||||
That registers new mods, re-pins any whose file changed, drops metadata for
|
||||
deleted ones, takes a ZFS snapshot, restarts the server, and prunes to the
|
||||
8 most recent snapshots. Players get the change on their next launch.
|
||||
|
||||
It refuses to restart if any mod failed to register, so the server never comes
|
||||
up on a half-synced pack. Edit the config block at the top first — `PACK_DIR`,
|
||||
`BASE_URL`, and `PACK_URL` need to match your deployment.
|
||||
|
||||
## The server unit
|
||||
|
||||
`minecraft.service` pairs with `minecraft.socket`, a console FIFO at
|
||||
`/run/minecraft-console`. That's what makes a clean shutdown possible:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop minecraft # sends "stop", world saves properly
|
||||
echo "say hello" | sudo tee /run/minecraft-console
|
||||
journalctl -u minecraft -f # console output
|
||||
```
|
||||
|
||||
Without the FIFO, systemd would SIGTERM the JVM and risk cutting a world save
|
||||
in half.
|
||||
|
||||
## Web server
|
||||
|
||||
`/minecraft` needs to be served over HTTP. Serve `pack.toml` and `index.toml`
|
||||
with `Cache-Control: no-cache` — a caching proxy will otherwise hand clients a
|
||||
stale pack while the server runs the new one. `mc-refresh-restart.sh` checks for
|
||||
exactly that and stops before restarting if it finds it.
|
||||
|
||||
## Caveats
|
||||
|
||||
- `packwiz-setup.sh` probes `packwiz --help` at runtime rather than assuming
|
||||
flag names, since the NeoForge options have moved between builds. If it bails
|
||||
with an interface complaint, the installed packwiz differs from what's
|
||||
expected.
|
||||
- Mod names come from filenames with the trailing version stripped
|
||||
(`create-1.21.1-6.0.4.jar` → `create`). It's a heuristic; rename the
|
||||
generated `.pw.toml` files if you don't like the result.
|
||||
- Hashes are pinned. Replacing a jar in place under the same filename requires
|
||||
a re-sync, or clients fail the hash check.
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the release zip.
|
||||
#
|
||||
# ./build.sh -> dist/mc-packwiz-deploy-<version>.zip
|
||||
# ./build.sh -o /tmp -> writes there instead
|
||||
# ./build.sh -v 1.2.0 -> override the version
|
||||
#
|
||||
# Version comes from the latest git tag when there is one, otherwise VERSION
|
||||
# below. A dirty working tree gets a -dirty suffix so a zip built mid-edit is
|
||||
# never mistaken for a tagged release.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="0.1.0"
|
||||
NAME="mc-packwiz-deploy"
|
||||
OUTDIR=""
|
||||
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Everything the zip ships. Anything not listed here stays out, so stray notes
|
||||
# or test output in the working tree can't leak into a release.
|
||||
FILES=(
|
||||
README.md
|
||||
deploy.sh
|
||||
mc-service-setup.sh
|
||||
packwiz-setup.sh
|
||||
mc-refresh-restart.sh
|
||||
)
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
info() { echo ">>> $*"; }
|
||||
usage() { sed -n '2,11p' "$0" | sed 's/^# \?//'; exit "${1:-0}"; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o|--outdir) OUTDIR="${2:-}"; shift 2 ;;
|
||||
-v|--version) VERSION="${2:-}"; VERSION_FORCED=1; shift 2 ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "unknown option: $1" >&2; usage 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$HERE"
|
||||
command -v zip >/dev/null || die "zip not found — sudo dnf install zip"
|
||||
|
||||
# ---------------------------------------------------------------- version ---
|
||||
|
||||
if [ -z "${VERSION_FORCED:-}" ] && git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
tag="$(git describe --tags --abbrev=0 2>/dev/null || true)"
|
||||
[ -n "$tag" ] && VERSION="${tag#v}"
|
||||
if ! git diff --quiet HEAD -- "${FILES[@]}" 2>/dev/null; then
|
||||
VERSION="${VERSION}-dirty"
|
||||
info "working tree has uncommitted changes to shipped files"
|
||||
fi
|
||||
fi
|
||||
|
||||
[ -n "$OUTDIR" ] || OUTDIR="$HERE/dist"
|
||||
mkdir -p "$OUTDIR"
|
||||
ZIP="$OUTDIR/${NAME}-${VERSION}.zip"
|
||||
|
||||
# ------------------------------------------------------------------ check ---
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
[ -f "$f" ] || die "missing file: $f"
|
||||
done
|
||||
|
||||
info "syntax-checking scripts"
|
||||
fail=0
|
||||
for f in "${FILES[@]}"; do
|
||||
case "$f" in
|
||||
*.sh) bash -n "$f" || { echo " FAILED: $f" >&2; fail=1; } ;;
|
||||
esac
|
||||
done
|
||||
(( fail )) && die "refusing to build a zip containing scripts that don't parse"
|
||||
|
||||
# ------------------------------------------------------------------ stage ---
|
||||
|
||||
# Staged in a temp tree so the zip has a single top-level directory and the
|
||||
# scripts land executable regardless of how git checked them out.
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
DEST="$STAGE/$NAME"
|
||||
mkdir -p "$DEST"
|
||||
|
||||
for f in "${FILES[@]}"; do
|
||||
case "$f" in
|
||||
*.sh) install -m 755 "$f" "$DEST/$f" ;;
|
||||
*) install -m 644 "$f" "$DEST/$f" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
printf '%s\n' "$VERSION" > "$DEST/VERSION"
|
||||
|
||||
# ------------------------------------------------------------------ build ---
|
||||
|
||||
rm -f "$ZIP"
|
||||
( cd "$STAGE" && zip -qr "$ZIP" "$NAME" )
|
||||
|
||||
info "built $ZIP"
|
||||
unzip -l "$ZIP" | sed 's/^/ /'
|
||||
|
||||
if command -v sha256sum >/dev/null; then
|
||||
sha256sum "$ZIP" | tee "$ZIP.sha256" | sed 's/^/ /'
|
||||
fi
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# One-shot deploy: systemd service + packwiz pack + player setup guides.
|
||||
#
|
||||
# ./deploy.sh -n "Opengames" -a themiro -u https://games.abdelbaki.eu/mods \
|
||||
# -N 21.1.72 --accept-eula
|
||||
#
|
||||
# Runs, in order:
|
||||
# 1. mc-service-setup.sh (root) — minecraft user, NeoForge, systemd units
|
||||
# 2. packwiz-setup.sh (you) — pack, mod index from the mirror, guides
|
||||
#
|
||||
# Run as your normal user; it calls sudo for the parts that need it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NAME=""; AUTHOR=""; BASEURL=""; NFVER=""; MCVER="1.21.1"
|
||||
SHARE="/minecraft"; XMX="8G"; XMS="4G"; ACCEPT_EULA=""
|
||||
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
step() { printf '\n\033[1m=== %s ===\033[0m\n\n' "$*"; }
|
||||
usage() { sed -n '2,13p' "$0" | sed 's/^# \?//'; exit "${1:-0}"; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-n|--name) NAME="${2:-}"; shift 2 ;;
|
||||
-a|--author) AUTHOR="${2:-}"; shift 2 ;;
|
||||
-u|--base-url) BASEURL="${2:-}"; shift 2 ;;
|
||||
-N|--neoforge) NFVER="${2:-}"; shift 2 ;;
|
||||
-m|--mc) MCVER="${2:-}"; shift 2 ;;
|
||||
-s|--share) SHARE="${2:-}"; shift 2 ;;
|
||||
-X|--xmx) XMX="${2:-}"; shift 2 ;;
|
||||
-x|--xms) XMS="${2:-}"; shift 2 ;;
|
||||
--accept-eula) ACCEPT_EULA="--accept-eula"; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "unknown option: $1" >&2; usage 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ "$(id -u)" -ne 0 ] || die "run this as your normal user, not root — it sudos where needed"
|
||||
[ -n "$NAME" ] || die "missing -n <pack name>"
|
||||
[ -n "$AUTHOR" ] || die "missing -a <author>"
|
||||
[ -n "$BASEURL" ] || die "missing -u <mirror base url>, e.g. https://games.abdelbaki.eu/mods"
|
||||
|
||||
for s in mc-service-setup.sh packwiz-setup.sh mc-refresh-restart.sh; do
|
||||
[ -x "$HERE/$s" ] || die "missing or not executable: $HERE/$s"
|
||||
done
|
||||
|
||||
# Fail early rather than halfway through, since step 1 makes system changes.
|
||||
sudo -v || die "sudo is required"
|
||||
|
||||
step "1/2 Minecraft systemd service"
|
||||
sudo "$HERE/mc-service-setup.sh" \
|
||||
-s "$SHARE" -A "$USER" -X "$XMX" -x "$XMS" \
|
||||
${NFVER:+-N "$NFVER"} ${ACCEPT_EULA}
|
||||
|
||||
step "2/2 packwiz pack and player guides"
|
||||
# The admin group membership from step 1 isn't active in this shell yet, so run
|
||||
# the pack setup under the new group explicitly rather than telling you to log
|
||||
# out and back in.
|
||||
if id -nG "$USER" | tr ' ' '\n' | grep -qx minecraft; then
|
||||
"$HERE/packwiz-setup.sh" -n "$NAME" -a "$AUTHOR" -m "$MCVER" -u "$BASEURL" -s "$SHARE"
|
||||
else
|
||||
sg minecraft -c "$(printf '%q ' "$HERE/packwiz-setup.sh" -n "$NAME" -a "$AUTHOR" \
|
||||
-m "$MCVER" -u "$BASEURL" -s "$SHARE")"
|
||||
fi
|
||||
|
||||
step "Done"
|
||||
cat <<EOF
|
||||
Start the server:
|
||||
|
||||
sudo systemctl start minecraft
|
||||
journalctl -u minecraft -f
|
||||
|
||||
Keep it in sync after adding mods to $SHARE/mods:
|
||||
|
||||
$HERE/mc-refresh-restart.sh
|
||||
|
||||
Edit the config block at the top of mc-refresh-restart.sh first — it needs
|
||||
PACK_DIR, BASE_URL and PACK_URL pointed at what this run just created.
|
||||
EOF
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# mc-refresh-restart.sh (adjusted for the /minecraft ZFS + HTTP-mirror build)
|
||||
#
|
||||
# 1. Syncs the packwiz pack against the live mods/ folder on the share
|
||||
# (adds new jars, re-pins changed ones, drops orphaned metadata)
|
||||
# 2. Refreshes index.toml
|
||||
# 3. Takes a ZFS snapshot of the share
|
||||
# 4. Restarts the Minecraft server via systemd (minecraft.service)
|
||||
# 5. Prunes snapshots to keep only the 8 most recent
|
||||
#
|
||||
# Requires: passwordless sudo for `systemctl restart minecraft` and `zfs`,
|
||||
# or run as root. packwiz must be in PATH.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
### CONFIG ###############################################
|
||||
SHARE="/minecraft" # ZFS dataset mountpoint
|
||||
PACK_DIR="${SHARE}/packs/mypack" # packwiz project root (has pack.toml)
|
||||
MODS_DIR="${SHARE}/mods" # live mods, served by the HTTP mirror
|
||||
BASE_URL="https://games.abdelbaki.eu/mods" # mirror base URL, no trailing slash
|
||||
PACK_URL="https://games.abdelbaki.eu/packs/mypack" # where PACK_DIR is served; "" to skip the check
|
||||
SERVICE_NAME="minecraft.service"
|
||||
START_TIMEOUT=60 # seconds to wait for the service to report active
|
||||
KEEP_SNAPSHOTS=8 # how many snapshots to retain
|
||||
SNAP_PREFIX="packwiz" # snapshot name prefix
|
||||
REMOVE_ORPHANS=1 # drop .pw.toml whose jar is gone
|
||||
###########################################################
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
|
||||
die() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
# Run privileged. Root runs directly; anyone else goes through sudo.
|
||||
priv() { if [ "$(id -u)" -eq 0 ]; then "$@"; else sudo "$@"; fi; }
|
||||
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
|
||||
### 0. Preflight #########################################
|
||||
command -v systemctl >/dev/null 2>&1 || die "systemctl not found."
|
||||
command -v packwiz >/dev/null 2>&1 || die "packwiz not found in PATH"
|
||||
|
||||
# An unmounted dataset leaves /minecraft as an empty dir on the root
|
||||
# filesystem. Refuse rather than snapshot nothing and archive nothing.
|
||||
mountpoint -q "${SHARE}" || die "${SHARE} is not a mountpoint — the ZFS dataset is not mounted.
|
||||
Check 'zpool status' and 'zfs mount -a'."
|
||||
|
||||
[ -f "${PACK_DIR}/pack.toml" ] || die "no pack.toml in ${PACK_DIR}"
|
||||
[ -d "${MODS_DIR}" ] || die "mods dir not found: ${MODS_DIR}"
|
||||
|
||||
DATASET="$(findmnt -no SOURCE "${SHARE}" 2>/dev/null || true)"
|
||||
[ -n "${DATASET}" ] || die "could not determine the dataset backing ${SHARE}"
|
||||
|
||||
### 1. Sync pack metadata against the live mods folder ####
|
||||
# Mods are sourced by URL with hashes pinned in the index, so `packwiz refresh`
|
||||
# alone will NOT notice a new or replaced jar — refresh only reindexes existing
|
||||
# .pw.toml files. Each jar has to be (re-)registered with `packwiz url add`.
|
||||
log "Syncing pack metadata against ${MODS_DIR}..."
|
||||
cd "${PACK_DIR}"
|
||||
|
||||
probe="$(curl -so /dev/null -w '%{http_code}' --max-time 10 "${BASE_URL}/" || echo 000)"
|
||||
[ "${probe}" = "000" ] && die "cannot reach ${BASE_URL} — packwiz needs it to hash each mod"
|
||||
|
||||
shopt -s nullglob
|
||||
JARS=("${MODS_DIR}"/*.jar)
|
||||
shopt -u nullglob
|
||||
(( ${#JARS[@]} > 0 )) || die "no .jar files in ${MODS_DIR}"
|
||||
|
||||
added=0; updated=0; unchanged=0; FAILED=()
|
||||
declare -A SEEN=()
|
||||
|
||||
for jar in "${JARS[@]}"; do
|
||||
file="$(basename "${jar}")"
|
||||
modname="$(printf '%s' "${file%.jar}" | sed -E 's/-[0-9][0-9A-Za-z.+_-]*$//')"
|
||||
[ -n "${modname}" ] || modname="${file%.jar}"
|
||||
SEEN["${modname}"]=1
|
||||
|
||||
meta="mods/${modname}.pw.toml"
|
||||
enc="$(printf '%s' "${file}" | sed -e 's/%/%25/g' -e 's/ /%20/g' -e 's/+/%2B/g')"
|
||||
|
||||
# Re-add only when the jar is new or its content changed. packwiz downloads
|
||||
# the URL to hash it, so skipping unchanged mods is what keeps this quick.
|
||||
if [ -f "${meta}" ]; then
|
||||
sha256="$(sha256sum "${jar}" | cut -d' ' -f1)"
|
||||
sha512="$(sha512sum "${jar}" | cut -d' ' -f1)"
|
||||
if grep -qF -e "${sha256}" -e "${sha512}" "${meta}"; then
|
||||
(( unchanged++ )) || true
|
||||
continue
|
||||
fi
|
||||
action="updated"
|
||||
else
|
||||
action="added"
|
||||
fi
|
||||
|
||||
if packwiz url add "${modname}" "${BASE_URL}/${enc}" >/dev/null 2>&1; then
|
||||
log " ${action}: ${modname}"
|
||||
[ "${action}" = "added" ] && (( added++ )) || (( updated++ ))
|
||||
else
|
||||
log " FAILED: ${modname}"
|
||||
FAILED+=("${file}")
|
||||
fi
|
||||
done
|
||||
|
||||
### 1b. Drop metadata for jars that are gone ##############
|
||||
if (( REMOVE_ORPHANS )); then
|
||||
shopt -s nullglob
|
||||
for meta in mods/*.pw.toml; do
|
||||
modname="$(basename "${meta}" .pw.toml)"
|
||||
[ -n "${SEEN[${modname}]:-}" ] && continue
|
||||
log " orphaned, removing: ${modname}"
|
||||
packwiz remove "${modname}" >/dev/null 2>&1 || rm -f -- "${meta}"
|
||||
done
|
||||
shopt -u nullglob
|
||||
fi
|
||||
|
||||
log "Sync complete: ${added} added, ${updated} updated, ${unchanged} unchanged."
|
||||
|
||||
if (( ${#FAILED[@]} > 0 )); then
|
||||
log "WARNING: ${#FAILED[@]} mod(s) failed to register:"
|
||||
printf ' %s\n' "${FAILED[@]}"
|
||||
die "refusing to restart with an incomplete pack — fix these first"
|
||||
fi
|
||||
|
||||
### 2. Refresh packwiz index #############################
|
||||
log "Refreshing packwiz index..."
|
||||
packwiz refresh
|
||||
log "index.toml refreshed."
|
||||
|
||||
### 2b. Verify clients can actually see the new pack ######
|
||||
# Clients pull pack.toml -> index.toml -> mods/*.pw.toml over HTTP. If PACK_DIR
|
||||
# isn't served, or a proxy is caching it, the server restarts onto a pack no
|
||||
# client can fetch — so compare what the mirror hands back against what's on
|
||||
# disk before touching the service.
|
||||
PACK_URL="${PACK_URL%/}"
|
||||
if [ -n "${PACK_URL}" ]; then
|
||||
log "Verifying ${PACK_URL}/pack.toml ..."
|
||||
remote="$(curl -fsS --max-time 10 -H 'Cache-Control: no-cache' "${PACK_URL}/pack.toml" 2>/dev/null || true)"
|
||||
|
||||
if [ -z "${remote}" ]; then
|
||||
die "cannot fetch ${PACK_URL}/pack.toml — ${PACK_DIR} is not being served.
|
||||
Point the web root at it, or set PACK_URL=\"\" to skip this check."
|
||||
fi
|
||||
|
||||
if [ "$(printf '%s' "${remote}" | sha256sum | cut -d' ' -f1)" \
|
||||
= "$(sha256sum pack.toml | cut -d' ' -f1)" ]; then
|
||||
log "Mirror is serving the current pack.toml."
|
||||
else
|
||||
die "${PACK_URL}/pack.toml differs from ${PACK_DIR}/pack.toml.
|
||||
Usually a caching proxy. Serve pack.toml and index.toml with
|
||||
'Cache-Control: no-cache', or purge the cache, then re-run."
|
||||
fi
|
||||
|
||||
# index.toml is fetched as a separate request and cached separately.
|
||||
idx="$(curl -fsS --max-time 10 -o /dev/null -w '%{http_code}' "${PACK_URL}/index.toml" || echo 000)"
|
||||
[ "${idx}" = "200" ] || die "${PACK_URL}/index.toml returned HTTP ${idx} — clients cannot resolve the pack"
|
||||
fi
|
||||
|
||||
### 3. Snapshot the share ################################
|
||||
# Taken BEFORE the restart so a server that fails to come up can be rolled
|
||||
# back to the exact state that last worked.
|
||||
TIMESTAMP="$(date '+%Y%m%d%H%M')"
|
||||
SNAPSHOT="${DATASET}@${SNAP_PREFIX}-${TIMESTAMP}"
|
||||
log "Snapshotting ${SNAPSHOT}..."
|
||||
if priv zfs snapshot "${SNAPSHOT}"; then
|
||||
log "Snapshot complete: ${SNAPSHOT}"
|
||||
else
|
||||
die "zfs snapshot failed"
|
||||
fi
|
||||
|
||||
### 4. Restart the server via systemd #####################
|
||||
log "Restarting ${SERVICE_NAME}..."
|
||||
priv systemctl restart "${SERVICE_NAME}"
|
||||
|
||||
waited=0
|
||||
until systemctl is-active --quiet "${SERVICE_NAME}" || (( waited >= START_TIMEOUT )); do
|
||||
sleep 1
|
||||
(( waited++ )) || true
|
||||
done
|
||||
|
||||
if systemctl is-active --quiet "${SERVICE_NAME}"; then
|
||||
log "${SERVICE_NAME} is active."
|
||||
else
|
||||
log "WARNING: ${SERVICE_NAME} did not report active within ${START_TIMEOUT}s."
|
||||
log " Check: journalctl -u ${SERVICE_NAME} -n 50"
|
||||
log " Roll back: sudo systemctl stop ${SERVICE_NAME} && sudo zfs rollback ${SNAPSHOT}"
|
||||
fi
|
||||
|
||||
### 5. Prune old snapshots, keep newest N #################
|
||||
log "Pruning snapshots, keeping newest ${KEEP_SNAPSHOTS}..."
|
||||
mapfile -t SNAPSHOTS < <(
|
||||
zfs list -H -o name -t snapshot -s creation "${DATASET}" 2>/dev/null \
|
||||
| grep -- "@${SNAP_PREFIX}-" || true
|
||||
)
|
||||
|
||||
if (( ${#SNAPSHOTS[@]} > KEEP_SNAPSHOTS )); then
|
||||
# Sorted oldest-first by creation, so the excess is at the front.
|
||||
for old in "${SNAPSHOTS[@]:0:$(( ${#SNAPSHOTS[@]} - KEEP_SNAPSHOTS ))}"; do
|
||||
log "Destroying old snapshot: ${old}"
|
||||
priv zfs destroy -- "${old}"
|
||||
done
|
||||
else
|
||||
log "Nothing to prune (${#SNAPSHOTS[@]}/${KEEP_SNAPSHOTS})."
|
||||
fi
|
||||
|
||||
log "Done."
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Minecraft NeoForge server as a systemd service on the ZFS share.
|
||||
#
|
||||
# sudo ./mc-service-setup.sh --accept-eula
|
||||
# sudo ./mc-service-setup.sh --accept-eula -N 21.1.72 -X 12G
|
||||
#
|
||||
# Creates a `minecraft` system user, optionally installs the NeoForge server,
|
||||
# and writes minecraft.service + minecraft.socket. The socket is a console FIFO
|
||||
# at /run/minecraft-console, which is what lets systemd stop the server with a
|
||||
# real `stop` command so the world saves instead of being killed mid-write.
|
||||
#
|
||||
# systemctl start|stop|restart minecraft
|
||||
# echo "say hello" | sudo tee /run/minecraft-console # console commands
|
||||
# journalctl -u minecraft -f # console output
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SHARE="/minecraft"
|
||||
MCUSER="minecraft"
|
||||
XMS="4G"
|
||||
XMX="8G"
|
||||
NFVER="" # set to install the NeoForge server
|
||||
ADMIN="" # human user who authors the pack on the share
|
||||
ACCEPT_EULA=0
|
||||
UNIT_DIR="/etc/systemd/system"
|
||||
FIFO="/run/minecraft-console"
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
info() { echo ">>> $*"; }
|
||||
warn() { echo " warning: $*" >&2; }
|
||||
usage() { sed -n '2,17p' "$0" | sed 's/^# \?//'; exit "${1:-0}"; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-s|--share) SHARE="${2:-}"; shift 2 ;;
|
||||
-U|--user) MCUSER="${2:-}"; shift 2 ;;
|
||||
-x|--xms) XMS="${2:-}"; shift 2 ;;
|
||||
-X|--xmx) XMX="${2:-}"; shift 2 ;;
|
||||
-N|--neoforge) NFVER="${2:-}"; shift 2 ;;
|
||||
-A|--admin) ADMIN="${2:-}"; shift 2 ;;
|
||||
--accept-eula) ACCEPT_EULA=1; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "unknown option: $1" >&2; usage 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "run this with sudo — it creates a user and writes unit files"
|
||||
|
||||
# ----------------------------------------------------------------- share ----
|
||||
|
||||
[ -d "$SHARE" ] || die "$SHARE does not exist"
|
||||
mountpoint -q "$SHARE" || die "$SHARE is not a mountpoint — the ZFS dataset is not mounted.
|
||||
Check 'zpool status' and 'zfs mount -a' first; installing a server onto the
|
||||
unmounted path would fill the root filesystem instead of the pool."
|
||||
|
||||
command -v java >/dev/null || die "java not found — install it first: dnf install java-21-openjdk-headless"
|
||||
|
||||
jver="$(java -version 2>&1 | head -1 | grep -oE '"[0-9]+' | tr -d '"' || echo 0)"
|
||||
[ "${jver:-0}" -ge 21 ] || warn "java looks like version $jver; NeoForge for 1.21.x needs 21"
|
||||
|
||||
# ------------------------------------------------------------------ user ----
|
||||
|
||||
if ! getent passwd "$MCUSER" >/dev/null; then
|
||||
info "creating system user $MCUSER"
|
||||
useradd --system --home-dir "$SHARE" --shell /usr/sbin/nologin \
|
||||
--comment "Minecraft server" "$MCUSER"
|
||||
else
|
||||
info "user $MCUSER already exists"
|
||||
fi
|
||||
|
||||
info "setting ownership of $SHARE to $MCUSER"
|
||||
chown -R "$MCUSER":"$MCUSER" "$SHARE"
|
||||
|
||||
# Without this the chown above locks the human out of the share they author the
|
||||
# pack on. Share root and packs/ become group-writable and setgid, so files
|
||||
# created by either side stay readable to both.
|
||||
if [ -n "$ADMIN" ]; then
|
||||
getent passwd "$ADMIN" >/dev/null || die "admin user does not exist: $ADMIN"
|
||||
info "granting $ADMIN write access to $SHARE (group $MCUSER)"
|
||||
usermod -aG "$MCUSER" "$ADMIN"
|
||||
chown "$ADMIN":"$MCUSER" "$SHARE"
|
||||
chmod 2775 "$SHARE"
|
||||
install -d -o "$ADMIN" -g "$MCUSER" -m 2775 "$SHARE/packs"
|
||||
warn "$ADMIN's new group membership needs a fresh login to take effect in
|
||||
existing shells — 'newgrp $MCUSER' works for the current one."
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------- neoforge ----
|
||||
|
||||
if [ -n "$NFVER" ]; then
|
||||
info "installing NeoForge $NFVER server into $SHARE"
|
||||
url="https://maven.neoforged.net/releases/net/neoforged/neoforge/${NFVER}/neoforge-${NFVER}-installer.jar"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
curl -fsSL -o "$tmp/installer.jar" "$url" \
|
||||
|| die "could not download $url — check the version number exists"
|
||||
|
||||
# The installer writes into the working directory, so run it as the service
|
||||
# user to avoid leaving root-owned files scattered through the share.
|
||||
( cd "$SHARE" && runuser -u "$MCUSER" -- java -jar "$tmp/installer.jar" --installServer ) \
|
||||
|| die "NeoForge installer failed"
|
||||
rm -rf "$tmp"; trap - EXIT
|
||||
fi
|
||||
|
||||
# Find the generated arg file — its path carries the NeoForge version, so
|
||||
# detect it rather than hardcoding one that goes stale on the next upgrade.
|
||||
ARGS_FILE="$(find "$SHARE/libraries/net/neoforged/neoforge" -name unix_args.txt 2>/dev/null | sort -V | tail -1 || true)"
|
||||
|
||||
if [ -z "$ARGS_FILE" ]; then
|
||||
die "no NeoForge server found under $SHARE
|
||||
Install one with: sudo $0 --accept-eula -N <neoforge-version>
|
||||
Versions are listed at https://projects.neoforged.net/neoforged/neoforge"
|
||||
fi
|
||||
info "server args: $ARGS_FILE"
|
||||
|
||||
# ------------------------------------------------------------------ eula ----
|
||||
|
||||
if [ ! -f "$SHARE/eula.txt" ] || ! grep -q '^eula=true' "$SHARE/eula.txt"; then
|
||||
if (( ACCEPT_EULA )); then
|
||||
info "writing eula.txt (accepted via --accept-eula)"
|
||||
echo "eula=true" > "$SHARE/eula.txt"
|
||||
chown "$MCUSER":"$MCUSER" "$SHARE/eula.txt"
|
||||
else
|
||||
die "the Minecraft EULA has not been accepted.
|
||||
Read https://aka.ms/MinecraftEULA then re-run with --accept-eula,
|
||||
or write 'eula=true' into $SHARE/eula.txt yourself."
|
||||
fi
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------- jvm args ----
|
||||
|
||||
# Written fresh so -X/-x actually take effect on re-runs. G1 tuning is the
|
||||
# widely-used server config; adjust here if you want different flags.
|
||||
info "writing user_jvm_args.txt (Xms=$XMS Xmx=$XMX)"
|
||||
cat > "$SHARE/user_jvm_args.txt" <<EOF
|
||||
-Xms${XMS}
|
||||
-Xmx${XMX}
|
||||
-XX:+UseG1GC
|
||||
-XX:MaxGCPauseMillis=130
|
||||
-XX:+ParallelRefProcEnabled
|
||||
-XX:+UnlockExperimentalVMOptions
|
||||
-XX:+DisableExplicitGC
|
||||
-XX:+AlwaysPreTouch
|
||||
-XX:G1HeapRegionSize=8M
|
||||
EOF
|
||||
chown "$MCUSER":"$MCUSER" "$SHARE/user_jvm_args.txt"
|
||||
|
||||
# ----------------------------------------------------------------- units ----
|
||||
|
||||
info "writing $UNIT_DIR/minecraft.socket"
|
||||
cat > "$UNIT_DIR/minecraft.socket" <<EOF
|
||||
[Unit]
|
||||
Description=Minecraft server console FIFO
|
||||
PartOf=minecraft.service
|
||||
|
||||
[Socket]
|
||||
ListenFIFO=$FIFO
|
||||
Service=minecraft.service
|
||||
SocketUser=$MCUSER
|
||||
SocketGroup=$MCUSER
|
||||
SocketMode=0660
|
||||
RemoveOnStop=yes
|
||||
EOF
|
||||
|
||||
info "writing $UNIT_DIR/minecraft.service"
|
||||
cat > "$UNIT_DIR/minecraft.service" <<EOF
|
||||
[Unit]
|
||||
Description=Minecraft Server (NeoForge)
|
||||
Documentation=https://docs.neoforged.net/
|
||||
After=network-online.target zfs-mount.service
|
||||
Wants=network-online.target
|
||||
Requires=minecraft.socket
|
||||
RequiresMountsFor=$SHARE
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$MCUSER
|
||||
Group=$MCUSER
|
||||
WorkingDirectory=$SHARE
|
||||
|
||||
# The console FIFO becomes the server's stdin, so ExecStop can hand it a real
|
||||
# 'stop' command. Without this systemd would SIGTERM the JVM and risk a world
|
||||
# save being cut in half.
|
||||
Sockets=minecraft.socket
|
||||
StandardInput=socket
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
ExecStart=/usr/bin/java @$SHARE/user_jvm_args.txt @$ARGS_FILE nogui
|
||||
ExecStop=/bin/sh -c '/bin/echo stop > $FIFO'
|
||||
|
||||
# Generous: a big world with many chunks loaded can take a while to save.
|
||||
TimeoutStopSec=180
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
|
||||
# Hardening. The server only ever needs to write inside the share.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ReadWritePaths=$SHARE
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# ---------------------------------------------------------------- enable ----
|
||||
|
||||
info "reloading systemd"
|
||||
systemctl daemon-reload
|
||||
systemctl enable minecraft.socket minecraft.service >/dev/null
|
||||
|
||||
echo
|
||||
info "installed. Start it with:"
|
||||
cat <<EOF
|
||||
|
||||
sudo systemctl start minecraft
|
||||
journalctl -u minecraft -f # watch the console
|
||||
|
||||
echo "say hello" | sudo tee $FIFO # send a console command
|
||||
echo "op yourname" | sudo tee $FIFO
|
||||
|
||||
sudo systemctl stop minecraft # graceful, saves the world
|
||||
|
||||
Not started automatically — check server.properties in $SHARE first
|
||||
(difficulty, MOTD, and especially online-mode).
|
||||
EOF
|
||||
|
|
@ -0,0 +1,564 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Set up packwiz and initialise a NeoForge modpack, sourcing every mod from an
|
||||
# HTTP mirror of a local mods directory rather than Modrinth/CurseForge.
|
||||
# Everything lives on the ZFS share at /minecraft.
|
||||
#
|
||||
# ./packwiz-setup.sh -n "My Pack" -a themiro -u https://games.abdelbaki.eu/mods
|
||||
#
|
||||
# ./packwiz-setup.sh -n "My Pack" -a themiro -m 1.21.1 -f 21.1.72 \
|
||||
# -M /minecraft/mods -u http://192.168.200.41:8080/mods
|
||||
#
|
||||
# The mirror must already be serving those jars when this runs — packwiz
|
||||
# fetches each URL to compute its hash. Run as your normal user.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NAME=""
|
||||
AUTHOR=""
|
||||
MCVER="1.21.1"
|
||||
NFVER="" # empty = latest
|
||||
MODSDIR=""
|
||||
BASEURL=""
|
||||
DIR=""
|
||||
PACKVER="1.0.0"
|
||||
SHARE="/minecraft" # ZFS dataset everything lives on
|
||||
PACKROOT="" # defaults to $SHARE/packs
|
||||
WEBROOT="" # defaults to $SHARE — docroot the site is served from
|
||||
SITE_URL="" # defaults to the origin of $BASEURL
|
||||
PACK_URL="" # defaults to $SITE_URL/packs/<slug>
|
||||
SERVER_ADDR="" # defaults to the hostname of $SITE_URL
|
||||
GUIDES=1 # write the player setup guides
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
info() { echo ">>> $*"; }
|
||||
warn() { echo " warning: $*" >&2; }
|
||||
usage() { sed -n '2,15p' "$0" | sed 's/^# \?//'; exit "${1:-0}"; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-n|--name) NAME="${2:-}"; shift 2 ;;
|
||||
-a|--author) AUTHOR="${2:-}"; shift 2 ;;
|
||||
-m|--mc) MCVER="${2:-}"; shift 2 ;;
|
||||
-f|--neoforge) NFVER="${2:-}"; shift 2 ;;
|
||||
-M|--mods-dir) MODSDIR="${2:-}"; shift 2 ;;
|
||||
-u|--base-url) BASEURL="${2:-}"; shift 2 ;;
|
||||
-v|--version) PACKVER="${2:-}"; shift 2 ;;
|
||||
-d|--dir) DIR="${2:-}"; shift 2 ;;
|
||||
-s|--share) SHARE="${2:-}"; shift 2 ;;
|
||||
-r|--root) PACKROOT="${2:-}"; shift 2 ;;
|
||||
-W|--webroot) WEBROOT="${2:-}"; shift 2 ;;
|
||||
-U|--site-url) SITE_URL="${2:-}"; shift 2 ;;
|
||||
-P|--pack-url) PACK_URL="${2:-}"; shift 2 ;;
|
||||
-A|--server) SERVER_ADDR="${2:-}"; shift 2 ;;
|
||||
--no-guides) GUIDES=0; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "unknown option: $1" >&2; usage 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ "$(id -u)" -ne 0 ] || die "run this as your normal user, not root"
|
||||
[ -n "$NAME" ] || die "missing -n <pack name>"
|
||||
[ -n "$AUTHOR" ] || die "missing -a <author>"
|
||||
|
||||
# ----------------------------------------------------------------- share ----
|
||||
|
||||
# If the ZFS dataset isn't mounted, /minecraft is just an empty directory on
|
||||
# root — writing a pack there would silently land on the 15G root filesystem
|
||||
# instead of the pool. Refuse rather than fill up the wrong disk.
|
||||
[ -d "$SHARE" ] || die "$SHARE does not exist"
|
||||
if ! mountpoint -q "$SHARE"; then
|
||||
die "$SHARE exists but nothing is mounted there — the ZFS dataset is not mounted.
|
||||
Check 'zpool status' and 'zfs mount -a' before running this."
|
||||
fi
|
||||
|
||||
fstype="$(findmnt -no FSTYPE "$SHARE" 2>/dev/null || true)"
|
||||
[ "$fstype" = "zfs" ] || warn "$SHARE is $fstype, not zfs — continuing anyway"
|
||||
|
||||
[ -n "$PACKROOT" ] || PACKROOT="$SHARE/packs"
|
||||
|
||||
slug="$(printf '%s' "$NAME" | tr '[:upper:] ' '[:lower:]-' | tr -cd 'a-z0-9-')"
|
||||
[ -n "$slug" ] || die "pack name '$NAME' has no usable characters for a directory name"
|
||||
[ -n "$DIR" ] || DIR="$PACKROOT/$slug"
|
||||
|
||||
# Fail on permissions now, with the fix, rather than midway through init.
|
||||
mkdir -p "$PACKROOT" 2>/dev/null || true
|
||||
[ -w "$PACKROOT" ] || die "cannot write to $PACKROOT
|
||||
fix with: sudo install -d -o $USER -g $USER $PACKROOT"
|
||||
|
||||
# ----------------------------------------------------------------- mirror ---
|
||||
|
||||
# The mods dir defaults to the share; -u is what turns mirroring on.
|
||||
[ -n "$BASEURL" ] && [ -z "$MODSDIR" ] && MODSDIR="$SHARE/mods"
|
||||
if [ -n "$MODSDIR$BASEURL" ]; then
|
||||
[ -n "$BASEURL" ] || die "-M given without -u <http base url>"
|
||||
[ -d "$MODSDIR" ] || die "mods dir does not exist: $MODSDIR"
|
||||
fi
|
||||
BASEURL="${BASEURL%/}"
|
||||
|
||||
# ------------------------------------------------------------------ site ----
|
||||
|
||||
# Everything the guides need is derivable from the mirror URL, so one -u is
|
||||
# usually enough; each piece can still be overridden individually.
|
||||
[ -n "$SITE_URL" ] || [ -z "$BASEURL" ] || SITE_URL="$(awk -F/ '{print $1"//"$3}' <<<"$BASEURL")"
|
||||
SITE_URL="${SITE_URL%/}"
|
||||
|
||||
if [ -n "$SITE_URL" ]; then
|
||||
[ -n "$PACK_URL" ] || PACK_URL="$SITE_URL/packs/$slug"
|
||||
[ -n "$SERVER_ADDR" ] || SERVER_ADDR="$(sed -e 's#^[a-z]*://##' -e 's#:.*##' <<<"$SITE_URL")"
|
||||
fi
|
||||
PACK_URL="${PACK_URL%/}"
|
||||
|
||||
[ -n "$WEBROOT" ] || WEBROOT="$SHARE"
|
||||
|
||||
if (( GUIDES )); then
|
||||
if [ -z "$SITE_URL" ]; then
|
||||
warn "no -u or -U given, so the guide URLs can't be derived — skipping guides"
|
||||
GUIDES=0
|
||||
elif [ ! -d "$WEBROOT" ]; then
|
||||
die "webroot does not exist: $WEBROOT (set it with -W, or pass --no-guides)"
|
||||
elif [ ! -w "$WEBROOT" ]; then
|
||||
die "cannot write guides to $WEBROOT
|
||||
fix with: sudo install -d -o $USER -g $USER $WEBROOT
|
||||
or pass --no-guides"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ deps ----
|
||||
|
||||
if ! command -v go >/dev/null; then
|
||||
info "installing Go"
|
||||
sudo dnf install -y golang >/dev/null || die "could not install golang"
|
||||
fi
|
||||
|
||||
GOBIN="$(go env GOPATH)/bin"
|
||||
|
||||
if ! command -v packwiz >/dev/null && [ ! -x "$GOBIN/packwiz" ]; then
|
||||
info "building packwiz (pulls from the Go module proxy, takes a minute)"
|
||||
go install github.com/packwiz/packwiz@latest \
|
||||
|| die "go install failed — check network access to proxy.golang.org"
|
||||
fi
|
||||
|
||||
PACKWIZ="$(command -v packwiz || echo "$GOBIN/packwiz")"
|
||||
[ -x "$PACKWIZ" ] || die "packwiz not found after install; expected $GOBIN/packwiz"
|
||||
info "packwiz: $PACKWIZ"
|
||||
|
||||
if ! command -v packwiz >/dev/null; then
|
||||
for rc in ~/.zshrc ~/.bashrc; do
|
||||
[ -f "$rc" ] || continue
|
||||
grep -q '/go/bin\|GOPATH/bin' "$rc" && continue
|
||||
printf '\nexport PATH="$PATH:%s"\n' "$GOBIN" >> "$rc"
|
||||
info "added $GOBIN to PATH in $rc"
|
||||
done
|
||||
export PATH="$PATH:$GOBIN"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ init ----
|
||||
|
||||
[ -e "$DIR/pack.toml" ] && die "$DIR/pack.toml already exists — refusing to overwrite
|
||||
(run 'packwiz init --reinit' there if that's what you want)"
|
||||
|
||||
mkdir -p "$DIR"
|
||||
cd "$DIR"
|
||||
|
||||
# The NeoForge flag names have moved between packwiz builds, so ask this
|
||||
# binary what it supports instead of assuming.
|
||||
help="$("$PACKWIZ" init --help 2>&1 || true)"
|
||||
grep -q -- '--modloader' <<<"$help" || die "unexpected packwiz init interface; run '$PACKWIZ init' interactively"
|
||||
|
||||
args=(--name "$NAME" --author "$AUTHOR" --version "$PACKVER" --mc-version "$MCVER" --modloader neoforge)
|
||||
|
||||
if [ -n "$NFVER" ]; then
|
||||
grep -q -- '--neoforge-version' <<<"$help" \
|
||||
|| die "this packwiz build has no --neoforge-version flag; run '$PACKWIZ init' interactively"
|
||||
args+=(--neoforge-version "$NFVER")
|
||||
elif grep -q -- '--neoforge-latest' <<<"$help"; then
|
||||
args+=(--neoforge-latest)
|
||||
fi
|
||||
|
||||
info "initialising NeoForge pack for MC $MCVER in $(pwd)"
|
||||
"$PACKWIZ" init "${args[@]}"
|
||||
[ -f pack.toml ] || die "packwiz init did not produce a pack.toml"
|
||||
|
||||
# ---------------------------------------------------------------- mirror ----
|
||||
|
||||
if [ -n "$MODSDIR" ]; then
|
||||
grep -q 'url' <<<"$("$PACKWIZ" --help 2>&1 || true)" \
|
||||
|| die "this packwiz build has no 'url' subcommand — cannot add mods by URL"
|
||||
|
||||
# packwiz downloads each URL to hash it, so a mirror that isn't up yet
|
||||
# produces a pile of confusing per-mod failures. Check once, up front.
|
||||
info "checking mirror at $BASEURL"
|
||||
probe="$(curl -so /dev/null -w '%{http_code}' --max-time 10 "$BASEURL/" || echo 000)"
|
||||
case "$probe" in
|
||||
2*|3*|40[34]) ;; # a listing may legitimately be forbidden
|
||||
000) die "cannot reach $BASEURL — start the mirror before running this" ;;
|
||||
*) warn "$BASEURL/ returned HTTP $probe; continuing, individual mods may fail" ;;
|
||||
esac
|
||||
|
||||
shopt -s nullglob
|
||||
jars=("$MODSDIR"/*.jar)
|
||||
shopt -u nullglob
|
||||
[ "${#jars[@]}" -gt 0 ] || die "no .jar files found in $MODSDIR"
|
||||
|
||||
info "adding ${#jars[@]} mods from the mirror"
|
||||
failed=()
|
||||
for jar in "${jars[@]}"; do
|
||||
file="$(basename "$jar")"
|
||||
|
||||
# Mod name: filename without .jar and without a trailing version, since
|
||||
# packwiz uses this as the .pw.toml basename and it should stay stable
|
||||
# across version bumps.
|
||||
modname="$(printf '%s' "${file%.jar}" | sed -E 's/-[0-9][0-9A-Za-z.+_-]*$//')"
|
||||
[ -n "$modname" ] || modname="${file%.jar}"
|
||||
|
||||
# Percent-encode the filename for the URL; leave the base URL alone.
|
||||
enc="$(printf '%s' "$file" | sed -e 's/%/%25/g' -e 's/ /%20/g' -e 's/+/%2B/g')"
|
||||
|
||||
if "$PACKWIZ" url add "$modname" "$BASEURL/$enc" >/dev/null 2>&1; then
|
||||
printf ' + %s\n' "$modname"
|
||||
else
|
||||
printf ' ! %s (failed)\n' "$modname"
|
||||
failed+=("$file")
|
||||
fi
|
||||
done
|
||||
|
||||
"$PACKWIZ" refresh
|
||||
|
||||
if [ "${#failed[@]}" -gt 0 ]; then
|
||||
echo
|
||||
warn "${#failed[@]} of ${#jars[@]} mods failed to add:"
|
||||
printf ' %s\n' "${failed[@]}" >&2
|
||||
warn "re-run those by hand to see the error, e.g."
|
||||
warn " $PACKWIZ url add <name> $BASEURL/${failed[0]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- guides ----
|
||||
|
||||
# One page per OS at $WEBROOT/setup-<os>-packwiz.html, generated with the real
|
||||
# pack URL and server address baked in so players never edit anything.
|
||||
write_guide() {
|
||||
local os="$1" out="$2"
|
||||
local os_label install_intro install_cmd install_note java_note dl_intro dl_cmd dl_note
|
||||
|
||||
case "$os" in
|
||||
windows)
|
||||
os_label="Windows"
|
||||
install_intro="From PowerShell:"
|
||||
install_cmd="winget install --id PrismLauncher.PrismLauncher"
|
||||
install_note="Or grab the installer from prismlauncher.org if you'd rather not use winget."
|
||||
java_note="Prism can download and manage Java 21 for you — on first run it offers to, and you should let it."
|
||||
dl_intro="Open PowerShell in that folder and run:"
|
||||
dl_cmd="curl.exe -L -o packwiz-installer-bootstrap.jar https://github.com/packwiz/packwiz-installer-bootstrap/releases/latest/download/packwiz-installer-bootstrap.jar"
|
||||
dl_note="Or open that URL in a browser and move the downloaded file in."
|
||||
;;
|
||||
linux)
|
||||
os_label="Linux"
|
||||
install_intro="Flatpak works on every distro and is the easiest to keep current:"
|
||||
install_cmd="flatpak install flathub org.prismlauncher.PrismLauncher"
|
||||
install_note="Fedora also has <code class=\"inline\">sudo dnf install prismlauncher</code>, and Arch ships it in extra."
|
||||
java_note="The Flatpak bundles its own Java and ignores the system one — nothing to install. On a native package, Fedora: <code class=\"inline\">sudo dnf install java-21-openjdk</code>, Debian/Ubuntu: <code class=\"inline\">sudo apt install openjdk-21-jre</code>."
|
||||
dl_intro="From a terminal in that folder:"
|
||||
dl_cmd="curl -LO https://github.com/packwiz/packwiz-installer-bootstrap/releases/latest/download/packwiz-installer-bootstrap.jar"
|
||||
dl_note="Flatpak instances live under <code class=\"inline\">~/.var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/instances/</code>."
|
||||
;;
|
||||
*) die "unknown OS for guide: $os" ;;
|
||||
esac
|
||||
|
||||
# Expanding heredoc: \$INST_JAVA and \$INST_MC_DIR are escaped so they reach
|
||||
# the page literally — Prism substitutes those at launch, not this script.
|
||||
cat > "$out" <<EOF
|
||||
<title>$NAME — Setup on $os_label</title>
|
||||
<style>
|
||||
:root{--bg:#F4F5F8;--surface:#FFF;--sunk:#EAECF2;--border:#D8DCE6;--ink:#171A22;
|
||||
--ink-soft:#4A5163;--muted:#6E7689;--accent:#3350D8;--accent-dim:#E6EAFB;
|
||||
--warn:#B0652C;--warn-dim:#FBF0E4;--code-bg:#14161E;--code-ink:#DDE2F0;
|
||||
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
--mono:ui-monospace,"SF Mono","JetBrains Mono","Cascadia Mono",Menlo,Consolas,monospace}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#101219;--surface:#171A23;--sunk:#1E222D;
|
||||
--border:#2B3040;--ink:#EDEFF5;--ink-soft:#B4BBCC;--muted:#838CA0;--accent:#7C92F5;
|
||||
--accent-dim:#1C2340;--warn:#E0A063;--warn-dim:#2A2113;--code-bg:#0B0D14}}
|
||||
:root[data-theme=dark]{--bg:#101219;--surface:#171A23;--sunk:#1E222D;--border:#2B3040;
|
||||
--ink:#EDEFF5;--ink-soft:#B4BBCC;--muted:#838CA0;--accent:#7C92F5;--accent-dim:#1C2340;
|
||||
--warn:#E0A063;--warn-dim:#2A2113;--code-bg:#0B0D14}
|
||||
:root[data-theme=light]{--bg:#F4F5F8;--surface:#FFF;--sunk:#EAECF2;--border:#D8DCE6;
|
||||
--ink:#171A22;--ink-soft:#4A5163;--muted:#6E7689;--accent:#3350D8;--accent-dim:#E6EAFB;
|
||||
--warn:#B0652C;--warn-dim:#FBF0E4;--code-bg:#14161E}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);
|
||||
font-size:16px;line-height:1.65;-webkit-font-smoothing:antialiased}
|
||||
.wrap{max-width:46rem;margin:0 auto;padding:0 1.25rem 5rem}
|
||||
.masthead{padding:3.5rem 0 2.5rem}
|
||||
.eyebrow{font-family:var(--mono);font-size:.72rem;text-transform:uppercase;
|
||||
letter-spacing:.14em;color:var(--muted);margin:0 0 .9rem}
|
||||
h1{font-size:clamp(2rem,6vw,2.9rem);line-height:1.08;letter-spacing:-.025em;
|
||||
font-weight:700;text-wrap:balance;margin:0 0 .85rem}
|
||||
.lede{font-size:1.08rem;color:var(--ink-soft);margin:0 0 1.75rem;max-width:34rem}
|
||||
.facts{display:flex;flex-wrap:wrap;gap:.5rem;padding:0;margin:0;list-style:none}
|
||||
.facts li{display:flex;align-items:baseline;gap:.45rem;background:var(--surface);
|
||||
border:1px solid var(--border);border-radius:2px;padding:.35rem .7rem;font-size:.85rem}
|
||||
.facts b{font-family:var(--mono);font-size:.7rem;text-transform:uppercase;
|
||||
letter-spacing:.1em;color:var(--muted);font-weight:500}
|
||||
.facts span{font-family:var(--mono);font-size:.86rem}
|
||||
.step{display:grid;grid-template-columns:2.4rem 1fr;gap:0 1rem;
|
||||
padding-bottom:2.2rem;margin-bottom:2.2rem;border-bottom:1px solid var(--border)}
|
||||
.num{font-family:var(--mono);font-size:.8rem;font-weight:600;color:var(--accent);
|
||||
padding-top:.35rem;font-variant-numeric:tabular-nums}
|
||||
.step h2{grid-column:2;font-size:1.3rem;line-height:1.25;letter-spacing:-.015em;
|
||||
font-weight:650;margin:0 0 .6rem;text-wrap:balance}
|
||||
.step .body{grid-column:2;display:flex;flex-direction:column;gap:.85rem}
|
||||
.step p{margin:0}
|
||||
.step ul{margin:0;padding-left:1.15rem;display:flex;flex-direction:column;gap:.35rem}
|
||||
@media(max-width:34rem){.step{grid-template-columns:1fr;gap:0}
|
||||
.step h2,.step .body{grid-column:1}.num{padding-top:0;margin-bottom:.2rem}}
|
||||
.cmd{position:relative}
|
||||
.cmd pre{margin:0;background:var(--code-bg);color:var(--code-ink);border-radius:3px;
|
||||
padding:.85rem 3.2rem .85rem 1rem;overflow-x:auto;font-family:var(--mono);
|
||||
font-size:.83rem;line-height:1.6}
|
||||
.cmd code{font-family:inherit;white-space:pre}
|
||||
.copy{position:absolute;top:.5rem;right:.5rem;font-family:var(--mono);font-size:.66rem;
|
||||
text-transform:uppercase;letter-spacing:.08em;padding:.25rem .5rem;border:1px solid #333A4D;
|
||||
border-radius:2px;background:transparent;color:#8E97AD;cursor:pointer}
|
||||
.copy:hover{color:var(--code-ink);border-color:#59627A}
|
||||
.copy:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||
.copy[data-done="1"]{color:#7BD88F;border-color:#3D6B4A}
|
||||
code.inline{font-family:var(--mono);font-size:.86em;background:var(--sunk);
|
||||
border:1px solid var(--border);border-radius:2px;padding:.08em .34em;word-break:break-word}
|
||||
.caption{font-size:.82rem;color:var(--muted);margin:0}
|
||||
.note{border-left:2px solid var(--accent);background:var(--accent-dim);
|
||||
padding:.8rem 1rem;border-radius:0 3px 3px 0;font-size:.92rem}
|
||||
.note.warn{border-left-color:var(--warn);background:var(--warn-dim)}
|
||||
.note b{display:block;font-family:var(--mono);font-size:.68rem;text-transform:uppercase;
|
||||
letter-spacing:.1em;margin-bottom:.3rem;color:var(--accent);font-weight:600}
|
||||
.note.warn b{color:var(--warn)}
|
||||
.note p{margin:0}
|
||||
.mech{background:var(--surface);border:1px solid var(--border);border-radius:3px;
|
||||
padding:1.5rem;margin-bottom:2.2rem}
|
||||
.mech h2{font-size:1.15rem;margin:0 0 1rem;letter-spacing:-.01em}
|
||||
.flow{display:flex;flex-direction:column;margin:0;padding:0;list-style:none}
|
||||
.flow li{display:grid;grid-template-columns:8.5rem 1fr;gap:.9rem;padding:.6rem 0;
|
||||
border-top:1px solid var(--border);font-size:.9rem}
|
||||
.flow li:first-child{border-top:0;padding-top:0}
|
||||
.flow b{font-family:var(--mono);font-size:.75rem;color:var(--accent);
|
||||
font-weight:500;word-break:break-all}
|
||||
@media(max-width:32rem){.flow li{grid-template-columns:1fr;gap:.15rem}}
|
||||
h2.sec{font-size:1.15rem;margin:0 0 .9rem;letter-spacing:-.01em}
|
||||
details{border:1px solid var(--border);border-radius:3px;background:var(--surface);
|
||||
margin-bottom:.5rem}
|
||||
summary{cursor:pointer;padding:.75rem 1rem;font-weight:550;font-size:.95rem;
|
||||
list-style:none;display:flex;align-items:center;gap:.6rem}
|
||||
summary::-webkit-details-marker{display:none}
|
||||
summary::before{content:"+";font-family:var(--mono);color:var(--accent);font-size:1rem;line-height:1}
|
||||
details[open] summary::before{content:"\2212"}
|
||||
summary:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
|
||||
.answer{padding:0 1rem 1rem;display:flex;flex-direction:column;gap:.7rem;
|
||||
font-size:.92rem;color:var(--ink-soft)}
|
||||
.answer p{margin:0}
|
||||
footer{margin-top:3rem;padding-top:1.25rem;border-top:1px solid var(--border);
|
||||
font-size:.84rem;color:var(--muted)}
|
||||
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<header class="masthead">
|
||||
<p class="eyebrow">Prism Launcher · $os_label</p>
|
||||
<h1>Join $NAME, and stay on it</h1>
|
||||
<p class="lede">A one-time setup that makes Prism re-sync your mods from the
|
||||
server every time you hit Launch. A mod gets added, you get it next session —
|
||||
no reinstalls, no zip files in chat.</p>
|
||||
<ul class="facts">
|
||||
<li><b>MC</b> <span>$MCVER</span></li>
|
||||
<li><b>Loader</b> <span>NeoForge</span></li>
|
||||
<li><b>Java</b> <span>21</span></li>
|
||||
<li><b>Server</b> <span>$SERVER_ADDR</span></li>
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<section class="step"><div class="num">01</div>
|
||||
<h2>Install Prism Launcher</h2>
|
||||
<div class="body">
|
||||
<p>$install_intro</p>
|
||||
<div class="cmd"><pre><code>$install_cmd</code></pre><button class="copy" type="button">Copy</button></div>
|
||||
<p class="caption">$install_note</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step"><div class="num">02</div>
|
||||
<h2>Get Java 21</h2>
|
||||
<div class="body">
|
||||
<p>Minecraft $MCVER on NeoForge needs Java 21. Check what you have:</p>
|
||||
<div class="cmd"><pre><code>java -version</code></pre><button class="copy" type="button">Copy</button></div>
|
||||
<p class="caption">$java_note</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step"><div class="num">03</div>
|
||||
<h2>Create the instance</h2>
|
||||
<div class="body">
|
||||
<p>Click <b>Add Instance</b> and set it up as:</p>
|
||||
<ul>
|
||||
<li><b>Version</b> — $MCVER</li>
|
||||
<li><b>Mod loader</b> — NeoForge, latest for $MCVER</li>
|
||||
</ul>
|
||||
<p>Create it, but <b>don't launch it yet</b>. Leave the mods folder empty —
|
||||
the next two steps fill it, and anything you add by hand is removed on the
|
||||
first sync.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step"><div class="num">04</div>
|
||||
<h2>Drop in the installer</h2>
|
||||
<div class="body">
|
||||
<p>Select the instance, click <b>Folder</b> in the right-hand panel, then open
|
||||
the <code class="inline">.minecraft</code> folder inside it.</p>
|
||||
<p>$dl_intro</p>
|
||||
<div class="cmd"><pre><code>$dl_cmd</code></pre><button class="copy" type="button">Copy</button></div>
|
||||
<p class="caption">$dl_note</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step"><div class="num">05</div>
|
||||
<h2>Wire up the automatic pull</h2>
|
||||
<div class="body">
|
||||
<p>This is the step that does the work. Right-click the instance →
|
||||
<b>Edit</b> → <b>Settings</b> → <b>Custom commands</b>. Tick
|
||||
<b>Custom commands</b>, then paste this into <b>Pre-launch command</b>:</p>
|
||||
<div class="cmd"><pre><code>"\$INST_JAVA" -jar packwiz-installer-bootstrap.jar -g -s client $PACK_URL/pack.toml</code></pre><button class="copy" type="button">Copy</button></div>
|
||||
<p>Every launch from now on, Prism runs that first: it reads the pack from the
|
||||
server, downloads anything new, removes anything dropped, and only then starts
|
||||
the game.</p>
|
||||
<div class="note"><b>What the flags do</b>
|
||||
<p><code class="inline">-g</code> skips the progress window — drop it the first
|
||||
time so you can watch it work and see any errors.
|
||||
<code class="inline">-s client</code> installs client-side mods only.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step"><div class="num">06</div>
|
||||
<h2>Launch and add the server</h2>
|
||||
<div class="body">
|
||||
<p>Hit <b>Launch</b>. The first run downloads the full mod set, so give it a
|
||||
minute. At the title screen go to <b>Multiplayer</b> → <b>Add Server</b>:</p>
|
||||
<div class="cmd"><pre><code>$SERVER_ADDR</code></pre><button class="copy" type="button">Copy</button></div>
|
||||
<p>That's it. From here on just launch normally — updates arrive on their own.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mech">
|
||||
<h2>What happens on every launch</h2>
|
||||
<ol class="flow">
|
||||
<li><b>pack.toml</b><span>Fetched first. Names the index and its hash, so a changed pack is spotted immediately.</span></li>
|
||||
<li><b>index.toml</b><span>Lists every mod entry with its hash.</span></li>
|
||||
<li><b>mods/*.pw.toml</b><span>One per mod: where to download it, and the hash it must match.</span></li>
|
||||
<li><b>your mods folder</b><span>Missing files downloaded, changed files replaced, removed files deleted.</span></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="sec">When it doesn't work</h2>
|
||||
|
||||
<details><summary>"Unable to access jarfile packwiz-installer-bootstrap.jar"</summary>
|
||||
<div class="answer">
|
||||
<p>The pre-launch command ran somewhere other than the folder holding the jar. Use the full path instead:</p>
|
||||
<div class="cmd"><pre><code>"\$INST_JAVA" -jar "\$INST_MC_DIR/packwiz-installer-bootstrap.jar" -g -s client $PACK_URL/pack.toml</code></pre><button class="copy" type="button">Copy</button></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details><summary>Game launches with no mods at all</summary>
|
||||
<div class="answer">
|
||||
<p>The pre-launch command didn't run. Check that <b>Custom commands</b> is
|
||||
actually ticked in the instance settings — pasting the command without
|
||||
enabling the checkbox is the usual cause.</p>
|
||||
<p>Remove the <code class="inline">-g</code> flag and launch again; you'll see
|
||||
the installer window and whatever it's complaining about.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details><summary>A hash doesn't match and the install stops</summary>
|
||||
<div class="answer">
|
||||
<p>A file on the server changed but the pack still lists the old hash. That's
|
||||
the pack being out of date, not your machine — nothing to fix client-side.</p>
|
||||
<p>Tell $AUTHOR to re-run the refresh script, then launch again.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details><summary>Crash mentioning class versions or UnsupportedClassVersionError</summary>
|
||||
<div class="answer">
|
||||
<p>Wrong Java. NeoForge for $MCVER needs Java 21, and an older one loads far
|
||||
enough to crash confusingly.</p>
|
||||
<p>In Prism: <b>Edit</b> → <b>Settings</b> → <b>Java</b> → <b>Auto-detect</b>, and pick a 21.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details><summary>Mods I added by hand keep disappearing</summary>
|
||||
<div class="answer">
|
||||
<p>Working as intended — the sync makes your mods folder match the server's
|
||||
exactly, so anything not in the pack is removed each launch.</p>
|
||||
<p>Want a personal client-side mod? Ask $AUTHOR to add it to the pack.</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<footer>Pack served from $SITE_URL · $NAME $PACKVER · If the pre-launch step
|
||||
fails, the game still launches, with whatever mods it had last time.</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.copy').forEach(function(b){
|
||||
b.addEventListener('click',function(){
|
||||
var c=b.parentElement.querySelector('code'); if(!c)return;
|
||||
navigator.clipboard.writeText(c.textContent.trim()).then(function(){
|
||||
b.textContent='Copied'; b.dataset.done='1';
|
||||
setTimeout(function(){b.textContent='Copy';delete b.dataset.done;},1600);
|
||||
}).catch(function(){
|
||||
b.textContent='Ctrl+C';
|
||||
setTimeout(function(){b.textContent='Copy';},1600);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
EOF
|
||||
}
|
||||
|
||||
if (( GUIDES )); then
|
||||
info "writing player setup guides to $WEBROOT"
|
||||
for os in windows linux; do
|
||||
out="$WEBROOT/setup-$os-packwiz.html"
|
||||
write_guide "$os" "$out"
|
||||
chmod 644 "$out"
|
||||
printf ' %s -> %s/setup-%s-packwiz.html\n' "$out" "$SITE_URL" "$os"
|
||||
done
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- finish ----
|
||||
|
||||
cat > .gitignore <<'EOF'
|
||||
# packwiz build output
|
||||
/build/
|
||||
EOF
|
||||
|
||||
if command -v git >/dev/null && [ ! -d .git ]; then
|
||||
git init -q && git add -A && git commit -qm "Initial packwiz pack" \
|
||||
&& info "git repo initialised with an initial commit"
|
||||
fi
|
||||
|
||||
echo
|
||||
info "done — pack is in $(pwd)"
|
||||
cat <<EOF
|
||||
|
||||
The mods are referenced by URL against $BASEURL, with hashes pinned in the
|
||||
index. If you replace a jar on the mirror, its hash changes and clients will
|
||||
fail the check — re-run 'packwiz url add' for that mod to update it.
|
||||
|
||||
packwiz refresh # rebuild the index after manual edits
|
||||
packwiz serve # serve pack.toml locally for testing
|
||||
packwiz url add <name> <url> # add one more mod from the mirror
|
||||
packwiz remove <name>
|
||||
|
||||
Clients need $(pwd) served at $PACK_URL — that is where
|
||||
pack.toml, index.toml and mods/*.pw.toml are fetched from. Verify with:
|
||||
|
||||
curl -fsS $PACK_URL/pack.toml | head -3
|
||||
EOF
|
||||
Loading…
Reference in New Issue