Next.js 16 + Tailwind v4 + shadcn v4 dashboard for managing a modded Forge 1.20.1 server. Includes server controls, player management, mod manager with Modrinth search and dependency resolution, world backups, snapshots, analytics, logs, and chat bridge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
277 lines
8 KiB
TypeScript
277 lines
8 KiB
TypeScript
import AdmZip from "adm-zip";
|
|
import { execSync } from "child_process";
|
|
import {
|
|
existsSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
statSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "fs";
|
|
import { join } from "path";
|
|
import { status } from "minecraft-server-util";
|
|
import {
|
|
MODS_DIR,
|
|
CLIENT_MODS_DIR,
|
|
MOD_METADATA_FILE,
|
|
MC_SERVER_IP,
|
|
MC_SERVER_PORT,
|
|
} from "./constants";
|
|
import { sendCommand } from "./rcon";
|
|
import type { ModSide } from "./modrinth";
|
|
|
|
const MODPACK_ZIP = "/var/www/minecraft/modpack.zip";
|
|
const MODPACK_MODS = "/var/www/minecraft/mods";
|
|
|
|
export type ModMeta = {
|
|
modId: string;
|
|
displayName: string;
|
|
version: string;
|
|
filename: string;
|
|
size: string;
|
|
side: ModSide;
|
|
};
|
|
|
|
// ── Mod metadata persistence ───────────────────────────────
|
|
|
|
type ModMetadataEntry = { projectId: string; side: ModSide };
|
|
type ModMetadataMap = Record<string, ModMetadataEntry>;
|
|
|
|
function loadModMetadata(): ModMetadataMap {
|
|
try {
|
|
return JSON.parse(readFileSync(MOD_METADATA_FILE, "utf8"));
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function saveModMetadata(metadata: ModMetadataMap): void {
|
|
writeFileSync(MOD_METADATA_FILE, JSON.stringify(metadata, null, 2));
|
|
}
|
|
|
|
export function addModMetadata(
|
|
filename: string,
|
|
entry: ModMetadataEntry
|
|
): void {
|
|
const metadata = loadModMetadata();
|
|
metadata[filename] = entry;
|
|
saveModMetadata(metadata);
|
|
}
|
|
|
|
export function removeModMetadata(filename: string): void {
|
|
const metadata = loadModMetadata();
|
|
delete metadata[filename];
|
|
saveModMetadata(metadata);
|
|
}
|
|
|
|
// ── Read mod metadata from JAR files ────────────────────────
|
|
|
|
function extractToml(content: string, key: string): string | null {
|
|
const regex = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "m");
|
|
const match = content.match(regex);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
function extractModMeta(
|
|
dir: string,
|
|
filename: string
|
|
): Omit<ModMeta, "side"> {
|
|
const filePath = join(dir, filename);
|
|
const stat = statSync(filePath);
|
|
let modId = "unknown";
|
|
let displayName = filename
|
|
.replace(/-(\d)/, " $1")
|
|
.replace(".jar", "")
|
|
.replace(/-/g, " ");
|
|
let version = "";
|
|
|
|
try {
|
|
const zip = new AdmZip(filePath);
|
|
const toml = zip.getEntry("META-INF/mods.toml");
|
|
if (toml) {
|
|
const content = toml.getData().toString("utf8");
|
|
modId = extractToml(content, "modId") || modId;
|
|
displayName = extractToml(content, "displayName") || displayName;
|
|
version = extractToml(content, "version") || "";
|
|
}
|
|
} catch {
|
|
// Use filename-based defaults
|
|
}
|
|
|
|
return {
|
|
modId,
|
|
displayName,
|
|
version,
|
|
filename,
|
|
size: (stat.size / 1024 / 1024).toFixed(1) + " MB",
|
|
};
|
|
}
|
|
|
|
export function getModDetails(): ModMeta[] {
|
|
const metadata = loadModMetadata();
|
|
|
|
// Server mods
|
|
const serverFiles = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
|
|
const serverMods: ModMeta[] = serverFiles.map((f) => {
|
|
const meta = extractModMeta(MODS_DIR, f);
|
|
const stored = metadata[f];
|
|
const side: ModSide = stored?.side === "server" ? "server" : stored?.side || "both";
|
|
return { ...meta, side };
|
|
});
|
|
|
|
// Client mods
|
|
let clientMods: ModMeta[] = [];
|
|
if (existsSync(CLIENT_MODS_DIR)) {
|
|
const clientFiles = readdirSync(CLIENT_MODS_DIR).filter((f) =>
|
|
f.endsWith(".jar")
|
|
);
|
|
clientMods = clientFiles.map((f) => ({
|
|
...extractModMeta(CLIENT_MODS_DIR, f),
|
|
side: "client" as ModSide,
|
|
}));
|
|
}
|
|
|
|
return [...serverMods, ...clientMods];
|
|
}
|
|
|
|
// ── Remove a single mod ─────────────────────────────────────
|
|
|
|
export function removeMod(filename: string): void {
|
|
const serverPath = join(MODS_DIR, filename);
|
|
const clientPath = join(CLIENT_MODS_DIR, filename);
|
|
|
|
if (existsSync(serverPath)) {
|
|
unlinkSync(serverPath);
|
|
} else if (existsSync(clientPath)) {
|
|
unlinkSync(clientPath);
|
|
} else {
|
|
throw new Error(`Mod "${filename}" not found`);
|
|
}
|
|
|
|
removeModMetadata(filename);
|
|
}
|
|
|
|
export function isClientOnlyMod(filename: string): boolean {
|
|
return (
|
|
!existsSync(join(MODS_DIR, filename)) &&
|
|
existsSync(join(CLIENT_MODS_DIR, filename))
|
|
);
|
|
}
|
|
|
|
// ── Server health check ─────────────────────────────────────
|
|
|
|
export type HealthProgress = {
|
|
phase: "waiting" | "process-alive" | "port-open" | "rcon-ready" | "failed" | "process-died";
|
|
elapsed: number;
|
|
message: string;
|
|
};
|
|
|
|
export async function waitForServerAdaptive(
|
|
onProgress?: (p: HealthProgress) => void | Promise<void>,
|
|
options?: { initialDelayMs?: number; maxWaitMs?: number }
|
|
): Promise<boolean> {
|
|
const { initialDelayMs = 8000, maxWaitMs = 600000 } = options ?? {};
|
|
const start = Date.now();
|
|
const elapsed = () => Date.now() - start;
|
|
const elapsedSec = () => Math.round(elapsed() / 1000);
|
|
|
|
const report = async (phase: HealthProgress["phase"], message: string) => {
|
|
await onProgress?.({ phase, elapsed: elapsed(), message });
|
|
};
|
|
|
|
await report("waiting", "Waiting for server process...");
|
|
await sleep(initialDelayMs);
|
|
|
|
let rconFailsWhileActive = 0;
|
|
|
|
while (elapsed() < maxWaitMs) {
|
|
// Tier 1: is the process alive?
|
|
let serviceState: string;
|
|
try {
|
|
serviceState = execSync("systemctl is-active minecraft.service", {
|
|
encoding: "utf8",
|
|
}).trim();
|
|
} catch {
|
|
await report("process-died", "Server process is not running");
|
|
return false;
|
|
}
|
|
|
|
if (serviceState !== "active" && serviceState !== "activating") {
|
|
await report("process-died", `Server process state: ${serviceState}`);
|
|
return false;
|
|
}
|
|
|
|
await report("process-alive", `Server process alive (${elapsedSec()}s)`);
|
|
|
|
// Tier 2: try RCON directly — this is the definitive "server is ready" signal
|
|
// RCON only starts after the server is fully loaded ("Done!")
|
|
try {
|
|
await sendCommand("list");
|
|
await report("rcon-ready", "Server is fully online");
|
|
return true;
|
|
} catch {
|
|
rconFailsWhileActive++;
|
|
}
|
|
|
|
// Tier 3: if RCON keeps failing, try MC protocol ping as fallback
|
|
// (handles case where RCON is misconfigured but server is actually ready)
|
|
if (rconFailsWhileActive >= 6) {
|
|
try {
|
|
await status(MC_SERVER_IP, MC_SERVER_PORT, { timeout: 3000 });
|
|
await report("port-open", "Server is online (port verified, RCON unavailable)");
|
|
return true;
|
|
} catch {}
|
|
}
|
|
|
|
await sleep(3000);
|
|
}
|
|
|
|
await report("failed", `Server did not respond within ${Math.round(maxWaitMs / 1000)}s`);
|
|
return false;
|
|
}
|
|
|
|
export async function waitForServer(timeoutMs = 600000): Promise<boolean> {
|
|
return waitForServerAdaptive(undefined, { maxWaitMs: timeoutMs });
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
// ── Modpack rebuild ─────────────────────────────────────────
|
|
|
|
export function rebuildModpack(): void {
|
|
// Zip server mods
|
|
execSync(
|
|
`cd ${MODS_DIR} && rm -f /tmp/modpack-rebuild.zip && zip -j /tmp/modpack-rebuild.zip *.jar`,
|
|
{ encoding: "utf8" }
|
|
);
|
|
|
|
// Add client mods to the same zip
|
|
if (existsSync(CLIENT_MODS_DIR)) {
|
|
try {
|
|
execSync(
|
|
`cd ${CLIENT_MODS_DIR} && zip -j /tmp/modpack-rebuild.zip *.jar 2>/dev/null || true`,
|
|
{ encoding: "utf8" }
|
|
);
|
|
} catch {}
|
|
}
|
|
|
|
// Copy to web root
|
|
execSync(`sudo cp /tmp/modpack-rebuild.zip ${MODPACK_ZIP}`);
|
|
execSync(`sudo rm -f ${MODPACK_MODS}/*.jar`);
|
|
execSync(`sudo cp ${MODS_DIR}/*.jar ${MODPACK_MODS}/`);
|
|
|
|
if (existsSync(CLIENT_MODS_DIR)) {
|
|
try {
|
|
execSync(
|
|
`sudo cp ${CLIENT_MODS_DIR}/*.jar ${MODPACK_MODS}/ 2>/dev/null || true`
|
|
);
|
|
} catch {}
|
|
}
|
|
|
|
// Generate modlist from both directories
|
|
execSync(
|
|
`(ls ${MODS_DIR}/*.jar -1 2>/dev/null; ls ${CLIENT_MODS_DIR}/*.jar -1 2>/dev/null) | xargs -I{} basename {} | sort | sudo tee /var/www/minecraft/modlist.txt > /dev/null`
|
|
);
|
|
}
|