Pass 3 first slice: mod update action, error boundaries, a11y, palette

- New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot →
  download latest → swap old jar → restart + verify (if server-side) →
  rebuild modpack, with automatic rollback on any failure.
- ModManager: "Update" button next to each mod with an available update,
  plus "Update all (N)" in the installed list header. Reuses the existing
  install timeline UI (same event shape). SSE reader extracted as
  consumeSSE helper.
- Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin
  subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail
  fallback with inline styles, no app shell dependency).
- A11y sweep: aria-pressed + aria-label on LogViewer level chips and
  ModManager side filter; aria-label on admin TabsList; skip-to-content
  link in Navbar targeting <main id="main"> on public + admin pages;
  role/aria-live on install/update timeline; global Esc in ModManager
  clears open confirm prompts and exits search/review wizard steps.
- Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers.
  Navigate admin tabs, toggle theme, start/stop/restart server, create
  backup, re-check mod updates, jump to any cached mod/player/snapshot/
  backup. Auth-aware — public users see only Home / Log in / Theme.
- AdminTabs listens to hashchange so palette navigation updates the
  active tab live.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hurkicorgi 2026-04-13 05:30:23 -06:00
parent f9ae1afac1
commit a011423017
16 changed files with 1124 additions and 22 deletions

View file

