buildmymcpserver/apps/generator/src/lib/build.ts

96 lines
3.5 KiB
TypeScript

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<BuildContext> {
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<void> {
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<void> {
// 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<void> {
await new Promise<void>((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})`));
});
});
}