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 { memo, invalidate } from "./cache"; import type { ModSide } from "./modrinth"; const MODS_CACHE_KEY = "mods:details"; const MODS_CACHE_TTL = 10_000; export function invalidateModsCache(): void { invalidate("mods:"); } 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; 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); invalidateModsCache(); } 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; } type JarParseCacheEntry = { mtimeMs: number; size: number; meta: Omit; }; const jarParseCache = new Map(); function parseJarMeta( dir: string, filename: string ): Omit { let modId = "unknown"; let displayName = filename .replace(/-(\d)/, " $1") .replace(".jar", "") .replace(/-/g, " "); let version = ""; try { const zip = new AdmZip(join(dir, filename)); 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: "", }; } function extractModMeta( dir: string, filename: string ): Omit { const filePath = join(dir, filename); const stat = statSync(filePath); const cacheKey = filePath; const cached = jarParseCache.get(cacheKey); if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { return { ...cached.meta, size: (stat.size / 1024 / 1024).toFixed(1) + " MB" }; } const meta = parseJarMeta(dir, filename); jarParseCache.set(cacheKey, { mtimeMs: stat.mtimeMs, size: stat.size, meta, }); return { ...meta, size: (stat.size / 1024 / 1024).toFixed(1) + " MB" }; } export function getModDetails(): ModMeta[] { return memo(MODS_CACHE_KEY, MODS_CACHE_TTL, computeModDetails); } function computeModDetails(): 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); jarParseCache.delete(serverPath); } else if (existsSync(clientPath)) { unlinkSync(clientPath); jarParseCache.delete(clientPath); } else { throw new Error(`Mod "${filename}" not found`); } removeModMetadata(filename); invalidateModsCache(); } 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, options?: { initialDelayMs?: number; maxWaitMs?: number } ): Promise { 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 { return waitForServerAdaptive(undefined, { maxWaitMs: timeoutMs }); } function sleep(ms: number): Promise { 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` ); }