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