feat(mods): replace packwiz with distribution-fed custom mod volume
Drop packwiz entirely. The server now loads a filtered mod subset from a local ./server-mods volume instead of fetching a packwiz pack at boot. New flow: - tooling/classify-mods.py (tomllib only, no network) reads each jar's META-INF/neoforge.mods.toml and seeds mods-sides.json with a side (client|server|both; default both). Existing entries are preserved. - mods-sides.json: committed, keyed by jar filename, hand-editable override surface. Initial seed: 65 both, 11 client, 0 server (76 jars). - tooling/sync-server-mods.sh mirrors the both/server jars from $DISTRIBUTION_WEB_ROOT into ./server-mods/ (authoritative; prunes strays; halts on a missing jar). Replaces render-pack.sh. - compose: minecraft mounts ./server-mods:/mods:ro with REMOVE_OLD_MODS=TRUE (itzg auto-syncs /mods -> /data/mods). Removed PACKWIZ_URL, the pack. extra_host (auth. kept for authlib), and depends_on caddy (drasl kept). Both classify-mods.py and sync-server-mods.sh resolve their source dir as: CLI arg / MODS_DIST_DIR env / $DISTRIBUTION_WEB_ROOT (in that order). Removed: tooling/render-pack.sh + add-custom-mod.sh (packwiz tooling), pack/ (packwiz source), custom/ (76 jars now sourced from the distribution), the pack. caddy vhost + its mounts/alias, and the packwiz .gitignore block. Client distribution is UNCHANGED: HeliosLauncher still installs the full modset from distribution.json. Docs (CLAUDE.md, plan/04/05/14, runtime) updated; plan/04-packwiz.md stubbed as superseded by plan/04-mods.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
172
plan/04-mods.md
Normal file
172
plan/04-mods.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# 04 — Mods (custom volume, distribution-fed)
|
||||
|
||||
> **Supersedes `04-packwiz.md`.** packwiz is being removed. This describes the
|
||||
> replacement: the server loads mods from a filtered local volume; clients keep
|
||||
> getting the full set from the HeliosLauncher `distribution.json`.
|
||||
|
||||
## Why drop packwiz
|
||||
|
||||
- The packwiz index tracked only **9** mods while the real modset (76 jars) lives
|
||||
in the distribution repo — packwiz was half-built and stale.
|
||||
- Manual packwiz curation (`.tmpl` render → `refresh` → re-hash) is the flagged
|
||||
pain point, and a single missing file (`custom/.gitkeep`) crashlooped the
|
||||
server (404 → installer aborts → minecraft never starts).
|
||||
- The launcher (HeliosLauncher) already distributes mods to clients via a
|
||||
Nebula-generated `distribution.json`. That is the canonical client mod list.
|
||||
The server just needs the same jars, minus client-only ones.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ulicraft-distribution/dist (another repo, = $DISTRIBUTION_WEB_ROOT)
|
||||
servers/ulicraft-1.21.1/
|
||||
forgemods/required/*.jar ← 76 jars, Nebula-managed (SOURCE OF TRUTH)
|
||||
distribution.json ← generated by Nebula; CLIENT install list
|
||||
│ │
|
||||
│ caddy distribution. vhost │ HeliosLauncher reads it →
|
||||
▼ ▼ installs ALL listed mods (client)
|
||||
guests' launcher ◄───────────────────────────────────
|
||||
|
||||
ulicraft-server (this repo)
|
||||
mods-sides.json ← { "<jar>": "client|server|both" } (committed, editable)
|
||||
tooling/classify-mods ← tomllib: seeds/updates mods-sides.json from jar manifests
|
||||
tooling/sync-server-mods ← copies both+server jars from $DIST → ./server-mods/
|
||||
server-mods/ (gitignored) ──mount──► minecraft /mods (REMOVE_OLD_MODS=TRUE)
|
||||
```
|
||||
|
||||
- **Client distribution: unchanged.** HeliosLauncher installs everything in
|
||||
`distribution.json`. No packwiz, no client zip.
|
||||
- **Server: filtered subset.** Only `both` + `server` jars are copied into the
|
||||
mounted `server-mods/`. `client`-tagged jars never reach the server.
|
||||
- **Single source of jars:** `$DISTRIBUTION_WEB_ROOT/.../forgemods/required/`.
|
||||
This repo stores no jars — only `mods-sides.json`.
|
||||
|
||||
## Classification — tomllib only (no Modrinth)
|
||||
|
||||
`tooling/classify-mods.py` reads each jar's `META-INF/neoforge.mods.toml` with
|
||||
Python `tomllib` and derives a side from the **`minecraft`/`neoforge` dependency
|
||||
`side`** (the field modders use to declare a mod's environment):
|
||||
|
||||
| manifest signal | derived side |
|
||||
|---|---|
|
||||
| `minecraft` (or `neoforge`) dep `side = "CLIENT"` | `client` |
|
||||
| dep `side = "SERVER"` | `server` |
|
||||
| dep `side = "BOTH"`, unspecified, or no manifest | `both` (default) |
|
||||
|
||||
Output is **merged** into `mods-sides.json`: existing (human-edited) entries are
|
||||
**preserved**; only new/unknown jars get a derived default. So the file is a
|
||||
one-time seed + permanent manual override surface. New jars default to `both`
|
||||
(your chosen default) unless their manifest says otherwise.
|
||||
|
||||
### Accuracy + the manual step (important)
|
||||
|
||||
tomllib reliably catches **hard client-only** mods — ones that declare their
|
||||
`minecraft`/`neoforge` dep as `CLIENT` (e.g. `sodium`, `iris`). Those are exactly
|
||||
the mods that refuse/break on a dedicated server, so the must-exclude set is
|
||||
handled automatically.
|
||||
|
||||
It will **not** auto-exclude client-*cosmetic* mods that declare `BOTH` in their
|
||||
manifest (e.g. `BetterF3`, `entityculling`, `sodium-extra`, `entity_texture_features`,
|
||||
`entity_model_features`, `ImmediatelyFast`, `drippyloadingscreen`, `melody`,
|
||||
`welcomescreen`, `Controlling`, `MouseTweaks`, `Searchables`, `tagtooltips`,
|
||||
`JustEnoughResources`, `TravelersTitles`, `BridgingMod`, `ponderjs`,
|
||||
`yeetusexperimentus`). These load on the server harmlessly (wasted RAM, no crash)
|
||||
unless you mark them `client` in `mods-sides.json`. **That manual curation is the
|
||||
"filter manually" step** — `classify-mods` seeds, you trim cosmetic-client mods.
|
||||
|
||||
Also worth an explicit decision in the override file:
|
||||
- `CustomSkinLoader` — server doesn't need it (skins resolve via Drasl/authlib);
|
||||
set `client` to keep it off the server.
|
||||
|
||||
## `mods-sides.json` format
|
||||
|
||||
```json
|
||||
{
|
||||
"jei-1.21.1-neoforge-19.27.0.340.jar": "both",
|
||||
"sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar": "client",
|
||||
"ftb-essentials-neoforge-2101.1.9.jar": "both",
|
||||
"rightclickharvest-neoforge-4.6.1+1.21.1.jar": "server"
|
||||
}
|
||||
```
|
||||
|
||||
Key = exact jar filename (matches `forgemods/required/*.jar`). Value ∈
|
||||
`client | server | both`. Hand-edit freely; `classify-mods` won't clobber it.
|
||||
|
||||
## Tooling
|
||||
|
||||
### `tooling/classify-mods.py` (dev-time, no network)
|
||||
1. Enumerate `$DISTRIBUTION_WEB_ROOT/servers/ulicraft-1.21.1/forgemods/required/*.jar`.
|
||||
2. For each jar not already in `mods-sides.json`: parse manifest → derive side
|
||||
(table above) → add entry.
|
||||
3. Report: counts per side, and **which jars were defaulted to `both`** (so you
|
||||
know what to review). Never overwrites existing entries.
|
||||
4. Optionally warn on entries in `mods-sides.json` whose jar no longer exists.
|
||||
|
||||
### `tooling/sync-server-mods.sh` (deploy-time, offline) — replaces `render-pack.sh`
|
||||
1. Read `mods-sides.json`.
|
||||
2. `mkdir -p server-mods`; for each `both`/`server` jar, copy from
|
||||
`$DISTRIBUTION_WEB_ROOT/.../forgemods/required/` → `server-mods/`.
|
||||
3. Remove any `server-mods/*.jar` not currently selected (keeps it authoritative;
|
||||
`REMOVE_OLD_MODS=TRUE` in the container also enforces this on `/data/mods`).
|
||||
4. **Halt** (non-zero exit) if a selected jar is missing from `$DIST`, or if
|
||||
`mods-sides.json` is absent — cautious, no silent partial sync.
|
||||
|
||||
## docker-compose.yml changes
|
||||
|
||||
```yaml
|
||||
# remove:
|
||||
# PACKWIZ_URL: ...
|
||||
# extra_hosts: pack.${BASE_DOMAIN}:host-gateway (auth. stays — authlib)
|
||||
environment:
|
||||
MODS_FILE: "" # ensure unset
|
||||
REMOVE_OLD_MODS: "TRUE" # mounted folder is authoritative
|
||||
volumes:
|
||||
- mc_data:/data
|
||||
- ./runtime:/extras:ro
|
||||
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
|
||||
```
|
||||
|
||||
`depends_on: caddy` can drop (server no longer fetches `pack.` at boot); keep
|
||||
`drasl` (authlib). `pack.` caddy vhost + the `./pack`/`./custom` mounts become
|
||||
dead — remove in the same pass.
|
||||
|
||||
## Deploy flow (updated `plan/14`)
|
||||
|
||||
```
|
||||
git pull --ff-only
|
||||
tooling/render-config.sh # drasl/nmsr/etc. (unchanged)
|
||||
tooling/sync-server-mods.sh # NEW — replaces render-pack.sh
|
||||
tooling/fetch-authlib.sh
|
||||
docker compose down --remove-orphans
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`$DISTRIBUTION_WEB_ROOT` must be present on the host (already is — caddy mounts
|
||||
it). `classify-mods.py` is a dev step run when the modset changes; it is **not**
|
||||
in the deploy path (keeps deploy offline + deterministic).
|
||||
|
||||
## Decommission checklist (on execution)
|
||||
|
||||
- [ ] `tooling/classify-mods.py` + generate initial `mods-sides.json` (review it).
|
||||
- [ ] `tooling/sync-server-mods.sh`.
|
||||
- [ ] compose: drop `PACKWIZ_URL`, `pack.` extra_host, `./pack`/`./custom` mounts,
|
||||
`pack.` caddy vhost; add `server-mods` mount + `REMOVE_OLD_MODS`.
|
||||
- [ ] delete `tooling/render-pack.sh`; remove its call from docs/deploy.
|
||||
- [ ] delete `pack/` (pack.toml, index.toml, `*.pw.toml*`, `.packwizignore`,
|
||||
CustomSkinLoader templates if duplicated in DIST) and `custom/` (jars now
|
||||
sourced from `$DIST`).
|
||||
- [ ] `.gitignore`: drop packwiz lines; add `server-mods/`.
|
||||
- [ ] supersede `plan/04-packwiz.md`; update `05-minecraft.md`, `14-deploy.md`,
|
||||
`CLAUDE.md` (mod source section), and the landing join steps if they still
|
||||
reference `pack.toml`.
|
||||
- [ ] resolves the host-local divergence around `pack/` ([[cochi-host-local-divergence]]).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Cosmetic-client mods** load on the server unless manually set `client` — RAM
|
||||
waste, not a crash. Curate `mods-sides.json` over time.
|
||||
- **No manifest** in a jar → defaults `both`. Acceptable; review the defaulted
|
||||
list `classify-mods` prints.
|
||||
- **Nebula regen** may rename jars (version bumps) → `mods-sides.json` keys go
|
||||
stale; `classify-mods` re-seeds new names as `both`, and `sync` warns on
|
||||
missing-from-DIST. Re-curate on big mod updates.
|
||||
@@ -1,37 +1,7 @@
|
||||
# packwiz — modpack source of truth
|
||||
# packwiz — modpack source of truth (SUPERSEDED)
|
||||
|
||||
## Summary
|
||||
|
||||
`packwiz` (installed at `~/go/bin/packwiz`) authors the modpack. The `./pack`
|
||||
directory is the **single source of truth** for mods, consumed by both:
|
||||
- the **server** (itzg `PACKWIZ_URL`), and
|
||||
- **clients** (Fjord launcher imports the pack / packwiz-installer).
|
||||
|
||||
Caddy serves `./pack` at `pack.ulicraft.net`. packwiz emits **metadata**
|
||||
(`pack.toml`, `index.toml`, `mods/*.pw.toml`); the `.pw.toml` files reference
|
||||
Modrinth/CF **CDN URLs**, so server and clients fetch the jars from those CDNs.
|
||||
The stack assumes the internet is present.
|
||||
|
||||
## Behaviors confirmed
|
||||
|
||||
- **Prune**: packwiz-installer tracks a manifest and **removes** client mods no
|
||||
longer in the index on re-run. (Server side via itzg likewise re-syncs.)
|
||||
- **Download source**: jars come from the CDN URLs in `.pw.toml`, not the pack
|
||||
host (which serves only metadata).
|
||||
- **itzg side filtering**: itzg downloads **server-side mods only**. Client-only
|
||||
mods MUST be tagged `side = "client"` (not `both`) or the server may pull and
|
||||
crash on them.
|
||||
|
||||
## NeoForge pinning
|
||||
|
||||
`pack.toml` pins MC `1.21.1` + NeoForge `21.1.x`. Verify exact NeoForge build
|
||||
against projects.neoforged.net before locking.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] `packwiz init` → `./pack` (MC 1.21.1, modloader neoforge, pinned version)
|
||||
- [ ] Curate mod shortlist (perf/tech/magic/storage/QoL/worldgen/map/voice)
|
||||
- [ ] Tag client-only mods `side = "client"`
|
||||
- [ ] `packwiz refresh`; verify `index.toml` committed (clients fetch it)
|
||||
- [ ] Point server `PACKWIZ_URL=http://pack.ulicraft.net/pack.toml`
|
||||
- [ ] Test install end-to-end on a fresh client
|
||||
> **Superseded by [`04-mods.md`](04-mods.md).** packwiz has been removed.
|
||||
> The server now loads a filtered mod subset from a local `server-mods/` volume
|
||||
> (synced from the distribution repo by `tooling/sync-server-mods.sh`); clients
|
||||
> still get the full set from the HeliosLauncher `distribution.json`.
|
||||
> See `04-mods.md` for the current design.
|
||||
|
||||
@@ -3,19 +3,24 @@
|
||||
## Summary
|
||||
|
||||
`itzg/minecraft-server:java21`, NeoForge 1.21.1, offline-mode + authlib-injector
|
||||
pointing at drasl. Mods via `PACKWIZ_URL`. itzg installs MC + NeoForge +
|
||||
libraries + server mods on first boot and re-syncs on restart; the stack assumes
|
||||
the internet is present, so `TYPE=NEOFORGE` boots normally every time.
|
||||
pointing at drasl. Mods come from a local `./server-mods` volume (mounted at
|
||||
`/mods`), NOT packwiz — see `04-mods.md`. itzg installs MC + NeoForge + libraries
|
||||
on first boot and auto-syncs `/mods` → `/data/mods` every boot; the stack assumes
|
||||
the internet is present (for the NeoForge installer), so `TYPE=NEOFORGE` boots
|
||||
normally every time.
|
||||
|
||||
## Server config
|
||||
|
||||
```
|
||||
TYPE=NEOFORGE, VERSION=1.21.1, NEOFORGE_VERSION=21.1.x
|
||||
PACKWIZ_URL=http://pack.${BASE_DOMAIN}/pack.toml
|
||||
REMOVE_OLD_MODS=TRUE # ./server-mods is the authoritative mod set
|
||||
volume ./server-mods:/mods:ro
|
||||
```
|
||||
|
||||
First boot installs MC + NeoForge + libraries + server mods into the `mc_data`
|
||||
volume. Subsequent boots re-sync the pack against `PACKWIZ_URL`.
|
||||
`./server-mods` is populated by `tooling/sync-server-mods.sh` (the `both`/`server`
|
||||
subset of `mods-sides.json`, copied from `$DISTRIBUTION_WEB_ROOT`). First boot
|
||||
installs MC + NeoForge + libraries into the `mc_data` volume; every boot itzg
|
||||
mirrors `/mods` into `/data/mods` and prunes removed ones (`REMOVE_OLD_MODS`).
|
||||
|
||||
## Auth / config
|
||||
|
||||
@@ -33,8 +38,9 @@ RCON enabled for mc-backup.
|
||||
## Tasks
|
||||
|
||||
- [ ] Compose: authlib URL `http://auth.${BASE_DOMAIN}/authlib-injector`
|
||||
- [ ] Compose: `PACKWIZ_URL=http://pack.${BASE_DOMAIN}/pack.toml`
|
||||
- [ ] Compose: mount `./server-mods:/mods:ro` + `REMOVE_OLD_MODS=TRUE`
|
||||
- [ ] Run `tooling/sync-server-mods.sh` before bring-up (populates `./server-mods`)
|
||||
- [ ] Mount `./runtime` for authlib-injector.jar at `/extras`
|
||||
- [ ] `depends_on`: drasl + caddy (pack served before server installs)
|
||||
- [ ] `depends_on`: drasl (authlib must resolve at boot)
|
||||
- [ ] Confirm `ENFORCE_SECURE_PROFILE=FALSE` ↔ drasl `SignPublicKeys=false`
|
||||
- [ ] Verify a client joins with their drasl skin
|
||||
|
||||
@@ -18,7 +18,8 @@ Production host for the Ulicraft stack.
|
||||
ssh cochi
|
||||
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||
git pull --ff-only
|
||||
tooling/render-config.sh # re-render drasl/nmsr/pack configs from .env
|
||||
tooling/render-config.sh # re-render drasl/nmsr configs from .env
|
||||
tooling/sync-server-mods.sh # sync ./server-mods from $DISTRIBUTION_WEB_ROOT
|
||||
docker compose down --remove-orphans
|
||||
docker compose up -d --build
|
||||
```
|
||||
@@ -30,12 +31,16 @@ ssh cochi 'set -e
|
||||
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||
git pull --ff-only
|
||||
tooling/render-config.sh
|
||||
tooling/sync-server-mods.sh
|
||||
docker compose down --remove-orphans
|
||||
docker compose up -d --build'
|
||||
```
|
||||
|
||||
`render-config.sh` re-renders configs (and `render-pack.sh` for the packwiz
|
||||
pack) from `.env` before the stack comes back up.
|
||||
`render-config.sh` re-renders the drasl/nmsr configs from `.env`;
|
||||
`sync-server-mods.sh` mirrors the `both`/`server` mod subset (from
|
||||
`mods-sides.json`) out of `$DISTRIBUTION_WEB_ROOT` into `./server-mods` before
|
||||
the stack comes back up. `$DISTRIBUTION_WEB_ROOT` must resolve on the host (it
|
||||
also backs the caddy `distribution.` mount).
|
||||
|
||||
### Landing rebuild (not part of `compose`)
|
||||
|
||||
@@ -52,6 +57,31 @@ docker compose restart drasl # apply CORS / RateLimit / RequireInvite
|
||||
`down`/`up` is not needed for a landing-only or flag-only change — rebuild `www/`
|
||||
and restart `drasl`.
|
||||
|
||||
## Known host-local divergence on `cochi` (uncommitted)
|
||||
|
||||
`cochi` carries deliberate local edits that are **not in the repo**, so
|
||||
`git pull --ff-only` **aborts** ("local changes would be overwritten") whenever
|
||||
an incoming commit also touches `docker-compose.yml`. As of 2026-06-09 the host
|
||||
has, uncommitted:
|
||||
|
||||
- `docker-compose.yml` — internal URLs forced `http`→`https`
|
||||
(`JVM_OPTS` authlib-injector), for the in-container HTTPS path. (The old
|
||||
`PACKWIZ_URL` divergence is moot — packwiz was removed; see `04-mods.md`.)
|
||||
- `dnsmasq/` (new), `caddy/caddy-root-ca.crt`, `landing/pnpm-workspace.yaml` —
|
||||
internal DNS + private CA so containers resolve/trust `https://auth.` etc.
|
||||
- `landing/package.json` modified. (The former `pack/` divergence is resolved —
|
||||
`pack/` and `custom/` were deleted with the packwiz removal.)
|
||||
|
||||
**Do not steamroll it.** Before a deploy that edits `docker-compose.yml`, either:
|
||||
1. **Converge** — commit the host edits to `main` + push, then a clean ff-pull
|
||||
deploys (preferred — ends the divergence). The https/dnsmasq/CA setup belongs
|
||||
in the repo.
|
||||
2. **Stash–pull–pop** — `git stash` → `git pull --ff-only` → `git stash pop`.
|
||||
Non-destructive; safe only when the incoming and host edits are in different
|
||||
regions of the file. Stop on a pop conflict.
|
||||
|
||||
Never `git reset --hard` / discard on the host without confirming first.
|
||||
|
||||
## What survives a hard restart
|
||||
|
||||
Named volumes persist — world and auth data are safe:
|
||||
@@ -70,7 +100,8 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
|
||||
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, `REGISTRATION_MODE` (`invite`|`open`,
|
||||
defaults `invite`), and the Let's Encrypt block (`render-config.sh` halts on an
|
||||
unset `BASE_DOMAIN` or an invalid `REGISTRATION_MODE`).
|
||||
- Docker + compose plugin, `packwiz`, `envsubst`, `git`.
|
||||
- Docker + compose plugin, `envsubst`, `jq` (used by `sync-server-mods.sh`;
|
||||
falls back to `python3` if absent), `git`.
|
||||
- Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`.
|
||||
- **First deploy only** — online prep (build landing, fetch launcher, fetch
|
||||
authlib into `runtime/`). Not needed for routine redeploys.
|
||||
@@ -78,7 +109,9 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
docker compose ps # caddy, drasl, minecraft, mc-backup, nmsr, uptime-kuma up
|
||||
docker compose ps # caddy, drasl, minecraft, mc-backup, nmsr, mc-status, uptime-kuma up
|
||||
# self-hosted live status feed (plan/15-mc-status.md):
|
||||
curl -fsS "https://$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/mcstatus/v2/status/java/$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)" | jq .online
|
||||
docker compose logs -f minecraft # watch for "Done"
|
||||
curl -fsS "https://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user