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 = { '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) => { 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}`; } else { text = `<${chunk.color}>${text}`; } } else if (chunk.bold) { text = `${text}`; } 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 };