feat(landing): join-flow rework, player rosters, account page, mod list

Execute plan/18 (WS1-6) + plan/17.

- WS1: homepage join = 3 steps off packwiz (register -> UlicraftLauncher
  -> join); per-OS downloads from a synced launcher-manifest.json (schema +
  example committed); Fjord moved to its own /fjord page. Drop packwizUrl.
- WS2/3: mc-status gains an isolated /players endpoint (admin login ->
  Drasl GET /players, own client+TTL+token cache, never blocks the status
  path); online + registered rosters render shared name+avatar tiles;
  AVATAR_MODE env (default headiso).
- WS4: /account skin CRUD via browser-direct Drasl (login -> upload ->
  reset), in-memory token, players[0]; register relinks "Manage it here".
- WS5: footer link to status.${BASE_DOMAIN} in every footer.
- WS6: tooling/build-modlist.py generates mods.json + extracted logos from
  the distribution forgemods; ModList.astro renders a flat list; stat tile
  fed from the count.

Manifest/mods loaders read at build with a process.cwd() fallback (the
import.meta.url-only path does not resolve in Astro's bundled build).

New env: AVATAR_MODE, DRASL_ADMIN_USERNAME, DRASL_ADMIN_PASSWORD (mc-status
only). Generated mods.json / launcher-manifest.json / logos are gitignored;
.example.json files document the shapes. Build: 12 pages; mc-status builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 18:17:14 +02:00
parent 096ef82420
commit 6c7e259fcb
24 changed files with 2319 additions and 117 deletions

View File

@@ -0,0 +1,41 @@
{
"schemaVersion": 1,
"product": "UlicraftLauncher",
"version": "1.0.0",
"channel": "stable",
"releasedAt": "2026-06-01T12:00:00Z",
"minecraft": "1.21.1",
"neoforge": "21.1.233",
"builds": [
{
"os": "windows",
"arch": "x64",
"format": "exe",
"filename": "UlicraftLauncher-1.0.0-setup.exe",
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-setup.exe",
"size": 78643200,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
},
{
"os": "macos",
"arch": "universal",
"format": "dmg",
"filename": "UlicraftLauncher-1.0.0-universal.dmg",
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-universal.dmg",
"size": 92274688
},
{
"os": "linux",
"arch": "x64",
"format": "appimage",
"filename": "UlicraftLauncher-1.0.0-x86_64.AppImage",
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-x86_64.AppImage",
"size": 85983232
}
],
"fjordPack": {
"filename": "ulicraft-1.21.1.mrpack",
"url": "https://distribution.ulicraft.net/launcher/ulicraft-1.21.1.mrpack",
"size": 12582912
}
}

View File

@@ -0,0 +1,119 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ulicraft.net/schemas/launcher-manifest.schema.json",
"title": "Ulicraft launcher download manifest",
"description": "Produced by the custom-launcher release flow, consumed by the landing build to render per-OS download buttons. One file describes the current UlicraftLauncher release plus the optional Fjord pack.",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "product", "version", "builds"],
"properties": {
"schemaVersion": {
"description": "Bump on breaking shape changes. Landing pins the major it understands.",
"type": "integer",
"const": 1
},
"product": {
"description": "Display name of the launcher.",
"type": "string",
"examples": ["UlicraftLauncher"]
},
"version": {
"description": "Launcher release version (semver, no leading v).",
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(?:[-+].+)?$"
},
"channel": {
"description": "Release channel.",
"type": "string",
"enum": ["stable", "beta"],
"default": "stable"
},
"releasedAt": {
"description": "Release timestamp (ISO 8601 / RFC 3339).",
"type": "string",
"format": "date-time"
},
"minecraft": {
"description": "Target Minecraft version the launcher installs.",
"type": "string",
"examples": ["1.21.1"]
},
"neoforge": {
"description": "NeoForge version the distribution pins.",
"type": "string",
"examples": ["21.1.233"]
},
"builds": {
"description": "Downloadable launcher binaries, one entry per OS/arch/format.",
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/asset" }
},
"fjordPack": {
"description": "Optional importable pack for the Fjord (Prism) alternative path. Lives on its own landing page, not the homepage.",
"$ref": "#/$defs/file"
}
},
"$defs": {
"asset": {
"type": "object",
"additionalProperties": false,
"required": ["os", "arch", "format", "filename", "url"],
"properties": {
"os": {
"type": "string",
"enum": ["windows", "macos", "linux"]
},
"arch": {
"description": "CPU arch; 'universal' for a fat/any binary.",
"type": "string",
"enum": ["x64", "arm64", "universal"]
},
"format": {
"description": "Installer/package kind, drives the button hint.",
"type": "string",
"enum": ["exe", "msi", "dmg", "pkg", "appimage", "deb", "rpm", "tar.gz", "zip"]
},
"label": {
"description": "Optional human hint override (else derived from os+arch+format).",
"type": "string"
},
"filename": {
"type": "string",
"minLength": 1
},
"url": {
"description": "Absolute download URL.",
"type": "string",
"format": "uri",
"pattern": "^https://"
},
"size": {
"description": "Bytes, for showing a size next to the button.",
"type": "integer",
"minimum": 0
},
"sha256": {
"description": "Lowercase hex SHA-256 for integrity display/verify.",
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
},
"file": {
"type": "object",
"additionalProperties": false,
"required": ["filename", "url"],
"properties": {
"filename": { "type": "string", "minLength": 1 },
"url": {
"type": "string",
"format": "uri",
"pattern": "^https://"
},
"size": { "type": "integer", "minimum": 0 },
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
}
}
}
}

View File

@@ -0,0 +1,5 @@
{
"schemaVersion": 1,
"count": 0,
"mods": []
}

56
landing/src/data/mods.ts Normal file
View File

@@ -0,0 +1,56 @@
// Build-time loader for the auto-generated mod catalogue. tooling/build-modlist.py
// writes src/data/mods.json (gitignored, generated) from the distribution's full
// client modset; it MAY NOT EXIST on a fresh checkout, so we read it at runtime
// with fs (a static `import` would hard-fail the build when absent). Fall back to
// the committed .example.json (an empty catalogue); if even that is unreadable,
// expose an empty catalogue so the page simply renders no mods and the stat tile
// keeps its default. Same pattern as loadLauncherManifest in site.ts.
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
// `process`/`import.meta.url` reads work at build time, as in site.ts).
import { readFileSync } from "node:fs";
export type Mod = {
id: string;
name: string;
version: string;
authors: string;
description: string;
logo: string | null;
};
export type ModCatalogue = {
schemaVersion: number;
count: number;
mods: Mod[];
};
export function loadMods(): ModCatalogue {
const empty: ModCatalogue = { schemaVersion: 1, count: 0, mods: [] };
// Resolve relative to this module first (works when import.meta.url points at
// the source dir), then fall back to a cwd-relative path (Astro runs the build
// from the landing root, so src/data/* resolves there even when the module has
// been bundled to a different location). Prefer the generated mods.json, then
// the committed empty mods.example.json.
// @ts-ignore — `process` is untyped here (no @types/node), same as site.ts.
const cwd: string = typeof process !== "undefined" ? process.cwd() : ".";
const candidates: Array<string | URL> = [];
for (const name of ["mods.json", "mods.example.json"]) {
try {
candidates.push(new URL(name, import.meta.url));
} catch {
// import.meta.url unavailable — skip the module-relative candidate.
}
candidates.push(`${cwd}/src/data/${name}`);
}
for (const c of candidates) {
try {
const parsed = JSON.parse(readFileSync(c, "utf8")) as ModCatalogue;
if (parsed && Array.isArray(parsed.mods)) return parsed;
} catch {
// try the next candidate (file missing/unreadable → fall back)
}
}
return empty;
}
export const MODS = loadMods();

View File

@@ -2,8 +2,88 @@
// domains, URLs, theme, launcher files, and the structural shape of the
// stat/feature lists. Translatable copy lives in src/i18n/ui.ts.
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
// `process` below is untyped). Used only at build time to read the manifest.
import { readFileSync } from "node:fs";
import { MODS } from "./mods";
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
// Installed-pack mod count, fed by tooling/build-modlist.py via mods.json (see
// ./mods.ts). Feeds the homepage "Mods" stat tile. When the generated catalogue
// is absent/empty (fresh checkout), fall back to the honest "50+" estimate.
const MOD_COUNT_KEY = MODS.count > 0 ? `${MODS.count}` : "50+";
// NMSR render mode for avatar tiles (online roster + members grid). Read at
// build time, same as BASE_DOMAIN. e.g. "headiso" | "head" | "fullbody".
// @ts-ignore — `process` is untyped here (no @types/node), same as BASE_DOMAIN.
const AVATAR_MODE = process.env.AVATAR_MODE ?? "headiso";
// Launcher download manifest (per launcher-manifest.schema.json). The
// custom-launcher release flow syncs launcher-manifest.json into this dir
// (gitignored, generated) — same pattern as sync-server-mods.sh. It MAY NOT
// EXIST at build time, so we read it at runtime with fs (a static `import`
// would hard-fail the build when absent). Fall back to the committed
// .example.json (the documented shape); if even that is unreadable, expose an
// empty-builds manifest so the homepage simply renders no download buttons.
type LauncherBuild = {
os: "windows" | "macos" | "linux";
arch: "x64" | "arm64" | "universal";
format: "exe" | "msi" | "dmg" | "pkg" | "appimage" | "deb" | "rpm" | "tar.gz" | "zip";
label?: string;
filename: string;
url: string;
size?: number;
sha256?: string;
};
type LauncherFile = { filename: string; url: string; size?: number; sha256?: string };
type LauncherManifest = {
schemaVersion: number;
product: string;
version: string;
channel?: string;
releasedAt?: string;
minecraft?: string;
neoforge?: string;
builds: LauncherBuild[];
fjordPack?: LauncherFile;
};
function loadLauncherManifest(): LauncherManifest {
const empty: LauncherManifest = {
schemaVersion: 1,
product: "UlicraftLauncher",
version: "0.0.0",
builds: [],
};
// Resolve relative to this module first, then fall back to a cwd-relative path:
// Astro bundles this module so import.meta.url may not point at src/data/ at
// build time, but the build runs from the landing root, so <cwd>/src/data/*
// resolves. Prefer the synced launcher-manifest.json, then the committed example.
// @ts-ignore — `process` is untyped here (no @types/node).
const cwd: string = typeof process !== "undefined" ? process.cwd() : ".";
const candidates: Array<string | URL> = [];
for (const name of ["launcher-manifest.json", "launcher-manifest.example.json"]) {
try {
candidates.push(new URL(name, import.meta.url));
} catch {
// import.meta.url unavailable — skip the module-relative candidate.
}
candidates.push(`${cwd}/src/data/${name}`);
}
for (const c of candidates) {
try {
const parsed = JSON.parse(readFileSync(c, "utf8")) as LauncherManifest;
if (parsed && Array.isArray(parsed.builds)) return parsed;
} catch {
// try the next candidate (synced file missing → fall back to example)
}
}
return empty;
}
const LAUNCHER_MANIFEST = loadLauncherManifest();
// REGISTRATION_MODE (invite|open) is read at build time, same .env as Drasl's
// render-config.sh. "invite" => the register form must collect an invite code.
const REGISTRATION_MODE = process.env.REGISTRATION_MODE ?? "invite";
@@ -43,19 +123,17 @@ export const site = {
// Requires CORSAllowOrigins to include the apex in drasl config.toml.
draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`,
// TODO: packwiz removed — the `pack.` vhost no longer exists, so this URL 404s.
// The modpack is now distributed to clients by HeliosLauncher via the
// distribution.json (server loads a filtered subset from ./server-mods/; see
// plan/04-mods.md). The join flow still references this in the launcher step
// and needs to be redesigned around the launcher/distribution flow. Kept
// (non-empty) only so the Astro build + the CopyChip in [...lang].astro don't
// break; do not treat as live.
packwizUrl: `https://pack.${BASE_DOMAIN}/pack.toml`,
// NMSR avatar renderer root. Player heads come from OUR Drasl skins, not
// Mojang/Crafatar — head URL is `${avatarUrl}/headiso/<uuid>?size=64`.
avatarUrl: `https://avatar.${BASE_DOMAIN}`,
// NMSR render mode for avatar tiles (ServerCard online row + PlayerRoster
// members grid). Avatar URL = `${avatarUrl}/${avatarMode}/<uuid>?size=N`.
avatarMode: AVATAR_MODE,
// Uptime Kuma status page (external link in footer).
statusUrl: `https://status.${BASE_DOMAIN}`,
// Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown
// before/without JS; live data comes from our SELF-HOSTED mc-status service
// (docker/mc-status, mcutil wrapper) reverse-proxied same-origin at the apex
@@ -65,12 +143,17 @@ export const site = {
status: {
slots: 10,
statusApi: `/api/mcstatus/v2/status/java/${BASE_DOMAIN}`,
// Registered-players roster (PlayerRoster.astro). Same mc-status service,
// same-origin via caddy /api/mcstatus/* → internal /players. Returns a
// sanitized [{uuid,name}] of every Drasl player (admin login is server-side
// in mc-status; no admin token in the page).
rosterApi: `/api/mcstatus/players`,
},
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
// come from ui.ts (same order).
stats: [
{ key: "50+" },
{ key: MOD_COUNT_KEY },
{ key: MC_VERSION },
{ key: "10" },
{ key: "6h" },
@@ -85,10 +168,16 @@ export const site = {
{ glyph: "heart", gold: true },
],
// Launcher downloads. Stable filenames are symlinks created by
// UlicraftLauncher download manifest — feeds the homepage join step 2
// per-OS buttons from `builds[]` (and the Fjord page's .mrpack via
// `fjordPack`). Synced from the custom-launcher repo; see loader above.
launcherManifest: LAUNCHER_MANIFEST,
// Fjord Launcher downloads (the alternative manual path on /fjord, OFF the
// homepage flow). Stable filenames are symlinks created by
// tooling/fetch-launcher.sh — keep these in sync with that script. OS names
// and hints are technical, kept language-neutral here.
launcher: {
fjord: {
name: "Fjord Launcher",
base: "/launcher/latest",
downloads: [