Initial commit: Minecraft dashboard
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>
This commit is contained in:
commit
dd69c17c3b
77 changed files with 7007 additions and 0 deletions
277
lib/mods.ts
Normal file
277
lib/mods.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
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`
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue