diff --git a/app/bun.lock b/app/bun.lock index 4de53ce..da6a298 100644 --- a/app/bun.lock +++ b/app/bun.lock @@ -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=="], diff --git a/app/package.json b/app/package.json index 69d4583..5ba9f7a 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/routes/[...slugs]/lib/projectmanager.ts b/app/src/routes/[...slugs]/lib/projectmanager.ts new file mode 100644 index 0000000..5f4abaf --- /dev/null +++ b/app/src/routes/[...slugs]/lib/projectmanager.ts @@ -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 = { + '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 }; diff --git a/app/src/routes/[...slugs]/plugins/docker/index.ts b/app/src/routes/[...slugs]/plugins/docker/index.ts new file mode 100644 index 0000000..7a71cfe --- /dev/null +++ b/app/src/routes/[...slugs]/plugins/docker/index.ts @@ -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; diff --git a/app/tsconfig.json b/app/tsconfig.json index a5567ee..e31c08e 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -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