#!/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/ SERVER_ADDR="" # defaults to the hostname of $SITE_URL MCPORT="25565" # Minecraft port, shown in the guides SCHEME="" # force http or https on every generated URL GUIDES=1 # write the player setup guides FORCE=0 # move an existing pack aside and rebuild it die() { echo "error: $*" >&2; exit 1; } info() { echo ">>> $*"; } warn() { echo " warning: $*" >&2; } usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; 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 ;; -p|--port) MCPORT="${2:-}"; shift 2 ;; -S|--scheme) SCHEME="${2:-}"; shift 2 ;; --no-guides) GUIDES=0; shift ;; --force) FORCE=1; 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 " [ -n "$AUTHOR" ] || die "missing -a " # ----------------------------------------------------------------- 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 " # On a first deploy the server has not run yet, so mods/ may not exist. # That is not an error — create it and carry on with an empty pack. if [ ! -d "$MODSDIR" ]; then info "creating $MODSDIR" mkdir -p "$MODSDIR" || die "could not create $MODSDIR" fi fi BASEURL="${BASEURL%/}" # The pack is usually built on the server, where the mirror may only be # reachable over plain HTTP, while clients must be handed the public HTTPS URL. # --scheme rewrites the scheme on every URL this run generates. if [ -n "$SCHEME" ]; then case "$SCHEME" in http|https) ;; *) die "--scheme wants http or https, got: $SCHEME" ;; esac [ -z "$BASEURL" ] || BASEURL="$SCHEME://${BASEURL#*://}" [ -z "$SITE_URL" ] || SITE_URL="$SCHEME://${SITE_URL#*://}" [ -z "$PACK_URL" ] || PACK_URL="$SCHEME://${PACK_URL#*://}" fi # ------------------------------------------------------------------ site ---- # Everything the guides need is derivable from the mirror URL, so one -u is # usually enough; each piece can still be overridden individually. # Strip the last segment off the mirror URL rather than reducing to the origin, # so a mirror under a base path keeps it: # https://host/mods -> https://host # https://host/abdelpakDelta/mods -> https://host/abdelpakDelta [ -n "$SITE_URL" ] || [ -z "$BASEURL" ] || SITE_URL="${BASEURL%/*}" SITE_URL="${SITE_URL%/}" if [ -n "$SITE_URL" ]; then [ -n "$PACK_URL" ] || PACK_URL="$SITE_URL/packs/$slug" # Host only: strip scheme, then any path, then any port. Dropping the path # matters once SITE_URL can carry one. [ -n "$SERVER_ADDR" ] || SERVER_ADDR="$(sed -e 's#^[a-z]*://##' -e 's#/.*##' -e 's#:.*##' <<<"$SITE_URL")" fi PACK_URL="${PACK_URL%/}" # Minecraft assumes 25565, so only show a port when it is not that — a # needless ":25565" in the address is a classic source of player confusion. if [ "$MCPORT" = "25565" ]; then SERVER_DISPLAY="$SERVER_ADDR" else SERVER_DISPLAY="$SERVER_ADDR:$MCPORT" fi [ -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 ---- if [ -e "$DIR/pack.toml" ]; then if (( FORCE )); then # Moved aside rather than deleted: the pack dir may hold hand-edited # .pw.toml files or a git history, and a rebuild is not worth losing them. bak="$DIR.bak.$(date +%Y%m%d%H%M%S)" info "existing pack found — moving it to $bak" mv "$DIR" "$bak" else die "$DIR/pack.toml already exists — refusing to overwrite Pass --force to move it aside and rebuild, or 'packwiz init --reinit' there." fi fi 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 failed=() if [ "${#jars[@]}" -eq 0 ]; then # An empty pack is a valid starting point: drop jars in later and run # mc-refresh-restart.sh to register them. warn "no .jar files in $MODSDIR yet — creating an empty pack" else info "adding ${#jars[@]} mods from the mirror" 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 fi "$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 $BASEURL/${failed[0]}" fi fi # ---------------------------------------------------------------- guides ---- # One page per OS at $WEBROOT/setup--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 sudo dnf install prismlauncher, 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: sudo dnf install java-21-openjdk, Debian/Ubuntu: sudo apt install openjdk-21-jre." 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 ~/.var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/instances/." ;; *) 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" <$NAME — Setup on $os_label

Prism Launcher · $os_label

Join $NAME, and stay on it

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.

  • MC $MCVER
  • Loader NeoForge
  • Java 21
  • Server $SERVER_DISPLAY
01

Install Prism Launcher

$install_intro

$install_cmd

$install_note

02

Get Java 21

Minecraft $MCVER on NeoForge needs Java 21. Check what you have:

java -version

$java_note

03

Create the instance

Click Add Instance and set it up as:

  • Version — $MCVER
  • Mod loader — NeoForge, latest for $MCVER

Create it, but don't launch it yet. Leave the mods folder empty — the next two steps fill it, and anything you add by hand is removed on the first sync.

04

Drop in the installer

Select the instance, click Folder in the right-hand panel, then open the .minecraft folder inside it.

$dl_intro

$dl_cmd

$dl_note

05

Wire up the automatic pull

This is the step that does the work. Right-click the instance → EditSettingsCustom commands. Tick Custom commands, then paste this into Pre-launch command:

"\$INST_JAVA" -jar packwiz-installer-bootstrap.jar -g -s client $PACK_URL/pack.toml

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.

What the flags do

-g skips the progress window — drop it the first time so you can watch it work and see any errors. -s client installs client-side mods only.

06

Launch and add the server

Hit Launch. The first run downloads the full mod set, so give it a minute. At the title screen go to MultiplayerAdd Server:

$SERVER_DISPLAY

That's it. From here on just launch normally — updates arrive on their own.

What happens on every launch

  1. pack.tomlFetched first. Names the index and its hash, so a changed pack is spotted immediately.
  2. index.tomlLists every mod entry with its hash.
  3. mods/*.pw.tomlOne per mod: where to download it, and the hash it must match.
  4. your mods folderMissing files downloaded, changed files replaced, removed files deleted.

When it doesn't work

"Unable to access jarfile packwiz-installer-bootstrap.jar"

The pre-launch command ran somewhere other than the folder holding the jar. Use the full path instead:

"\$INST_JAVA" -jar "\$INST_MC_DIR/packwiz-installer-bootstrap.jar" -g -s client $PACK_URL/pack.toml
Game launches with no mods at all

The pre-launch command didn't run. Check that Custom commands is actually ticked in the instance settings — pasting the command without enabling the checkbox is the usual cause.

Remove the -g flag and launch again; you'll see the installer window and whatever it's complaining about.

A hash doesn't match and the install stops

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.

Tell $AUTHOR to re-run the refresh script, then launch again.

Crash mentioning class versions or UnsupportedClassVersionError

Wrong Java. NeoForge for $MCVER needs Java 21, and an older one loads far enough to crash confusingly.

In Prism: EditSettingsJavaAuto-detect, and pick a 21.

Mods I added by hand keep disappearing

Working as intended — the sync makes your mods folder match the server's exactly, so anything not in the pack is removed each launch.

Want a personal client-side mod? Ask $AUTHOR to add it to the pack.

Pack served from $SITE_URL · $NAME $PACKVER · If the pre-launch step fails, the game still launches, with whatever mods it had last time.
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 < # add one more mod from the mirror packwiz remove 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