@ -0,0 +1,400 @@
import { NextRequest } from "next/server";
import { existsSync, readFileSync, unlinkSync } from "fs";
import { join } from "path";
import { exec } from "child_process";
import { auth } from "@/lib/auth";
import { downloadMod, getLatestVersion } from "@/lib/modrinth";
import type { ModSide } from "@/lib/modrinth";
import { createSnapshot, restoreSnapshot, listSnapshots } from "@/lib/snapshots";
import {
waitForServerAdaptive,
rebuildModpack,
addModMetadata,
removeModMetadata,
invalidateModsCache,
} from "@/lib/mods";
import { invalidate as invalidateCache } from "@/lib/cache";
import { MODS_DIR, CLIENT_MODS_DIR, MOD_METADATA_FILE } from "@/lib/constants";
export const dynamic = "force-dynamic";
type ModMetadataEntry = { projectId: string; side: ModSide };
function restartServer(): Promise<void> {
return new Promise((resolve, reject) => {
exec("sudo systemctl restart minecraft.service", (err) => {
if (err) reject(err);
else resolve();
});
});
}
function locateMod(filename: string): { dir: string; side: ModSide } | null {
if (existsSync(join(MODS_DIR, filename))) {
return { dir: MODS_DIR, side: "both" };
}
if (existsSync(join(CLIENT_MODS_DIR, filename))) {
return { dir: CLIENT_MODS_DIR, side: "client" };
}
return null;
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
const { filenames } = (await req.json()) as { filenames: string[] };
if (!Array.isArray(filenames) || filenames.length === 0) {
return new Response(JSON.stringify({ error: "No mods to update" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Validate filenames
for (const f of filenames) {
if (!f || !f.endsWith(".jar") || f.includes("/") || f.includes("\\")) {
return new Response(JSON.stringify({ error: `Invalid filename: ${f}` }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
}
let metadata: Record<string, ModMetadataEntry>;
try {
metadata = JSON.parse(readFileSync(MOD_METADATA_FILE, "utf8"));
} catch {
return new Response(
JSON.stringify({ error: "Mod metadata unavailable" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
const stream = new TransformStream();
const writer = stream.writable.getWriter();
const encoder = new TextEncoder();
async function send(event: string, data: object) {
try {
await writer.write(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
);
} catch {}
}
(async () => {
const snapName = `before-update-${Date.now()}`;
try {
// 1. Resolve targets
await send("step", {
id: "resolve",
status: "active",
message: `Resolving latest versions for ${filenames.length} mod(s)...`,
});
type Target = {
oldFilename: string;
dir: string;
side: ModSide;
projectId: string;
newFilename: string;
url: string;
};
const targets: Target[] = [];
for (const f of filenames) {
const entry = metadata[f];
if (!entry?.projectId) {
await send("step", {
id: "resolve",
status: "error",
message: `${f}: unknown origin (not installed via dashboard)`,
});
await send("done", {
success: false,
message: `Cannot update ${f} — no Modrinth project linked.`,
});
return;
}
const loc = locateMod(f);
if (!loc) {
await send("step", {
id: "resolve",
status: "error",
message: `${f}: file missing on disk`,
});
await send("done", { success: false, message: `Mod file missing: ${f}` });
return;
}
const side: ModSide = entry.side || loc.side;
const latest = await getLatestVersion(entry.projectId);
if (!latest || !latest.files?.[0]) {
await send("step", {
id: "resolve",
status: "error",
message: `${f}: no compatible Modrinth version`,
});
await send("done", {
success: false,
message: `No update available for ${f}.`,
});
return;
}
const file = latest.files[0];
if (file.filename === f) {
// Already up to date
continue;
}
targets.push({
oldFilename: f,
dir: side === "client" ? CLIENT_MODS_DIR : MODS_DIR,
side,
projectId: entry.projectId,
newFilename: file.filename,
url: file.url,
});
}
if (targets.length === 0) {
await send("step", {
id: "resolve",
status: "done",
message: "All selected mods already current",
});
await send("done", {
success: true,
message: "No updates needed — everything is up to date.",
installed: [],
});
return;
}
await send("step", {
id: "resolve",
status: "done",
message: `${targets.length} update(s) to apply`,
});
// 2. Snapshot
await send("step", {
id: "snapshot",
status: "active",
message: "Creating safety snapshot...",
});
try {
createSnapshot(snapName);
} catch (e) {
await send("step", {
id: "snapshot",
status: "error",
message: (e as Error).message,
});
await send("done", {
success: false,
message: `Snapshot failed: ${(e as Error).message}`,
});
return;
}
await send("step", { id: "snapshot", status: "done", message: "Snapshot created" });
// 3. Download + swap
await send("step", {
id: "download",
status: "active",
message: `Downloading ${targets.length} mod(s)...`,
});
try {
for (const t of targets) {
await downloadMod(t.url, t.newFilename, t.side);
const oldPath = join(t.dir, t.oldFilename);
if (existsSync(oldPath) && t.oldFilename !== t.newFilename) {
unlinkSync(oldPath);
}
removeModMetadata(t.oldFilename);
addModMetadata(t.newFilename, { projectId: t.projectId, side: t.side });
}
invalidateModsCache();
invalidateCache("mods:updates");
} catch (e) {
await send("step", {
id: "download",
status: "error",
message: (e as Error).message,
});
await send("step", {
id: "rollback",
status: "active",
message: "Rolling back...",
});
try {
const snaps = listSnapshots();
if (snaps.length > 0) restoreSnapshot(snaps[0].dirName);
} catch {}
await send("step", {
id: "rollback",
status: "done",
message: "Rolled back to snapshot",
});
await send("done", {
success: false,
message: `Download failed: ${(e as Error).message}`,
rolledBack: true,
});
return;
}
await send("step", {
id: "download",
status: "done",
message: "All mods downloaded",
});
// 4. Restart if any server-side updates
const hasServerMods = targets.some((t) => t.side !== "client");
if (!hasServerMods) {
await send("step", {
id: "modpack",
status: "active",
message: "Rebuilding modpack...",
});
try { rebuildModpack(); } catch {}
await send("step", {
id: "modpack",
status: "done",
message: "Modpack updated",
});
await send("done", {
success: true,
message: `Updated ${targets.length} client-only mod(s).`,
installed: targets.map((t) => t.newFilename),
});
return;
}
await send("step", {
id: "restart",
status: "active",
message: "Restarting server...",
});
try {
await restartServer();
} catch (e) {
await send("step", {
id: "restart",
status: "error",
message: (e as Error).message,
});
await send("step", { id: "rollback", status: "active", message: "Rolling back..." });
try {
const snaps = listSnapshots();
if (snaps.length > 0) restoreSnapshot(snaps[0].dirName);
} catch {}
await send("step", {
id: "rollback",
status: "done",
message: "Rolled back to snapshot",
});
await send("done", {
success: false,
message: `Restart failed: ${(e as Error).message}`,
rolledBack: true,
});
return;
}
await send("step", {
id: "restart",
status: "done",
message: "Restart command sent",
});
// 5. Verify
await send("step", {
id: "health",
status: "active",
message: "Waiting for server...",
});
const online = await waitForServerAdaptive(async (p) => {
await send("step", { id: "health", status: "active", message: p.message });
});
if (online) {
await send("step", {
id: "health",
status: "done",
message: "Server is online",
});
await send("step", {
id: "modpack",
status: "active",
message: "Rebuilding modpack...",
});
try { rebuildModpack(); } catch {}
await send("step", {
id: "modpack",
status: "done",
message: "Modpack updated",
});
await send("done", {
success: true,
message: `Updated ${targets.length} mod(s). Server is online.`,
installed: targets.map((t) => t.newFilename),
});
} else {
await send("step", {
id: "health",
status: "error",
message: "Server failed to start",
});
await send("step", {
id: "rollback",
status: "active",
message: "Rolling back to snapshot...",
});
try {
const snaps = listSnapshots();
if (snaps.length > 0) {
restoreSnapshot(snaps[0].dirName);
await restartServer();
await waitForServerAdaptive(async (p) => {
await send("step", {
id: "rollback",
status: "active",
message: `Rollback: ${p.message}`,
});
});
try { rebuildModpack(); } catch {}
}
} catch {}
await send("step", {
id: "rollback",
status: "done",
message: "Rolled back and server restarted",
});
await send("done", {
success: false,
message: `Server failed after updating ${targets.length} mod(s). Rolled back.`,
rolledBack: true,
});
}
} catch (e) {
await send("done", { success: false, message: (e as Error).message });
} finally {
try { writer.close(); } catch {}
}
})();
return new Response(stream.readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}