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
Amir Alexander Abdelbaki 2026-08-05 22:23:34 +02:00
commit e738422dbe
7 changed files with 1282 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
# build output
/dist/

97
README.md Normal file
View File

@ -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.

104
build.sh Executable file
View File

@ -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

81
deploy.sh Executable file
View File

@ -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

204
mc-refresh-restart.sh Executable file
View File

@ -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."

230
mc-service-setup.sh Executable file
View File

@ -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

564
packwiz-setup.sh Executable file
View File

@ -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