added docker manager

This commit is contained in:
2025-09-09 17:19:17 +02:00
parent 06dca4061a
commit 15712464af
5 changed files with 376 additions and 1 deletions

View File

@@ -15,6 +15,8 @@
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.3.1",
"bun-types": "^1.2.21",
"mdsvex": "^0.12.3",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
@@ -211,6 +213,10 @@
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/node": ["@types/node@24.3.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g=="],
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
"@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -221,6 +227,8 @@
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
@@ -239,6 +247,8 @@
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="],
@@ -409,6 +419,8 @@
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"unist-util-is": ["unist-util-is@4.1.0", "", {}, "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg=="],
"unist-util-stringify-position": ["unist-util-stringify-position@2.0.3", "", { "dependencies": { "@types/unist": "^2.0.2" } }, "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g=="],

View File

@@ -23,6 +23,8 @@
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.3.1",
"bun-types": "^1.2.21",
"mdsvex": "^0.12.3",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",

View File

@@ -0,0 +1,346 @@
import { spawn, $, write, file } from 'bun';
import { join } from 'path';
declare namespace Bun {
export const YAML: {
parse(text: string): any;
};
}
interface MinecraftEnvironment {
EULA: string;
VERSION?: string;
TYPE?: 'VANILLA' | 'FORGE' | 'FABRIC' | 'PAPER' | 'SPIGOT' | 'BUKKIT' | 'PURPUR' | 'MODDED';
DIFFICULTY?: 'PEACEFUL' | 'EASY' | 'NORMAL' | 'HARD';
MODE?: 'SURVIVAL' | 'CREATIVE' | 'ADVENTURE' | 'SPECTATOR';
MAX_PLAYERS?: string;
MOTD?: string;
LEVEL_NAME?: string;
SEED?: string;
PVP?: string;
ENABLE_COMMAND_BLOCK?: string;
SPAWN_PROTECTION?: string;
MAX_WORLD_SIZE?: string;
VIEW_DISTANCE?: string;
ONLINE_MODE?: string;
ALLOW_NETHER?: string;
ANNOUNCE_PLAYER_ACHIEVEMENTS?: string;
MEMORY?: string;
JVM_OPTS?: string;
[key: string]: string | undefined;
}
interface MinecraftService {
image: 'itzg/minecraft-server';
container_name: string;
tty: true;
stdin_open: true;
ports: string[];
environment: MinecraftEnvironment;
volumes: ['./data:/data'];
restart?: 'unless-stopped' | 'no';
networks?: string[];
}
// simple YAML stringifier for basic objects
function stringifyYAML(obj: any, indent = 0): string {
const spaces = ' '.repeat(indent);
if (obj === null || obj === undefined) {
return 'null';
}
if (typeof obj === 'string') {
// handle strings that need quoting
if (obj.includes(':') || obj.includes('\n') || obj.includes('#')) {
return `"${obj.replace(/"/g, '\\"')}"`;
}
return obj;
}
if (typeof obj === 'number' || typeof obj === 'boolean') {
return String(obj);
}
if (Array.isArray(obj)) {
if (obj.length === 0) return '[]';
return obj.map((item) => `${spaces}- ${stringifyYAML(item, indent + 2)}`).join('\n');
}
if (typeof obj === 'object') {
const entries = Object.entries(obj);
if (entries.length === 0) return '{}';
return entries
.map(([key, value]) => {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return `${spaces}${key}:\n${stringifyYAML(value, indent + 2)}`;
} else {
return `${spaces}${key}: ${stringifyYAML(value, 0)}`;
}
})
.join('\n');
}
return String(obj);
}
// minimal Minecraft docker-compose template
const baseTemplate: { services: { mc: MinecraftService } } = {
services: {
mc: {
image: 'itzg/minecraft-server',
container_name: 'minecraft',
tty: true,
stdin_open: true,
ports: ['25565:25565'],
environment: {
EULA: 'TRUE',
TYPE: 'PAPER',
ENABLE_WHITELIST: 'true',
WHITELIST: 'f396e2b9-cbb1-46a0-bb72-96898a1ca44d',
DIFFICULTY: 'NORMAL',
SPAWN_PROTECTION: '0',
RCON_CMDS_FIRST_CONNECT: `op vaporvee`
},
volumes: ['./data:/data']
}
}
};
const forbiddenKeys = new Set([
'ENABLE_QUERY',
'QUERY_PORT',
'ENABLE_RCON',
'RCON_PORT',
'RCON_PASSWORD'
]);
class ProjectManager {
constructor(private baseDir = join(process.cwd(), 'docker-projects')) {}
projectPath(name: string) {
return join(this.baseDir, name);
}
composePath(name: string) {
return join(this.projectPath(name), 'docker-compose.yml');
}
async createProject(name: string) {
await $`mkdir -p ${this.projectPath(name)}`;
await write(this.composePath(name), stringifyYAML(baseTemplate));
}
async patchProject(name: string, patch: any) {
const path = this.composePath(name);
const f = file(path);
if (!(await f.exists())) throw new Error('Project not found');
const text = await f.text();
const config = Bun.YAML.parse(text);
if (patch.services?.mc) {
for (const [key, val] of Object.entries(patch.services.mc)) {
if (!forbiddenKeys.has(key)) {
if (typeof val === 'object' && !Array.isArray(val)) {
config.services.mc[key] = { ...config.services.mc[key], ...val };
} else {
config.services.mc[key] = val;
}
}
}
}
await write(path, stringifyYAML(config));
return config;
}
async overrideValue(name: string, key: string, value: string) {
const path = this.composePath(name);
const f = file(path);
if (!(await f.exists())) throw new Error('Project not found');
const text = await f.text();
const config = Bun.YAML.parse(text);
if (forbiddenKeys.has(key)) throw new Error(`Key ${key} cannot be overridden`);
if (!config.services.mc.environment) config.services.mc.environment = {};
config.services.mc.environment[key] = value;
await write(path, stringifyYAML(config));
return config;
}
async removeProject(name: string) {
const cwd = this.projectPath(name);
await spawn(['docker', 'compose', 'down', '-v'], { cwd }).exited;
await $`rm -rf ${cwd}`;
}
async runCompose(name: string, args: string[]) {
const cwd = this.projectPath(name);
const result = await spawn(['docker', 'compose', ...args], { cwd });
return result.exited;
}
async attach(name: string) {
const cwd = this.projectPath(name);
const process = spawn(['docker', 'exec', '-i', 'minecraft', 'rcon-cli'], {
cwd,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe'
});
const ansiColorMap: Record<string, string> = {
'30': 'black',
'31': 'red',
'32': 'green',
'33': 'yellow',
'34': 'blue',
'35': 'magenta',
'36': 'cyan',
'37': 'white',
'90': 'brightBlack',
'91': 'brightRed',
'92': 'brightGreen',
'93': 'brightYellow',
'94': 'brightBlue',
'95': 'brightMagenta',
'96': 'brightCyan',
'97': 'brightWhite'
};
function parseAnsiColors(text: string) {
const ansiRegex = /\x1b\[([0-9;]+)m/g;
const chunks: any[] = [];
let lastIndex = 0;
let match;
let currentColor: string | undefined;
let currentBold = false;
while ((match = ansiRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
const textChunk = text.substring(lastIndex, match.index);
chunks.push({
text: textChunk,
color: currentColor,
bold: currentBold
});
}
const codes = match[1].split(';');
for (const code of codes) {
if (code === '0') {
currentColor = undefined;
currentBold = false;
chunks.push({ text: '', reset: true });
} else if (code === '1') {
currentBold = true;
} else if (ansiColorMap[code]) {
currentColor = ansiColorMap[code];
}
}
lastIndex = ansiRegex.lastIndex;
}
if (lastIndex < text.length) {
const textChunk = text.substring(lastIndex);
chunks.push({
text: textChunk,
color: currentColor,
bold: currentBold
});
}
return chunks;
}
const reader = new ReadableStream({
start(controller) {
const decoder = new TextDecoder();
let buffer = '';
const processOutput = async (stream: ReadableStream<Uint8Array>) => {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const colorChunks = parseAnsiColors(line);
let formattedText = '';
for (const chunk of colorChunks) {
if (chunk.reset) {
continue;
}
let text = chunk.text;
if (text) {
if (chunk.color) {
if (chunk.bold) {
text = `<${chunk.color} font-bold>${text}</${chunk.color}>`;
} else {
text = `<${chunk.color}>${text}</${chunk.color}>`;
}
} else if (chunk.bold) {
text = `<span font-bold>${text}</span>`;
}
formattedText += text;
}
}
controller.enqueue({
type: 'output',
formatted: formattedText,
chunks: colorChunks,
rawText: line
});
}
}
} catch (error) {
controller.error(error);
} finally {
reader.releaseLock();
}
};
if (process.stdout) {
processOutput(process.stdout);
}
if (process.stderr) {
processOutput(process.stderr);
}
}
});
const writer = {
async write(text: string) {
if (process.stdin) {
await process.stdin.write(text);
}
},
async close() {
if (process.stdin) {
await process.stdin.end();
}
}
};
return {
reader,
writer,
process
};
}
}
export { ProjectManager };

View File

@@ -0,0 +1,14 @@
// Docker plugin for Craftstation
// This module provides Docker-related functionality
export interface DockerPlugin {
name: string;
version: string;
}
export const dockerPlugin: DockerPlugin = {
name: 'docker',
version: '1.0.0'
};
export default dockerPlugin;

View File

@@ -9,7 +9,8 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"types": ["bun-types", "node"]
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files