96 lines
3.5 KiB
TypeScript
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})`));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|