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
30
lib/auth.ts
Normal file
30
lib/auth.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "Admin Login",
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (
|
||||
credentials?.username === process.env.ADMIN_USERNAME &&
|
||||
credentials?.password === process.env.ADMIN_PASSWORD
|
||||
) {
|
||||
return { id: "1", name: "Admin" };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
],
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 24 * 60 * 60,
|
||||
},
|
||||
});
|
||||
9
lib/constants.ts
Normal file
9
lib/constants.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const MC_SERVER_IP = "127.0.0.1";
|
||||
export const MC_SERVER_PORT = 25565;
|
||||
export const MODS_DIR = "/home/minecraft/server/mods";
|
||||
export const CLIENT_MODS_DIR = "/home/minecraft/server/client-mods";
|
||||
export const MOD_METADATA_FILE = "/home/minecraft/server/mod-metadata.json";
|
||||
export const MODPACK_ZIP = "/var/www/minecraft/modpack.zip";
|
||||
export const INSTALLER_BAT = "/var/www/minecraft/install-modpack.bat";
|
||||
export const RCON_PORT = 25575;
|
||||
export const RCON_PASSWORD = process.env.RCON_PASSWORD || "";
|
||||
229
lib/modrinth.ts
Normal file
229
lib/modrinth.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { MODS_DIR, CLIENT_MODS_DIR } from "./constants";
|
||||
import { getModDetails } from "./mods";
|
||||
|
||||
const API = "https://api.modrinth.com/v2";
|
||||
const GAME_VERSION = "1.20.1";
|
||||
const LOADER = "forge";
|
||||
|
||||
export type ModSide = "client" | "server" | "both";
|
||||
|
||||
export type SearchResult = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon_url: string;
|
||||
downloads: number;
|
||||
project_id: string;
|
||||
};
|
||||
|
||||
export type ModDownload = {
|
||||
projectId: string;
|
||||
versionId: string;
|
||||
title: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
isDependency: boolean;
|
||||
alreadyInstalled: boolean;
|
||||
side: ModSide;
|
||||
};
|
||||
|
||||
export type ResolveResult = {
|
||||
toInstall: ModDownload[];
|
||||
skipped: ModDownload[];
|
||||
conflicts: string[];
|
||||
};
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────
|
||||
|
||||
export async function searchMods(query: string): Promise<SearchResult[]> {
|
||||
const facets = JSON.stringify([
|
||||
[`categories:${LOADER}`],
|
||||
[`versions:${GAME_VERSION}`],
|
||||
["project_type:mod"],
|
||||
]);
|
||||
const url = `${API}/search?query=${encodeURIComponent(query)}&facets=${encodeURIComponent(facets)}&limit=12`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)" },
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Modrinth search failed: ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
return data.hits.map((h: Record<string, unknown>) => ({
|
||||
slug: h.slug,
|
||||
title: h.title,
|
||||
description: h.description,
|
||||
icon_url: h.icon_url || "",
|
||||
downloads: h.downloads,
|
||||
project_id: h.project_id,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Get project details (title + side info) ─────────────────
|
||||
|
||||
type ProjectDetails = {
|
||||
title: string;
|
||||
client_side: string;
|
||||
server_side: string;
|
||||
};
|
||||
|
||||
async function getProjectDetails(
|
||||
projectId: string
|
||||
): Promise<ProjectDetails | null> {
|
||||
try {
|
||||
const res = await fetch(`${API}/project/${projectId}`, {
|
||||
headers: {
|
||||
"User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)",
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return {
|
||||
title: data.title || projectId,
|
||||
client_side: data.client_side || "optional",
|
||||
server_side: data.server_side || "optional",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function classifySide(
|
||||
client_side: string,
|
||||
server_side: string
|
||||
): ModSide {
|
||||
if (server_side === "unsupported") return "client";
|
||||
if (client_side === "unsupported") return "server";
|
||||
return "both";
|
||||
}
|
||||
|
||||
// ── Get latest version for a project ────────────────────────
|
||||
|
||||
type VersionFile = { url: string; filename: string };
|
||||
type VersionDep = {
|
||||
project_id: string | null;
|
||||
dependency_type: string;
|
||||
};
|
||||
type VersionData = {
|
||||
id: string;
|
||||
name: string;
|
||||
files: VersionFile[];
|
||||
dependencies: VersionDep[];
|
||||
};
|
||||
|
||||
async function getLatestVersion(
|
||||
projectId: string
|
||||
): Promise<VersionData | null> {
|
||||
const url = `${API}/project/${projectId}/version?game_versions=["${GAME_VERSION}"]&loaders=["${LOADER}"]`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)" },
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const versions: VersionData[] = await res.json();
|
||||
return versions.length > 0 ? versions[0] : null;
|
||||
}
|
||||
|
||||
// ── Resolve dependencies ────────────────────────────────────
|
||||
|
||||
export async function resolveDependencies(
|
||||
projectIds: string[],
|
||||
titles: Record<string, string>
|
||||
): Promise<ResolveResult> {
|
||||
// Cache installed mods once for the entire resolution
|
||||
const installed = getModDetails();
|
||||
const toInstall: ModDownload[] = [];
|
||||
const skipped: ModDownload[] = [];
|
||||
const conflicts: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const queue = projectIds.map((id) => ({ id, isDep: false }));
|
||||
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift()!;
|
||||
if (visited.has(item.id)) continue;
|
||||
visited.add(item.id);
|
||||
|
||||
const [version, details] = await Promise.all([
|
||||
getLatestVersion(item.id),
|
||||
getProjectDetails(item.id),
|
||||
]);
|
||||
|
||||
if (!version || version.files.length === 0) {
|
||||
if (!item.isDep) {
|
||||
conflicts.push(
|
||||
`"${titles[item.id] || item.id}" has no Forge 1.20.1 version available`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = titles[item.id] || details?.title || item.id;
|
||||
const side = details
|
||||
? classifySide(details.client_side, details.server_side)
|
||||
: "both";
|
||||
const file = version.files[0];
|
||||
|
||||
const mod: ModDownload = {
|
||||
projectId: item.id,
|
||||
versionId: version.id,
|
||||
title,
|
||||
filename: file.filename,
|
||||
url: file.url,
|
||||
isDependency: item.isDep,
|
||||
alreadyInstalled: false,
|
||||
side,
|
||||
};
|
||||
|
||||
// Check if already installed (by filename as rough proxy)
|
||||
const filenameBase = file.filename.replace(/-[\d.]+.*\.jar$/, "").toLowerCase();
|
||||
const isInstalled = installed.some(
|
||||
(m) =>
|
||||
m.filename === file.filename ||
|
||||
m.filename.toLowerCase().startsWith(filenameBase)
|
||||
);
|
||||
|
||||
if (isInstalled) {
|
||||
mod.alreadyInstalled = true;
|
||||
skipped.push(mod);
|
||||
} else {
|
||||
toInstall.push(mod);
|
||||
}
|
||||
|
||||
// Queue required dependencies
|
||||
for (const dep of version.dependencies) {
|
||||
if (
|
||||
dep.dependency_type === "required" &&
|
||||
dep.project_id &&
|
||||
!visited.has(dep.project_id)
|
||||
) {
|
||||
queue.push({ id: dep.project_id, isDep: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { toInstall, skipped, conflicts };
|
||||
}
|
||||
|
||||
// ── Download a mod ──────────────────────────────────────────
|
||||
|
||||
export async function downloadMod(
|
||||
url: string,
|
||||
filename: string,
|
||||
side: ModSide = "both"
|
||||
): Promise<void> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to download ${filename}: ${res.status}`);
|
||||
|
||||
const targetDir = side === "client" ? CLIENT_MODS_DIR : MODS_DIR;
|
||||
if (side === "client" && !existsSync(CLIENT_MODS_DIR)) {
|
||||
mkdirSync(CLIENT_MODS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
writeFileSync(join(targetDir, filename), buffer);
|
||||
}
|
||||
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`
|
||||
);
|
||||
}
|
||||
18
lib/rcon.ts
Normal file
18
lib/rcon.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Rcon } from "rcon-client";
|
||||
import { MC_SERVER_IP, RCON_PORT, RCON_PASSWORD } from "./constants";
|
||||
|
||||
export async function sendCommand(command: string): Promise<string> {
|
||||
const rcon = await Rcon.connect({
|
||||
host: MC_SERVER_IP,
|
||||
port: RCON_PORT,
|
||||
password: RCON_PASSWORD,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await rcon.send(command);
|
||||
return response;
|
||||
} finally {
|
||||
rcon.end();
|
||||
}
|
||||
}
|
||||
160
lib/snapshots.ts
Normal file
160
lib/snapshots.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
copyFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
} from "fs";
|
||||
import { join } from "path";
|
||||
import { MODS_DIR, CLIENT_MODS_DIR, MOD_METADATA_FILE } from "./constants";
|
||||
|
||||
const SNAPSHOTS_DIR = "/home/minecraft/server/snapshots";
|
||||
const MAX_SNAPSHOTS = 10;
|
||||
|
||||
export type SnapshotMeta = {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
modCount: number;
|
||||
mods: string[];
|
||||
};
|
||||
|
||||
function ensureDir(dir: string) {
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
export function createSnapshot(name: string): SnapshotMeta {
|
||||
ensureDir(SNAPSHOTS_DIR);
|
||||
|
||||
// Sanitize name
|
||||
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60);
|
||||
const dirName = `${safeName}_${Date.now()}`;
|
||||
const snapDir = join(SNAPSHOTS_DIR, dirName);
|
||||
mkdirSync(snapDir);
|
||||
|
||||
// Back up server mods
|
||||
const mods = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
|
||||
for (const file of mods) {
|
||||
copyFileSync(join(MODS_DIR, file), join(snapDir, file));
|
||||
}
|
||||
|
||||
// Back up client mods
|
||||
if (existsSync(CLIENT_MODS_DIR)) {
|
||||
const clientMods = readdirSync(CLIENT_MODS_DIR).filter((f) =>
|
||||
f.endsWith(".jar")
|
||||
);
|
||||
if (clientMods.length > 0) {
|
||||
const clientSnapDir = join(snapDir, "client-mods");
|
||||
mkdirSync(clientSnapDir);
|
||||
for (const file of clientMods) {
|
||||
copyFileSync(join(CLIENT_MODS_DIR, file), join(clientSnapDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back up mod metadata
|
||||
if (existsSync(MOD_METADATA_FILE)) {
|
||||
copyFileSync(MOD_METADATA_FILE, join(snapDir, "mod-metadata.json"));
|
||||
}
|
||||
|
||||
const meta: SnapshotMeta = {
|
||||
name: safeName,
|
||||
createdAt: new Date().toISOString(),
|
||||
modCount: mods.length,
|
||||
mods,
|
||||
};
|
||||
|
||||
writeFileSync(join(snapDir, "meta.json"), JSON.stringify(meta, null, 2));
|
||||
|
||||
// Enforce max snapshots
|
||||
pruneOldSnapshots();
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
export function restoreSnapshot(dirName: string): void {
|
||||
const snapDir = join(SNAPSHOTS_DIR, dirName);
|
||||
if (!existsSync(snapDir)) {
|
||||
throw new Error(`Snapshot "${dirName}" not found`);
|
||||
}
|
||||
|
||||
// Clear current server mods
|
||||
const currentMods = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
|
||||
for (const file of currentMods) {
|
||||
unlinkSync(join(MODS_DIR, file));
|
||||
}
|
||||
|
||||
// Copy snapshot server mods back
|
||||
const snapFiles = readdirSync(snapDir).filter((f) => f.endsWith(".jar"));
|
||||
for (const file of snapFiles) {
|
||||
copyFileSync(join(snapDir, file), join(MODS_DIR, file));
|
||||
}
|
||||
|
||||
// Restore client mods
|
||||
if (existsSync(CLIENT_MODS_DIR)) {
|
||||
const currentClient = readdirSync(CLIENT_MODS_DIR).filter((f) =>
|
||||
f.endsWith(".jar")
|
||||
);
|
||||
for (const file of currentClient) {
|
||||
unlinkSync(join(CLIENT_MODS_DIR, file));
|
||||
}
|
||||
}
|
||||
|
||||
const clientSnapDir = join(snapDir, "client-mods");
|
||||
if (existsSync(clientSnapDir)) {
|
||||
ensureDir(CLIENT_MODS_DIR);
|
||||
const snapClientFiles = readdirSync(clientSnapDir).filter((f) =>
|
||||
f.endsWith(".jar")
|
||||
);
|
||||
for (const file of snapClientFiles) {
|
||||
copyFileSync(join(clientSnapDir, file), join(CLIENT_MODS_DIR, file));
|
||||
}
|
||||
}
|
||||
|
||||
// Restore mod metadata
|
||||
const metaBackup = join(snapDir, "mod-metadata.json");
|
||||
if (existsSync(metaBackup)) {
|
||||
copyFileSync(metaBackup, MOD_METADATA_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
export function listSnapshots(): (SnapshotMeta & { dirName: string })[] {
|
||||
ensureDir(SNAPSHOTS_DIR);
|
||||
|
||||
const dirs = readdirSync(SNAPSHOTS_DIR).filter((d) => {
|
||||
const metaPath = join(SNAPSHOTS_DIR, d, "meta.json");
|
||||
return existsSync(metaPath);
|
||||
});
|
||||
|
||||
return dirs
|
||||
.map((dirName) => {
|
||||
const meta = JSON.parse(
|
||||
readFileSync(join(SNAPSHOTS_DIR, dirName, "meta.json"), "utf8")
|
||||
) as SnapshotMeta;
|
||||
return { ...meta, dirName };
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteSnapshot(dirName: string): void {
|
||||
const snapDir = join(SNAPSHOTS_DIR, dirName);
|
||||
if (!existsSync(snapDir)) {
|
||||
throw new Error(`Snapshot "${dirName}" not found`);
|
||||
}
|
||||
rmSync(snapDir, { recursive: true });
|
||||
}
|
||||
|
||||
function pruneOldSnapshots(): void {
|
||||
const snapshots = listSnapshots();
|
||||
if (snapshots.length > MAX_SNAPSHOTS) {
|
||||
const toDelete = snapshots.slice(MAX_SNAPSHOTS);
|
||||
for (const snap of toDelete) {
|
||||
deleteSnapshot(snap.dirName);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue