Pin Minecraft 26.3 and add safe Orion managed deployment support

This commit is contained in:
Aryan Singh committed 2026-09-29 20:02:31 -05:00
1 parent dff2b5a9c4
commit 008120dbad
40 files changed
+1877 -490

No files matched your search

+5 -2
View File
@@ -39,6 +39,8 @@ const SECTIONS = [
] as const;
export default function ConfigPage() {
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment", 0);
const managed = deployment?.managed ?? true;
const { data, refresh } = useApi<ConfigValues>("/api/config", 0);
const [values, setValues] = useState<ConfigValues>({});
const [saving, setSaving] = useState(false);
@@ -103,10 +105,10 @@ export default function ConfigPage() {
</p>
</div>
<div className="flex gap-2">
<Button onClick={save} busy={saving} disabled={!dirty}>
<Button onClick={save} busy={saving} disabled={managed || !dirty}>
Save
</Button>
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={dirty}>
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={managed || dirty}>
<Rocket size={14} /> Apply (restarts server)
</Button>
</div>
@@ -128,6 +130,7 @@ export default function ConfigPage() {
<div key={f.key}>
<label className="mb-1 block text-xs font-medium text-ink2">{f.label}</label>
<Input
disabled={managed}
value={values[f.key] ?? ""}
onChange={(e) => set(f.key, e.target.value)}
className="w-full font-mono"
+9 -4
View File
@@ -17,7 +17,7 @@ import CodeMirror from "@uiw/react-codemirror";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { oneDark } from "@codemirror/theme-one-dark";
import { api, toast } from "@/lib/api";
import { api, toast, useApi } from "@/lib/api";
import { Card, Button, EmptyState, Input, Modal } from "@/components/ui";
import { fmtBytes, fmtDateTime } from "@/lib/format";
@@ -32,6 +32,8 @@ function langFor(name: string) {
}
export default function FilesPage() {
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment");
const managed = deployment?.managed ?? true;
const [path, setPath] = useState("");
const [entries, setEntries] = useState<Entry[] | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -124,10 +126,10 @@ export default function FilesPage() {
e.target.value = "";
}}
/>
<Button size="sm" onClick={() => uploadRef.current?.click()}>
<Button disabled={managed} size="sm" onClick={() => uploadRef.current?.click()}>
<Upload size={13} /> Upload here
</Button>
<Button size="sm" onClick={() => setMkdirOpen(true)}>
<Button disabled={managed} size="sm" onClick={() => setMkdirOpen(true)}>
<FolderPlus size={13} /> New folder
</Button>
</div>
@@ -187,6 +189,7 @@ export default function FilesPage() {
</a>
)}
<button
disabled={managed}
onClick={() => setConfirmDelete(e)}
className="rounded p-1 text-muted hover:text-crit"
title={e.type === "dir" ? "Delete (empty folders only)" : "Delete"}
@@ -213,7 +216,7 @@ export default function FilesPage() {
{editing.truncated && <span className="text-[10px] text-warn">(truncated — file over 2 MB)</span>}
</div>
<div className="flex gap-2">
<Button size="sm" variant="primary" onClick={save} busy={saving} disabled={!editing.dirty || editing.truncated}>
<Button size="sm" variant="primary" onClick={save} busy={saving} disabled={managed || !editing.dirty || editing.truncated}>
<Save size={13} /> Save
</Button>
<Button size="sm" onClick={() => setEditing(null)}>
@@ -223,6 +226,8 @@ export default function FilesPage() {
</header>
<div className="scroll-slim min-h-0 flex-1 overflow-auto">
<CodeMirror
editable={!managed}
readOnly={managed}
value={editing.content}
onChange={(v) => setEditing((e) => (e ? { ...e, content: v, dirty: true } : e))}
extensions={langFor(editing.path)}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Shell } from "@/components/shell";
import { ManagedNotice } from "@/components/managed-notice";
export default function PanelLayout({ children }: { children: React.ReactNode }) {
return <Shell>{children}</Shell>;
return <Shell><ManagedNotice />{children}</Shell>;
}
+14 -10
View File
@@ -60,15 +60,17 @@ const FEATURED = [
"Pre-generates the world around spawn so nobody lags exploring new chunks. Run `chunky radius 2000` then `chunky start` in Console.",
},
{
slug: "proxy-protocol-support",
slug: "fabricproxy-lite",
emoji: "🛡",
title: "Proxy Protocol Support",
title: "FabricProxy-Lite",
description:
"Using TCPShield (or another proxy)? This restores players' real IPs so the Security page and IP bans work. Config is auto-enforced — see docs/tcpshield.md.",
"Velocity modern forwarding requires coordinated proxy and backend configuration. Follow docs/tcpshield.md before enabling.",
},
];
export default function ModsPage() {
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment");
const managed = deployment?.managed ?? true;
const { data, refresh } = useApi<ModsData>("/api/mods", 0);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<SearchHit[] | null>(null);
@@ -127,10 +129,10 @@ export default function ModsPage() {
<div>
<h1 className="text-xl font-bold">Mods</h1>
<p className="mt-0.5 text-xs text-ink2">
Managed mods auto-update to the newest build for Minecraft {data?.mcVersion ?? "…"} on every apply
{managed ? "Mod versions are locked by the reviewed deployment." : "Pinned Modrinth version IDs remain fixed when the server restarts."}
</p>
</div>
<Button variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}>
<Button disabled={managed} variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}>
<RefreshCw size={14} /> {needsApply ? "Apply changes (restarts server)" : "Update all & restart"}
</Button>
</div>
@@ -150,7 +152,7 @@ export default function ModsPage() {
<Button
size="sm"
variant={already ? "ghost" : "primary"}
disabled={already}
disabled={managed || already}
busy={busy === `add-${f.slug}`}
onClick={() => void manage(`add-${f.slug}`, { op: "add", slug: f.slug }, `${f.title} added — hit Apply to install`)}
>
@@ -170,7 +172,7 @@ export default function ModsPage() {
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void search()}
placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.2"}…`}
placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.3"}…`}
className="w-full"
/>
<Button onClick={() => void search()} busy={searching}>
@@ -208,7 +210,7 @@ export default function ModsPage() {
<Button
size="sm"
variant={already ? "ghost" : "primary"}
disabled={already}
disabled={managed || already}
busy={busy === `add-${h.slug}`}
onClick={() => void manage(`add-${h.slug}`, { op: "add", slug: h.slug }, `${h.title} added`)}
>
@@ -223,8 +225,7 @@ export default function ModsPage() {
{/* managed list */}
<Card title={`Managed mods (${data?.managed.length ?? 0})`}>
<p className="mb-3 text-[11px] leading-relaxed text-muted">
These Modrinth projects are pinned in <code>.env</code> (MODRINTH_PROJECTS). The server resolves the
newest compatible build on every start, so updating Minecraft also updates these automatically.
Entries with version IDs stay fixed. Review Minecraft, loader and mod compatibility together before updating.
</p>
<ul className="space-y-1.5">
{(data?.managed ?? []).map((slug) => (
@@ -240,6 +241,7 @@ export default function ModsPage() {
<Button
size="sm"
variant="danger"
disabled={managed}
busy={busy === `remove-${slug}`}
onClick={() => void manage(`remove-${slug}`, { op: "remove", slug }, `${slug} removed`)}
>
@@ -285,6 +287,7 @@ export default function ModsPage() {
<div className="flex justify-end gap-1.5">
<Button
size="sm"
disabled={managed}
busy={busy === `toggle-${m.file}`}
onClick={() =>
void manage(
@@ -300,6 +303,7 @@ export default function ModsPage() {
<Button
size="sm"
variant="danger"
disabled={managed}
busy={busy === `delete-${m.file}`}
onClick={() => void manage(`delete-${m.file}`, { op: "delete", file: m.file }, `${m.file} deleted`)}
>
+1 -1
View File
@@ -26,7 +26,7 @@ export async function DELETE(req: NextRequest) {
return handle(async () => {
const name = req.nextUrl.searchParams.get("name");
if (!name) return fail("Missing backup name");
deleteBackup(name);
await deleteBackup(name);
return ok({ deleted: name });
});
}
+6 -3
View File
@@ -1,3 +1,5 @@
import { assertDeploymentEditable } from "@/server/managed";
import { withServerOperation } from "@/server/operation";
import { NextRequest } from "next/server";
import { applyMcConfig, applyBackupConfig } from "@/server/compose";
import { safetyBackup } from "@/server/backups";
@@ -5,9 +7,10 @@ import { notify } from "@/server/notify";
import { ok, fail, readJson, handle } from "@/server/api";
export async function POST(req: NextRequest) {
return handle(async () => {
return handle(() => withServerOperation(async () => {
assertDeploymentEditable();
const { service, backupFirst } = await readJson<{ service?: string; backupFirst?: boolean }>(req);
if (backupFirst) await safetyBackup("config apply");
if (service !== "backup" || backupFirst) await safetyBackup("config apply");
const res = service === "backup" ? await applyBackupConfig() : await applyMcConfig();
if (!res.ok) {
notify("error", "Config apply failed", res.output.slice(-800));
@@ -15,5 +18,5 @@ export async function POST(req: NextRequest) {
}
notify("success", "Configuration applied", "Container recreated with the new settings.", { discord: false });
return ok({ output: res.output.slice(-1000) });
});
}));
}
@@ -0,0 +1,5 @@
import { env } from "@/server/env";
import { ok } from "@/server/api";
export const dynamic = "force-dynamic";
export function GET() { return ok({ managed: env.managed }); }
+7 -2
View File
@@ -1,10 +1,15 @@
import { safetyBackup } from "@/server/backups";
import { assertDeploymentEditable } from "@/server/managed";
import { withServerOperation } from "@/server/operation";
import { applyMcConfig } from "@/server/compose";
import { notify } from "@/server/notify";
import { ok, fail, handle } from "@/server/api";
/** Recreate the mc container so the itzg image re-resolves MODRINTH_PROJECTS. */
export async function POST() {
return handle(async () => {
return handle(() => withServerOperation(async () => {
assertDeploymentEditable();
await safetyBackup("mod apply");
const res = await applyMcConfig();
if (!res.ok) {
notify("error", "Mod apply failed", res.output.slice(-800));
@@ -12,5 +17,5 @@ export async function POST() {
}
notify("success", "Mod changes applied", "Server container recreated; mods re-resolved.", { discord: false });
return ok({ output: res.output.slice(-1000) });
});
}));
}
+8 -2
View File
@@ -1,3 +1,5 @@
import { assertDeploymentEditable } from "@/server/managed";
import { withServerOperation } from "@/server/operation";
import { NextRequest } from "next/server";
import fs from "node:fs";
import path from "node:path";
@@ -18,7 +20,8 @@ export async function GET() {
/** Danger zone: archive the current world and generate a fresh one. */
export async function POST(req: NextRequest) {
return handle(async () => {
return handle(() => withServerOperation(async () => {
assertDeploymentEditable();
const { op, confirm } = await readJson<{ op?: string; confirm?: string }>(req);
if (op !== "reset") return fail("Unknown operation");
if (confirm !== "reset the world") return fail('Type "reset the world" to confirm');
@@ -27,6 +30,8 @@ export async function POST(req: NextRequest) {
if (!fs.existsSync(worldDir)) return fail("No world folder found");
await safetyBackup("world reset");
const backup = await inspectContainer(env.backupContainer);
if (backup.running) await powerContainer(env.backupContainer, "stop");
const state = await inspectContainer(env.mcContainer);
if (state.running) await powerContainer(env.mcContainer, "stop");
@@ -34,8 +39,9 @@ export async function POST(req: NextRequest) {
fs.renameSync(worldDir, path.join(env.mcDataDir, archived));
await powerContainer(env.mcContainer, "start");
if (backup.running) await powerContainer(env.backupContainer, "start");
logActivity("world_reset", null, `old world kept as ${archived}`);
notify("warn", "World reset", `A fresh world is generating. Old world kept as ${archived}.`);
return ok({ archived });
});
}));
}
@@ -0,0 +1,11 @@
"use client";
import { useApi } from "@/lib/api";
export function ManagedNotice() {
const { data } = useApi<{ managed: boolean }>("/api/deployment", 0);
if (!data?.managed) return null;
return <div className="mb-4 rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm">
Managed deployment: versions, mods and files are read-only here. Update the reviewed
deployment through Komodo. Console, player controls and backups remain available.
</div>;
}
+2
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { DockerError } from "./docker";
import { RconError } from "./rcon";
import { ManagedDeploymentError } from "./managed";
/**
* Small helpers for route handlers. Authentication is enforced globally in
@@ -21,6 +22,7 @@ export function handle(fn: () => Promise<NextResponse> | NextResponse) {
try {
return await fn();
} catch (err) {
if (err instanceof ManagedDeploymentError) return fail(err.message, 409);
if (err instanceof DockerError && err.kind === "unavailable") {
return fail("Docker is unreachable from the panel", 503);
}
+52 -29
View File
@@ -2,9 +2,12 @@ import fs from "node:fs";
import path from "node:path";
import { execFile } from "node:child_process";
import { execInContainer, powerContainer, inspectContainer } from "./docker";
import { db, logActivity } from "./db";
import { logActivity } from "./db";
import { notify } from "./notify";
import { env } from "./env";
import { withServerOperation } from "./operation";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Backup management on top of the itzg/mc-backup sidecar (scheduled tars)
@@ -38,43 +41,63 @@ export function listBackups(): BackupInfo[] {
.sort((a, b) => b.mtime - a.mtime);
}
export async function triggerBackup(): Promise<string> {
export function triggerBackup(): Promise<string> {
return withServerOperation(triggerBackupUnlocked);
}
async function triggerBackupUnlocked(): Promise<string> {
const before = new Set(listBackups().map((b) => b.name));
const res = await execInContainer(env.backupContainer, ["backup", "now"], 30 * 60 * 1000);
if (res.exitCode !== 0) {
notify("error", "Backup failed", res.output.slice(-800));
throw new Error(`Backup failed: ${res.output.slice(-300)}`);
}
const created = listBackups().find((b) => !before.has(b.name));
logActivity("backup", null, created?.name ?? "manual backup");
notify("success", "Backup completed", created ? created.name : "", { discord: false });
return created?.name ?? "backup completed";
// Some sidecar versions acknowledge the request before writing the archive.
// Wait for a new complete gzip/tar, never treat the request as a finished backup.
const deadline = Date.now() + 30 * 60 * 1000;
while (Date.now() < deadline) {
for (const created of listBackups().filter((b) => !before.has(b.name) && b.size > 0)) {
try {
await execFileAsync("python3", [env.restoreHelper, "verify", "--archive", backupPath(created.name)],
{ timeout: 10 * 60 * 1000, maxBuffer: 1024 * 1024 });
logActivity("backup", null, created.name);
notify("success", "Backup completed", created.name, { discord: false });
return created.name;
} catch { /* Still being written, or invalid: do not report success. */ }
}
await new Promise((resolve) => setTimeout(resolve, 3000));
}
throw new Error("No new complete Minecraft backup appeared within 30 minutes");
}
export async function restoreBackup(name: string): Promise<void> {
const file = backupPath(name);
if (!fs.existsSync(file)) throw new Error("Backup file not found");
const state = await inspectContainer(env.mcContainer);
if (state.running) await powerContainer(env.mcContainer, "stop");
await new Promise<void>((resolve, reject) => {
execFile(
"tar",
["-xpzf", file, "-C", env.mcDataDir],
{ timeout: 30 * 60 * 1000 },
(err, _stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve())
);
export function restoreBackup(name: string): Promise<void> {
return withServerOperation(async () => {
const file = backupPath(name);
if (!fs.existsSync(file)) throw new Error("Backup file not found");
await execFileAsync("python3", [env.restoreHelper, "verify", "--archive", file],
{ timeout: 30 * 60 * 1000 });
const [mc, backup] = await Promise.all([
inspectContainer(env.mcContainer), inspectContainer(env.backupContainer),
]);
if (backup.running) await powerContainer(env.backupContainer, "stop");
if (mc.running) await powerContainer(env.mcContainer, "stop");
// Failure deliberately leaves the writers stopped for inspection.
const args = [env.restoreHelper, "restore", "--archive", file,
"--data", env.mcDataDir, "--work", env.restoreWorkDir];
if (env.managed) args.push("--lock-file", path.join(env.projectDir, "release-lock.json"));
const result = await execFileAsync("python3", args, { timeout: 30 * 60 * 1000 });
if (mc.running) await powerContainer(env.mcContainer, "start");
if (backup.running) await powerContainer(env.backupContainer, "start");
logActivity("restore", null, `${name}: ${result.stdout.trim()}`);
notify("success", "Backup restored", "Previous data retained in the restore workspace.");
});
await powerContainer(env.mcContainer, "start");
logActivity("restore", null, name);
notify("success", "Backup restored", `World restored from ${name}. Server starting.`);
}
export function deleteBackup(name: string): void {
fs.unlinkSync(backupPath(name));
logActivity("backup_delete", null, name);
export function deleteBackup(name: string): Promise<void> {
return withServerOperation(async () => {
fs.unlinkSync(backupPath(name));
logActivity("backup_delete", null, name);
});
}
export function backupsDirSize(): number {
@@ -92,7 +115,7 @@ export async function safetyBackup(reason: string): Promise<void> {
try {
await triggerBackup();
} catch (err) {
db();
notify("warn", "Safety backup failed", `Proceeding with ${reason} anyway: ${(err as Error).message}`);
notify("error", "Safety backup failed", `${reason} cancelled: ${(err as Error).message}`);
throw err;
}
}
+8 -4
View File
@@ -1,5 +1,8 @@
import { execFile } from "node:child_process";
import { env } from "./env";
import { assertDeploymentEditable } from "./managed";
import { withServerOperation } from "./operation";
import path from "node:path";
/**
* Apply configuration changes by re-running docker compose for a service.
@@ -9,21 +12,22 @@ import { env } from "./env";
*/
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
return new Promise((resolve) => {
assertDeploymentEditable();
return withServerOperation(() => new Promise((resolve) => {
execFile(
"docker",
["compose", "--project-directory", env.projectDir, ...args],
["compose", "--project-directory", env.projectDir, "-f", path.join(env.projectDir, env.composeFile), ...args],
{ timeout: 10 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 },
(err, stdout, stderr) => {
resolve({ ok: !err, output: [stdout, stderr, err?.message].filter(Boolean).join("\n") });
}
);
});
}));
}
/** Recreate the Minecraft container so new .env values take effect. */
export function applyMcConfig(): Promise<{ ok: boolean; output: string }> {
return runCompose(["up", "-d", "--no-deps", env.mcContainer]);
return runCompose(["up", "-d", "--no-deps", env.mcService]);
}
/** Recreate the backup sidecar (after backup schedule changes). */
+7 -1
View File
@@ -1,5 +1,6 @@
import http from "node:http";
import { env } from "./env";
import { withServerOperation } from "./operation";
/**
* Minimal Docker Engine API client over the unix socket (no dependencies).
@@ -130,6 +131,10 @@ export async function containerEnv(name: string): Promise<Record<string, string>
export type PowerAction = "start" | "stop" | "restart" | "kill";
export async function powerContainer(name: string, action: PowerAction): Promise<void> {
return withServerOperation(() => powerContainerUnlocked(name, action));
}
async function powerContainerUnlocked(name: string, action: PowerAction): Promise<void> {
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
method: "POST",
@@ -292,5 +297,6 @@ export async function execInContainer(
});
const info = await json<{ ExitCode: number | null }>(`/exec/${Id}/json`);
return { exitCode: info.ExitCode ?? 0, output };
if (info.ExitCode === null) throw new DockerError("Container command is still running; completion is unverified");
return { exitCode: info.ExitCode, output };
}
+5
View File
@@ -14,6 +14,11 @@ function str(key: string, fallback: string): string {
export const env = {
dev,
managed: process.env.PANEL_MANAGED === "true",
mcService: str("MC_SERVICE", "mc"),
composeFile: str("COMPOSE_FILE_NAME", "docker-compose.yml"),
restoreWorkDir: path.resolve(str("RESTORE_WORK_DIR", dev ? "./dev-data/restore" : "/restore-work")),
restoreHelper: str("RESTORE_HELPER", "/app/restore_data.py"),
sessionSecret: str("PANEL_SESSION_SECRET", dev ? "craftdeck-dev-secret-do-not-use" : ""),
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
+6 -1
View File
@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { env } from "./env";
import { assertDeploymentEditable } from "./managed";
/**
* Read/write the project .env file while preserving comments and ordering.
@@ -18,16 +19,20 @@ export function readEnvFile(): Record<string, string> {
try {
text = fs.readFileSync(envFilePath(), "utf8");
} catch {
return out;
return env.managed ? Object.fromEntries(EDITABLE_ENV_KEYS.map((key) => [key, process.env[key] ?? ""])) : out;
}
for (const line of text.split("\n")) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
if (m) out[m[1]] = m[2].trim();
}
if (env.managed) {
for (const key of EDITABLE_ENV_KEYS) out[key] = process.env[key] ?? out[key] ?? "";
}
return out;
}
export function writeEnvFile(updates: Record<string, string>): void {
assertDeploymentEditable();
const file = envFilePath();
let lines: string[];
try {
+5
View File
@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { env } from "./env";
import { assertDeploymentEditable } from "./managed";
/**
* Sandboxed file access for the file browser / editors. Every path is
@@ -80,18 +81,21 @@ export function readTextFile(rel: string): { content: string; truncated: boolean
}
export function writeTextFile(rel: string, content: string): void {
assertDeploymentEditable();
const abs = resolveSafe(rel);
fs.writeFileSync(abs, content);
fixOwnership(abs);
}
export function writeBinaryFile(rel: string, data: Buffer): void {
assertDeploymentEditable();
const abs = resolveSafe(rel);
fs.writeFileSync(abs, data);
fixOwnership(abs);
}
export function deletePath(rel: string): void {
assertDeploymentEditable();
const abs = resolveSafe(rel);
if (abs === env.mcDataDir) throw new Error("Refusing to delete the data root");
const st = fs.statSync(abs);
@@ -103,6 +107,7 @@ export function deletePath(rel: string): void {
}
export function makeDir(rel: string): void {
assertDeploymentEditable();
const abs = resolveSafe(rel);
fs.mkdirSync(abs, { recursive: true });
fixOwnership(abs);
+11
View File
@@ -0,0 +1,11 @@
import { env } from "./env";
export class ManagedDeploymentError extends Error {
constructor() {
super("This deployment is managed by Komodo. Change its reviewed configuration in homelab-infra.");
}
}
export function assertDeploymentEditable(): void {
if (env.managed) throw new ManagedDeploymentError();
}
+4 -1
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { execFile } from "node:child_process";
import { readEnvFile, writeEnvFile } from "./envfile";
import { env } from "./env";
import { assertDeploymentEditable } from "./managed";
/**
* Mod management: the MODRINTH_PROJECTS list in .env is the source of truth
@@ -24,7 +25,7 @@ export type ModSearchResult = {
};
export function currentMcVersion(): string {
return readEnvFile()["MC_VERSION"] || "26.2";
return readEnvFile()["MC_VERSION"] || "26.3";
}
export async function searchMods(query: string): Promise<ModSearchResult[]> {
@@ -143,11 +144,13 @@ function safeModFile(file: string): string {
}
export function toggleMod(file: string): void {
assertDeploymentEditable();
const full = safeModFile(file);
const target = full.endsWith(".disabled") ? full.slice(0, -".disabled".length) : `${full}.disabled`;
fs.renameSync(full, target);
}
export function deleteModFile(file: string): void {
assertDeploymentEditable();
fs.unlinkSync(safeModFile(file));
}
+16
View File
@@ -0,0 +1,16 @@
import { AsyncLocalStorage } from "node:async_hooks";
// One reentrant queue for power, backup, restore and Compose operations.
const state = globalThis as unknown as {
__craftdeckOperation?: { tail: Promise<unknown>; context: AsyncLocalStorage<boolean> };
};
const queue = state.__craftdeckOperation ??= {
tail: Promise.resolve(), context: new AsyncLocalStorage<boolean>(),
};
export function withServerOperation<T>(operation: () => Promise<T>): Promise<T> {
if (queue.context.getStore()) return operation();
const result = queue.tail.then(() => queue.context.run(true, operation));
queue.tail = result.catch(() => undefined);
return result;
}
+3 -4
View File
@@ -1,10 +1,11 @@
import cron, { type ScheduledTask } from "node-cron";
import { db } from "./db";
import { notify } from "./notify";
import { powerContainer, execInContainer } from "./docker";
import { powerContainer } from "./docker";
import { rcon } from "./rcon";
import { say } from "./mc";
import { env } from "./env";
import { triggerBackup } from "./backups";
/**
* Scheduled tasks (cron-based): restarts, backups, announcements, arbitrary
@@ -47,9 +48,7 @@ export async function runScheduleAction(action: Schedule["action"], payload: str
return "Restarted";
}
case "backup": {
const res = await execInContainer(env.backupContainer, ["backup", "now"]);
if (res.exitCode !== 0) throw new Error(res.output.slice(-500) || "backup failed");
return "Backup completed";
return await triggerBackup();
}
case "command": {
if (!payload) throw new Error("No command configured");