import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; import type { GeneratorSpec } from '@bmm/types'; const here = path.dirname(fileURLToPath(import.meta.url)); // apps/generator/src/lib -> repo root const REPO_ROOT = path.resolve(here, '..', '..', '..', '..'); const RUNNER_TEMPLATE_DIR = path.resolve(REPO_ROOT, 'apps', 'runner-template'); const BUILD_CONTEXT_ROOT = path.resolve(REPO_ROOT, 'build-context'); export interface BuildContext { contextDir: string; imageTag: string; } export async function prepareBuildContext( serverId: string, version: number, slug: string, generatedCode: string, spec: GeneratorSpec, ): Promise { const contextDir = path.join(BUILD_CONTEXT_ROOT, `${slug}-v${version}-${serverId.slice(0, 8)}`); await fs.rm(contextDir, { recursive: true, force: true }); await fs.mkdir(contextDir, { recursive: true }); // Copy runner-template files into context await copyDir(RUNNER_TEMPLATE_DIR, contextDir); // Overwrite the server.ts with generated code await fs.mkdir(path.join(contextDir, 'src'), { recursive: true }); await fs.writeFile(path.join(contextDir, 'src', 'server.ts'), generatedCode, 'utf8'); // Merge generated deps into package.json const pkgPath = path.join(contextDir, 'package.json'); const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')); pkg.dependencies = { ...pkg.dependencies, ...spec.dependencies }; await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); const imageTag = `bmm-mcp-${slug}:v${version}`; return { contextDir, imageTag }; } async function copyDir(src: string, dest: string): Promise { const entries = await fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { if (entry.name === 'node_modules' || entry.name === 'dist') continue; const s = path.join(src, entry.name); const d = path.join(dest, entry.name); if (entry.isDirectory()) { await fs.mkdir(d, { recursive: true }); await copyDir(s, d); } else { await fs.copyFile(s, d); } } } export async function staticCheck(contextDir: string): Promise { // Skip in CI without a tsc binary; we trust the renderer + spec validation in dev. // A future iteration will run `pnpm --filter ./build-context/... typecheck` here. const code = await fs.readFile(path.join(contextDir, 'src', 'server.ts'), 'utf8'); if (code.includes('eval(') || code.includes('new Function(')) { throw new Error('static_check_banned_token'); } if (!code.includes("StreamableHTTPServerTransport") || !code.includes("McpServer")) { throw new Error('static_check_missing_mcp_primitives'); } } export async function dockerBuild(contextDir: string, imageTag: string, onLog: (msg: string) => void): Promise { await new Promise((resolve, reject) => { const child = spawn('docker', ['build', '-t', imageTag, '.'], { cwd: contextDir, stdio: ['ignore', 'pipe', 'pipe'], }); child.stdout.on('data', (d) => { for (const line of d.toString().split(/\r?\n/)) { if (line.trim()) onLog(line.trim()); } }); child.stderr.on('data', (d) => { for (const line of d.toString().split(/\r?\n/)) { if (line.trim()) onLog(line.trim()); } }); child.on('error', (e) => reject(e)); child.on('close', (code) => { if (code === 0) resolve(); else reject(new Error(`docker_build_failed (exit ${code})`)); }); }); }