#!/usr/bin/env node /** * Backend Builder Agent — SF-04 * * 根据 PRD (SF-01) + Architecture (SF-02) 自动生成后端项目。 * * 技术栈:Node.js + Fastify + TypeScript + SQLite + JWT + bcrypt * * 生成内容: * - package.json / tsconfig.json / .env.example / README.md * - src/index.ts — Fastify 服务入口 * - src/db/schema.ts — SQLite 建表语句 * - src/db/client.ts — better-sqlite3 封装 * - src/routes/*.ts — CRUD 路由 * - src/routes/auth.ts — 认证路由(register/login/me) * - src/middleware/auth.ts — JWT 权限中间件 * - src/services/*.ts — 业务逻辑层 * - src/types/*.ts — TypeScript 类型定义 * - src/__tests__/*.test.ts — node:test 测试 * * Usage: * node scripts/backend-builder-agent.mjs --prd --arch [options] * node scripts/backend-builder-agent.mjs --input-text "<需求>" [options] * * Options: * --prd PRD JSON(SF-01 输出) * --arch Architecture JSON(SF-02 输出) * --input-text 直接传入需求(自动调 SF-01 → SF-02 → SF-04) * --output 输出目录(default: backend/) * --verbose 详细输出 * --help 显示帮助 * * @module backend-builder-agent */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { createContract, toCamel, toSnake, tsType, sqliteType, createInputFields, updateInputFields, hasField } from "./model-contract.mjs"; const __dirname = dirname(resolve(fileURLToPath(import.meta.url))); const WORKSPACE = resolve(__dirname, ".."); const DEFAULT_OUTPUT = resolve(WORKSPACE, "backend"); // ═══════════════════════════════════════════════════════ // Utilities // ═══════════════════════════════════════════════════════ function safeName(s) { return (s || "item") .replace(/[^a-zA-Z0-9_\u4e00-\u9fff]/g, "_") .replace(/_{2,}/g, "_") .replace(/^_|_$/g, "") || "item"; } function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); } function camelCase(s) { return s.replace(/[-_]([a-zA-Z])/g, (_, c) => c.toUpperCase()); } function pascalCase(s) { return capitalize(camelCase(s)); } /** Words where final "s" is part of the root, not a plural marker. */ const SINGULAR_EXCEPTIONS = new Set([ "status", "bus", "campus", "focus", "bonus", "virus", "genius", "census", "ensus", "consensus", "apparatus", "fetus", "hiatus", ]); function singularize(s) { if (s.endsWith("ies")) return s.slice(0, -3) + "y"; if (s.endsWith("ses") || s.endsWith("xes") || s.endsWith("ches") || s.endsWith("shes")) return s.slice(0, -2); if (s.endsWith("s") && !s.endsWith("ss")) { // Check compound words: "reading_status" → last segment is "status" const lastSeg = s.includes("_") ? s.split("_").pop() : s; if (SINGULAR_EXCEPTIONS.has(lastSeg)) return s; return s.slice(0, -1); } return s; } // ═══════════════════════════════════════════════════════ // 1. TypeScript Types Generator // ═══════════════════════════════════════════════════════ function generateTypes(contract) { const entities = contract.entities || []; const lines = [ `// Auto-generated types (from Model Contract)`, ``, ]; // ─── Entity interfaces ─────────────────────────── for (const entity of entities) { const name = entity.name; lines.push(`export interface ${name} {`); for (const f of entity.fields) { const fname = toCamel(contract, f.name); const optional = f.required ? "" : "?"; lines.push(` ${fname}${optional}: ${tsType(f)};`); } lines.push(`}`); lines.push(``); } // ─── CreateInput types ──────────────────────────── for (const entity of entities) { const name = entity.name; const createFields = createInputFields(entity); if (createFields.length === 0) continue; lines.push(`export interface Create${name}Input {`); for (const f of createFields) { const fname = toCamel(contract, f.name); const optional = f.required ? "" : "?"; lines.push(` ${fname}${optional}: ${tsType(f)};`); } lines.push(`}`); lines.push(``); } // ─── UpdateInput types (all optional) ───────────── for (const entity of entities) { const name = entity.name; const updateFields = updateInputFields(entity); if (updateFields.length === 0) continue; lines.push(`export interface Update${name}Input {`); for (const f of updateFields) { const fname = toCamel(contract, f.name); lines.push(` ${fname}?: ${tsType(f)};`); } lines.push(`}`); lines.push(``); } // ─── Auth types (always included) ──────────────── lines.push(`// ─── Auth ───────────────────────────────────────────`); lines.push(`export interface LoginInput {`); lines.push(` username: string;`); lines.push(` password: string;`); lines.push(`}`); lines.push(``); lines.push(`export interface RegisterInput {`); lines.push(` username: string;`); lines.push(` password: string;`); lines.push(` nickname?: string;`); lines.push(`}`); lines.push(``); lines.push(`export interface AuthResponse {`); lines.push(` token: string;`); lines.push(` user: User;`); lines.push(`}`); lines.push(``); // API response wrappers lines.push(`// ─── API ────────────────────────────────────────────`); lines.push(`export interface ApiResponse {`); lines.push(` data: T;`); lines.push(` message?: string;`); lines.push(`}`); lines.push(``); lines.push(`export interface PaginatedResponse {`); lines.push(` data: T[];`); lines.push(` total: number;`); lines.push(` page: number;`); lines.push(` pageSize: number;`); lines.push(`}`); lines.push(``); lines.push(`export interface ErrorResponse {`); lines.push(` error: string;`); lines.push(` message: string;`); lines.push(` statusCode: number;`); lines.push(`}`); lines.push(``); // JWT payload lines.push(`// ─── JWT ────────────────────────────────────────────`); lines.push(`export interface JwtPayload {`); lines.push(` userId: string;`); lines.push(` username: string;`); lines.push(` role: string;`); lines.push(` iat?: number;`); lines.push(` exp?: number;`); lines.push(`}`); lines.push(``); return lines.join("\n"); } // ═══════════════════════════════════════════════════════ // 2. Database Schema Generator (SQLite) // ═══════════════════════════════════════════════════════ function generateDBSchema(contract) { const entities = contract.entities || []; const sqlParts = []; for (const entity of entities) { const tableName = entity.table; const cols = []; for (const f of entity.fields) { const colName = f.name; const colType = sqliteType(f); let constraints = ""; if (f.isPrimary) { constraints = "PRIMARY KEY"; // Auto-generate UUID for primary keys if (f.isAuto || colType === "TEXT") { constraints += " DEFAULT (lower(hex(randomblob(16))))"; } } if (f.required && !f.isPrimary) { constraints = "NOT NULL"; } if (f.unique) { constraints = constraints ? `${constraints} UNIQUE` : "UNIQUE"; } if (f.defaultValue !== undefined && !f.isAuto) { const defVal = f.defaultValue.startsWith("'") || f.defaultValue.startsWith("\"") ? f.defaultValue : `'${f.defaultValue}'`; constraints = constraints ? `${constraints} DEFAULT ${defVal}` : `DEFAULT ${defVal}`; } if (f.fkEntity && f.fkColumn) { const ref = `REFERENCES ${f.fkEntity}(${f.fkColumn})`; constraints = constraints ? `${constraints} ${ref}` : ref; } cols.push(` ${colName} ${colType}${constraints ? " " + constraints : ""}`); } sqlParts.push(`CREATE TABLE IF NOT EXISTS ${tableName} (\n${cols.join(",\n")}\n);`); } // Generate the TS file const escapedSql = sqlParts.map(s => JSON.stringify(s)).join(",\n "); return `// Auto-generated SQLite schema import type { Database } from "sql.js"; export function createTables(db: Database): void { const statements = [ ${escapedSql} ]; for (const sql of statements) { const trimmed = sql.trim(); if (trimmed) db.run(trimmed); } } `; } // ═══════════════════════════════════════════════════════ // 3. Database Client Generator // ═══════════════════════════════════════════════════════ function generateDBClient() { return `// SQLite database client (sql.js — pure WASM, no native deps) import initSqlJs, { type Database, type BindParams } from "sql.js"; import { createTables } from "./schema.js"; let db: Database | null = null; let initPromise: Promise | null = null; /** Initialize the database (call once at startup). */ export async function initDb(dbPath?: string): Promise { if (db) return db; if (initPromise) return initPromise; initPromise = (async () => { const SQL = await initSqlJs(); const path = dbPath || process.env.DATABASE_URL || ":memory:"; // Try to load existing database from file let buffer: ArrayLike | undefined; if (path !== ":memory:") { try { const fs = await import("node:fs/promises"); const data = await fs.readFile(path); buffer = new Uint8Array(data); } catch { // File doesn't exist yet — start fresh } } db = new SQL.Database(buffer); db.run("PRAGMA foreign_keys = ON"); createTables(db); return db; })(); return initPromise; } /** Get the initialized database (must call initDb first). */ export function getDb(): Database { if (!db) throw new Error("Database not initialized. Call initDb() first."); return db; } /** Save database to disk. */ export async function saveDb(dbPath?: string): Promise { if (!db) return; const path = dbPath || process.env.DATABASE_URL || "./data/app.db"; if (path === ":memory:") return; const fs = await import("node:fs/promises"); const { dirname } = await import("node:path"); await fs.mkdir(dirname(path), { recursive: true }); const data = db.export(); await fs.writeFile(path, Buffer.from(data)); } export async function closeDb(): Promise { if (db) { await saveDb(); db.close(); db = null; initPromise = null; } } // Helper: run a query and return all rows as objects export function queryAll>(sql: string, params: BindParams = []): T[] { const d = getDb(); const stmt = d.prepare(sql); if (params) stmt.bind(params); const results: T[] = []; while (stmt.step()) { const row = stmt.getAsObject(); results.push(row as unknown as T); } stmt.free(); return results; } // Helper: run a query and return the first row export function queryOne>(sql: string, params: BindParams = []): T | undefined { const rows = queryAll(sql, params); return rows[0]; } // Helper: run a mutation and return { changes, lastInsertRowid } export function execute(sql: string, params: BindParams = []): { changes: number; lastInsertRowid: number } { const d = getDb(); d.run(sql, params); return { changes: d.getRowsModified(), lastInsertRowid: 0, }; } `; } // ═══════════════════════════════════════════════════════ // 4. Auth Middleware Generator // ═══════════════════════════════════════════════════════ function generateAuthMiddleware() { return `// JWT Authentication Middleware import type { FastifyRequest, FastifyReply } from "fastify"; import type { JwtPayload } from "../types/index.js"; /** * Verify JWT token and attach user to request. */ export async function authenticate(request: FastifyRequest, reply: FastifyReply): Promise { try { await request.jwtVerify(); } catch (err) { reply.status(401).send({ error: "Unauthorized", message: "Invalid or expired token", statusCode: 401 }); } } /** Helper to get typed user from request (after authenticate). */ export function getUser(request: FastifyRequest): JwtPayload { return request.user as unknown as JwtPayload; } /** * Require admin role. * Must be used after authenticate. */ export async function requireAdmin(request: FastifyRequest, reply: FastifyReply): Promise { const user = request.user as unknown as JwtPayload | undefined; if (!user || user.role !== "admin") { reply.status(403).send({ error: "Forbidden", message: "Admin access required", statusCode: 403 }); } } /** * Optional auth: attach user if token present, but don't fail if missing. */ export async function optionalAuth(request: FastifyRequest): Promise { try { await request.jwtVerify(); } catch { // No token or invalid — continue without user } } `; } // ═══════════════════════════════════════════════════════ // 5. Service Generator // ═══════════════════════════════════════════════════════ function generateServices(contract) { const entities = contract.entities || []; const files = {}; // Skip auth entities (users) — auth has its own service const serviceEntities = entities.filter(e => e.table !== "users"); for (const entity of serviceEntities) { const tableName = entity.table; const name = entity.name; const singular = singularize(tableName); const inputFields = createInputFields(entity); const updateFields = updateInputFields(entity); // Build the db column names and camelCase field names for the template const createCols = []; const createCamelFields = []; const updateColFields = []; for (const f of inputFields) { const colName = f.name; const camelName = toCamel(contract, colName); createCols.push(colName); createCamelFields.push(camelName); updateColFields.push({ col: colName, camel: camelName }); } const hasCreatedAt = hasField(entity, "created_at"); const hasUpdatedAt = hasField(entity, "updated_at"); // Build column list including id and timestamp cols const allCols = ["id", ...createCols]; if (hasCreatedAt) allCols.push("created_at"); if (hasUpdatedAt) allCols.push("updated_at"); const allPlaceholders = allCols.map(() => "?").join(", "); // Build values expression const valueParts = ["id"]; for (const camel of createCamelFields) { valueParts.push(`input.${camel} ?? null`); } if (hasCreatedAt) valueParts.push("now"); if (hasUpdatedAt) valueParts.push("now"); const valuesExpr = valueParts.join(", "); // Build update set expressions const updateLines = []; for (const { col, camel } of updateColFields) { updateLines.push(` if (input.${camel} !== undefined) { sets.push("${col} = ?"); values.push(input.${camel}); }`); } const serviceFile = `services/${singular}.ts`; files[`src/${serviceFile}`] = `// Auto-generated ${name} service import { queryAll, queryOne, execute } from "../db/client.js"; import type { ${name}, Create${name}Input, Update${name}Input } from "../types/index.js"; export class ${name}Service { /** List all ${tableName} */ list(): ${name}[] { return queryAll<${name}>("SELECT * FROM ${tableName} ORDER BY created_at DESC"); } /** Get by ID */ getById(id: string): ${name} | undefined { return queryOne<${name}>("SELECT * FROM ${tableName} WHERE id = ?", [id]); } /** Create */ create(input: Create${name}Input): ${name} { const id = crypto.randomUUID(); const now = new Date().toISOString(); const cols = ["${allCols.join('", "')}"]; const values = [${valuesExpr}]; execute(\`INSERT INTO ${tableName} (\${cols.join(", ")}) VALUES (${allPlaceholders})\`, values); return this.getById(id)!; } /** Update */ update(id: string, input: Update${name}Input): ${name} | undefined { const existing = this.getById(id); if (!existing) return undefined; const sets: string[] = []; const values: unknown[] = []; ${updateLines.join("\n")} if (sets.length === 0) return existing; ${hasUpdatedAt ? 'sets.push("updated_at = ?");\n values.push(new Date().toISOString());' : ''} values.push(id); execute(\`UPDATE ${tableName} SET \${sets.join(", ")} WHERE id = ?\`, values); return this.getById(id)!; } /** Delete */ delete(id: string): boolean { const result = execute("DELETE FROM ${tableName} WHERE id = ?", [id]); return result.changes > 0; } } `; } return files; } // ═══════════════════════════════════════════════════════ // 6. Route Generator (CRUD) // ═══════════════════════════════════════════════════════ function generateRoutes(contract) { const entities = contract.entities || []; const files = {}; // Skip auth entities (users) — auth has its own routes const routeEntities = entities.filter(e => e.table !== "users"); for (const entity of routeEntities) { const tableName = entity.table; const name = entity.name; const singular = singularize(tableName); const routeFile = `src/routes/${tableName}.ts`; files[routeFile] = `// Auto-generated ${name} routes import type { FastifyInstance } from "fastify"; import { authenticate } from "../middleware/auth.js"; import { ${name}Service } from "../services/${singular}.js"; import type { Create${name}Input, Update${name}Input } from "../types/index.js"; const service = new ${name}Service(); export async function ${tableName}Routes(app: FastifyInstance): Promise { // All routes require authentication app.addHook("onRequest", authenticate); // GET /api/${tableName} — list app.get("/", async (request, reply) => { const items = service.list(); return { data: items }; }); // GET /api/${tableName}/:id — get by id app.get<{ Params: { id: string } }>("/:id", async (request, reply) => { const item = service.getById(request.params.id); if (!item) { return reply.status(404).send({ error: "Not Found", message: "${name} not found", statusCode: 404 }); } return { data: item }; }); // POST /api/${tableName} — create app.post<{ Body: Create${name}Input }>("/", async (request, reply) => { const item = service.create(request.body); return reply.status(201).send({ data: item }); }); // PUT /api/${tableName}/:id — update app.put<{ Params: { id: string }; Body: Update${name}Input }>("/:id", async (request, reply) => { const item = service.update(request.params.id, request.body); if (!item) { return reply.status(404).send({ error: "Not Found", message: "${name} not found", statusCode: 404 }); } return { data: item }; }); // DELETE /api/${tableName}/:id — delete app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => { const deleted = service.delete(request.params.id); if (!deleted) { return reply.status(404).send({ error: "Not Found", message: "${name} not found", statusCode: 404 }); } return reply.status(204).send(); }); } `; } return files; } // ═══════════════════════════════════════════════════════ // 7. Auth Routes Generator // ═══════════════════════════════════════════════════════ function generateAuthRoutes() { return `// Authentication routes import type { FastifyInstance } from "fastify"; import bcrypt from "bcrypt"; import { queryOne, execute } from "../db/client.js"; import { authenticate } from "../middleware/auth.js"; import type { User, RegisterInput, LoginInput, AuthResponse } from "../types/index.js"; const SALT_ROUNDS = 10; export async function authRoutes(app: FastifyInstance): Promise { // POST /api/auth/register app.post<{ Body: RegisterInput }>("/register", async (request, reply) => { const { username, password, nickname } = request.body; if (!username || !password) { return reply.status(400).send({ error: "Bad Request", message: "Username and password are required", statusCode: 400, }); } if (password.length < 6) { return reply.status(400).send({ error: "Bad Request", message: "Password must be at least 6 characters", statusCode: 400, }); } const existing = queryOne("SELECT id FROM users WHERE username = ?", [username]); if (existing) { return reply.status(409).send({ error: "Conflict", message: "Username already exists", statusCode: 409, }); } const id = crypto.randomUUID(); const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); const now = new Date().toISOString(); execute( "INSERT INTO users (id, username, password_hash, nickname, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", [id, username, passwordHash, nickname || username, now, now] ); const user = queryOne( "SELECT id, username, nickname, role, created_at, updated_at FROM users WHERE id = ?", [id] ); if (!user) { return reply.status(500).send({ error: "Internal Error", message: "Failed to create user", statusCode: 500 }); } const token = app.jwt.sign({ userId: id, username, role: user.role }); return reply.status(201).send({ token, user } satisfies AuthResponse); }); // POST /api/auth/login app.post<{ Body: LoginInput }>("/login", async (request, reply) => { const { username, password } = request.body; if (!username || !password) { return reply.status(400).send({ error: "Bad Request", message: "Username and password are required", statusCode: 400, }); } const user = queryOne( "SELECT * FROM users WHERE username = ?", [username] ); if (!user) { return reply.status(401).send({ error: "Unauthorized", message: "Invalid username or password", statusCode: 401, }); } const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { return reply.status(401).send({ error: "Unauthorized", message: "Invalid username or password", statusCode: 401, }); } const token = app.jwt.sign({ userId: user.id, username: user.username, role: user.role }); const { password_hash, ...safeUser } = user; return { token, user: safeUser } satisfies AuthResponse; }); // GET /api/auth/me — current user info app.get("/me", { onRequest: [authenticate] }, async (request, reply) => { const jwtUser = request.user as unknown as { userId: string }; const user = queryOne( "SELECT id, username, nickname, role, created_at, updated_at FROM users WHERE id = ?", [jwtUser.userId] ); if (!user) { return reply.status(404).send({ error: "Not Found", message: "User not found", statusCode: 404, }); } return { data: user }; }); } `; } // ═══════════════════════════════════════════════════════ // 8. Server Entry Point Generator // ═══════════════════════════════════════════════════════ function generateIndex(contract, projectName) { const entities = (contract.entities || []).filter(e => e.table !== "users"); const routeImports = entities.map(e => { return `import { ${e.table}Routes } from "./routes/${e.table}.js";`; }).join("\n"); const routeRegistrations = entities.map(e => { return ` await app.register(${e.table}Routes, { prefix: "/api/${e.table}" });`; }).join("\n"); return `// ${projectName} — Fastify Backend Server import Fastify from "fastify"; import cors from "@fastify/cors"; import fjwt from "@fastify/jwt"; import { initDb, closeDb } from "./db/client.js"; import { authRoutes } from "./routes/auth.js"; ${routeImports} const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production-${crypto.randomUUID().slice(0, 8)}"; export async function buildApp() { const app = Fastify({ logger: { level: process.env.LOG_LEVEL || "info", transport: process.env.NODE_ENV !== "production" ? { target: "pino-pretty", options: { colorize: true } } : undefined, }, }); // Init database await initDb(); // Plugins await app.register(cors, { origin: process.env.CORS_ORIGIN || "*", methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], }); await app.register(fjwt, { secret: JWT_SECRET }); // Routes await app.register(authRoutes, { prefix: "/api/auth" }); ${routeRegistrations} // Health check app.get("/api/health", async () => ({ status: "ok", timestamp: new Date().toISOString() })); // Graceful shutdown app.addHook("onClose", async () => { closeDb(); }); return app; } // Start server if called directly (not when imported by tests) const port = parseInt(process.env.PORT || "3001", 10); const host = process.env.HOST || "0.0.0.0"; async function main() { const app = await buildApp(); try { await app.listen({ port, host }); } catch (err) { app.log.error(err); process.exit(1); } } // Guard: only run when executed directly, not when imported const isMain = process.argv[1] && (import.meta.url === \`file://\${process.argv[1]}\` || import.meta.url.endsWith(process.argv[1]) || process.argv[1].endsWith("/src/index.ts") || process.argv[1].endsWith("/src/index.js")); if (isMain) { main(); } `; } // ═══════════════════════════════════════════════════════ // 9. Config Files Generator // ═══════════════════════════════════════════════════════ function generatePackageJson(projectName) { const name = safeName(projectName || "backend").toLowerCase().replace(/\s+/g, "-"); return JSON.stringify({ name, version: "0.1.0", private: true, type: "module", scripts: { dev: "tsx watch src/index.ts", build: "tsc", start: "node dist/index.js", test: "node --import tsx --test src/__tests__/*.test.ts", "test:watch": "node --import tsx --test --watch src/__tests__/*.test.ts", }, dependencies: { "fastify": "^5.0.0", "@fastify/cors": "^10.0.0", "@fastify/jwt": "^9.0.0", "sql.js": "^1.12.0", "bcrypt": "^5.1.0", }, devDependencies: { "@types/node": "^22.0.0", "@types/bcrypt": "^5.0.0", "typescript": "^5.6.0", "tsx": "^4.0.0", "pino-pretty": "^11.0.0", }, }, null, 2); } function generateTsConfig() { return JSON.stringify({ compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "bundler", lib: ["ES2022"], outDir: "./dist", rootDir: "./src", strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, resolveJsonModule: true, declaration: true, sourceMap: true, }, include: ["src/**/*"], exclude: ["node_modules", "dist", "src/__tests__"], }, null, 2); } function generateEnvExample(projectName) { return `# ${projectName} Backend Environment Variables # Server PORT=3001 HOST=0.0.0.0 NODE_ENV=development LOG_LEVEL=info # Database DATABASE_URL=./data/app.db # Auth JWT_SECRET=change-me-to-a-random-secret-key # CORS CORS_ORIGIN=* `; } function generateReadme(projectName, prdSummary, contract) { const entities = (contract.entities || []).filter(e => e.table !== "users"); const entityList = entities.map(e => `- **${e.table}** — ${e.description || ""}`).join("\n"); return `# ${projectName} — Backend API > ${prdSummary || "Auto-generated backend service"} ## Tech Stack - **Runtime**: Node.js - **Framework**: Fastify 5 - **Language**: TypeScript - **Database**: SQLite (better-sqlite3) - **Auth**: JWT + bcrypt ## Getting Started \`\`\`bash # Install dependencies npm install # Development (hot reload) npm run dev # Build npm run build # Production start npm run start # Run tests npm test \`\`\` ## Project Structure \`\`\` src/ ├── index.ts # Server entry point ├── db/ │ ├── schema.ts # SQLite schema │ └── client.ts # Database client ├── routes/ │ ├── auth.ts # Auth routes (register/login/me) │ └── *.ts # CRUD routes ├── services/ │ └── *.ts # Business logic ├── middleware/ │ └── auth.ts # JWT middleware ├── types/ │ └── index.ts # TypeScript types └── __tests__/ └── *.test.ts # Tests \`\`\` ## API Endpoints ### Auth - \`POST /api/auth/register\` — Register - \`POST /api/auth/login\` — Login - \`GET /api/auth/me\` — Current user (auth required) ### Resources ${entities.map(e => { const tableName = e.table; return `#### ${tableName} - \`GET /api/${tableName}\` — List all - \`GET /api/${tableName}/:id\` — Get by ID - \`POST /api/${tableName}\` — Create - \`PUT /api/${tableName}/:id\` — Update - \`DELETE /api/${tableName}/:id\` — Delete`; }).join("\n\n")} ### System - \`GET /api/health\` — Health check `; } // ═══════════════════════════════════════════════════════ // 10. Test Generator // ═══════════════════════════════════════════════════════ function generateTests(contract, projectName) { const entities = contract.entities || []; const tests = {}; // Auth test tests["__tests__/auth.test.ts"] = `// Auth routes test import { describe, it, before, after } from "node:test"; import assert from "node:assert/strict"; import { buildApp } from "../index.js"; import type { FastifyInstance } from "fastify"; let app: FastifyInstance; let token: string; before(async () => { process.env.JWT_SECRET = "test-secret"; process.env.DATABASE_URL = ":memory:"; app = await buildApp(); await app.ready(); }); after(async () => { await app.close(); }); describe("POST /api/auth/register", () => { it("registers a new user", async () => { const res = await app.inject({ method: "POST", url: "/api/auth/register", payload: { username: "testuser", password: "password123" }, }); assert.equal(res.statusCode, 201); const body = res.json(); assert.ok(body.token); assert.equal(body.user.username, "testuser"); token = body.token; }); it("rejects duplicate username", async () => { const res = await app.inject({ method: "POST", url: "/api/auth/register", payload: { username: "testuser", password: "password123" }, }); assert.equal(res.statusCode, 409); }); it("rejects short password", async () => { const res = await app.inject({ method: "POST", url: "/api/auth/register", payload: { username: "user2", password: "123" }, }); assert.equal(res.statusCode, 400); }); }); describe("POST /api/auth/login", () => { it("logs in with correct credentials", async () => { const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "testuser", password: "password123" }, }); assert.equal(res.statusCode, 200); const body = res.json(); assert.ok(body.token); token = body.token; }); it("rejects wrong password", async () => { const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "testuser", password: "wrongpassword" }, }); assert.equal(res.statusCode, 401); }); }); describe("GET /api/auth/me", () => { it("returns current user with valid token", async () => { const res = await app.inject({ method: "GET", url: "/api/auth/me", headers: { authorization: \`Bearer \${token}\` }, }); assert.equal(res.statusCode, 200); const body = res.json(); assert.equal(body.data.username, "testuser"); }); it("rejects without token", async () => { const res = await app.inject({ method: "GET", url: "/api/auth/me", }); assert.equal(res.statusCode, 401); }); }); `; // CRUD test template for each entity (skip users — auth has own tests) const testEntities = entities.filter(e => e.table !== "users"); for (const entity of testEntities) { const tableName = entity.table; const name = entity.name; const singular = singularize(tableName); // Build sample create payload from input fields const inputFields = createInputFields(entity); const samplePayload = {}; let hasUserFk = false; for (const f of inputFields) { const fname = toCamel(contract, f.name); const ft = tsType(f); // FK fields referencing users — use registered user's real ID if (f.fkEntity === "users" && f.fkColumn === "id") { samplePayload[fname] = "__REG_USER_ID__"; hasUserFk = true; } else if (ft === "number") { samplePayload[fname] = 1; } else if (ft === "boolean") { samplePayload[fname] = true; } else { samplePayload[fname] = `sample-${fname.toLowerCase()}`; } } // Serialize payload for use in template const payloadJson = JSON.stringify(samplePayload, null, 4) .split("\n").map((line, i) => i === 0 ? line : ` ${line}`).join("\n"); const userIdExpr = hasUserFk ? `regRes.json().user.id` : `"00000000-0000-0000-0000-000000000001"`; tests[`__tests__/${tableName}.test.ts`] = `// ${name} CRUD test import { describe, it, before, after } from "node:test"; import assert from "node:assert/strict"; import { buildApp } from "../index.js"; import type { FastifyInstance } from "fastify"; let app: FastifyInstance; let token: string; let createdId: string; let payload: Record; before(async () => { process.env.JWT_SECRET = "test-secret"; process.env.DATABASE_URL = ":memory:"; app = await buildApp(); await app.ready(); // Register and login to get a token const regRes = await app.inject({ method: "POST", url: "/api/auth/register", payload: { username: \`test_\${Date.now()}\`, password: "password123" }, }); token = regRes.json().token; // Resolve user FK references with the registered user's real ID payload = ${payloadJson.replace(/"__REG_USER_ID__"/g, userIdExpr)}; }); after(async () => { await app.close(); }); describe("GET /api/${tableName}", () => { it("returns empty list", async () => { const res = await app.inject({ method: "GET", url: "/api/${tableName}", headers: { authorization: \`Bearer \${token}\` }, }); assert.equal(res.statusCode, 200); const body = res.json(); assert.ok(Array.isArray(body.data)); }); }); describe("POST /api/${tableName}", () => { it("creates a ${singular}", async () => { const res = await app.inject({ method: "POST", url: "/api/${tableName}", headers: { authorization: \`Bearer \${token}\` }, payload, }); assert.equal(res.statusCode, 201); const body = res.json(); assert.ok(body.data.id); createdId = body.data.id; }); }); describe("GET /api/${tableName}/:id", () => { it("returns the created ${singular}", async () => { const res = await app.inject({ method: "GET", url: \`/api/${tableName}/\${createdId}\`, headers: { authorization: \`Bearer \${token}\` }, }); assert.equal(res.statusCode, 200); const body = res.json(); assert.equal(body.data.id, createdId); }); it("returns 404 for nonexistent id", async () => { const res = await app.inject({ method: "GET", url: \`/api/${tableName}/nonexistent\`, headers: { authorization: \`Bearer \${token}\` }, }); assert.equal(res.statusCode, 404); }); }); describe("PUT /api/${tableName}/:id", () => { it("updates the ${singular}", async () => { const res = await app.inject({ method: "PUT", url: \`/api/${tableName}/\${createdId}\`, headers: { authorization: \`Bearer \${token}\` }, payload, }); assert.equal(res.statusCode, 200); }); }); describe("DELETE /api/${tableName}/:id", () => { it("deletes the ${singular}", async () => { const res = await app.inject({ method: "DELETE", url: \`/api/${tableName}/\${createdId}\`, headers: { authorization: \`Bearer \${token}\` }, }); assert.equal(res.statusCode, 204); }); it("returns 404 after delete", async () => { const res = await app.inject({ method: "GET", url: \`/api/${tableName}/\${createdId}\`, headers: { authorization: \`Bearer \${token}\` }, }); assert.equal(res.statusCode, 404); }); }); `; } return tests; } // ═══════════════════════════════════════════════════════ // 11. Main Builder Function // ═══════════════════════════════════════════════════════ /** * Build a complete backend project from PRD + Architecture. * * @param {object} prd — SF-01 PRD * @param {object} arch — SF-02 Architecture * @returns {object} { files, stats, error, message } */ export function buildBackend(prd, arch) { if (!prd || prd.error) { return { error: "INVALID_PRD", message: "Invalid or missing PRD", files: {}, stats: {} }; } if (!arch || arch.error) { return { error: "INVALID_ARCH", message: "Invalid or missing Architecture", files: {}, stats: {} }; } const projectName = prd.projectName || arch.projectName || "BackendApp"; const contract = arch.contract || createContract(prd, { databaseSchema: arch.databaseSchema || [], domain: arch.domain }); const prdSummary = prd.summary || ""; const files = {}; // ── Config files ── files["package.json"] = generatePackageJson(projectName); files["tsconfig.json"] = generateTsConfig(); files[".env.example"] = generateEnvExample(projectName); files["README.md"] = generateReadme(projectName, prdSummary, contract); // ── Types ── files["src/types/index.ts"] = generateTypes(contract); files["src/types/fastify.d.ts"] = `// Augment Fastify request with JWT user import type { JwtPayload } from "./index.js"; declare module "fastify" { interface FastifyRequest { user?: JwtPayload; } } `; files["src/types/sql.js.d.ts"] = `// Type declarations for sql.js (no native types package available) declare module "sql.js" { export interface Database { run(sql: string, params?: BindParams): void; exec(sql: string): void; prepare(sql: string): Statement; export(): Uint8Array; close(): void; getRowsModified(): number; } export interface Statement { bind(params?: BindParams): boolean; step(): boolean; getAsObject>(): T; getColumnNames(): string[]; free(): boolean; } export type BindParams = unknown[] | Record; export interface SqlJsStatic { Database: new (data?: ArrayLike) => Database; } export default function initSqlJs(config?: Record): Promise; } `; // ── Database ── files["src/db/schema.ts"] = generateDBSchema(contract); files["src/db/client.ts"] = generateDBClient(); // ── Middleware ── files["src/middleware/auth.ts"] = generateAuthMiddleware(); // ── Services ── Object.assign(files, generateServices(contract)); // ── Routes (already include src/ prefix) Object.assign(files, generateRoutes(contract)); files["src/routes/auth.ts"] = generateAuthRoutes(); // ── Server entry ── files["src/index.ts"] = generateIndex(contract, projectName); // ── Tests (with src/ prefix fixup) const tests = generateTests(contract, projectName); for (const [key, value] of Object.entries(tests)) { files[`src/${key}`] = value; } // ── .gitignore ── files[".gitignore"] = `node_modules/ dist/ data/ .env *.db *.db-journal *.db-wal `; // ── Stats ── const allPaths = Object.keys(files); const totalFiles = allPaths.length; const routes = allPaths.filter(p => p.startsWith("src/routes/") && p !== "src/routes/auth.ts"); const services = allPaths.filter(p => p.startsWith("src/services/")); const testFiles = allPaths.filter(p => p.startsWith("src/__tests__/")); const stats = { totalFiles, routes: routes.length, services: services.length, tests: testFiles.length, dbSchema: contract.entities.length, routeFiles: routes, serviceFiles: services, testFiles, }; return { files, stats, error: null, message: null }; } // ═══════════════════════════════════════════════════════ // 12. File I/O // ═══════════════════════════════════════════════════════ export function loadJSON(path) { try { if (!existsSync(path)) return { data: null, error: `File not found: ${path}` }; return { data: JSON.parse(readFileSync(path, "utf-8")), error: null }; } catch (e) { return { data: null, error: `Failed to load: ${e.message}` }; } } export function writeBackend(result, outputDir) { mkdirSync(outputDir, { recursive: true }); for (const [relPath, content] of Object.entries(result.files)) { const fullPath = resolve(outputDir, relPath); mkdirSync(dirname(fullPath), { recursive: true }); writeFileSync(fullPath, content); } } // ═══════════════════════════════════════════════════════ // 13. CLI Entry // ═══════════════════════════════════════════════════════ function parseArgs() { const args = process.argv.slice(2); const opts = { prd: null, arch: null, inputText: null, output: null, verbose: false, help: false }; for (let i = 0; i < args.length; i++) { if (args[i] === "--prd" && args[i + 1]) opts.prd = args[++i]; else if (args[i] === "--arch" && args[i + 1]) opts.arch = args[++i]; else if (args[i] === "--input-text" && args[i + 1]) opts.inputText = args[++i]; else if (args[i] === "--output" && args[i + 1]) opts.output = args[++i]; else if (args[i] === "--verbose") opts.verbose = true; else if (args[i] === "--help" || args[i] === "-h") opts.help = true; } return opts; } async function main() { const opts = parseArgs(); if (opts.help) { console.log(` Backend Builder Agent — SF-04 Usage: node scripts/backend-builder-agent.mjs --prd --arch [options] node scripts/backend-builder-agent.mjs --input-text "<需求>" [options] Options: --prd PRD JSON(SF-01 输出) --arch Architecture JSON(SF-02 输出) --input-text 直接传入需求(自动调 SF-01 → SF-02 → SF-04) --output 输出目录(default: backend/) --verbose 详细输出 --help 显示帮助 `); return; } let prd = null, arch = null; if (opts.prd && opts.arch) { const prdRes = loadJSON(opts.prd); const archRes = loadJSON(opts.arch); if (prdRes.error) { console.error(prdRes.error); process.exit(1); } if (archRes.error) { console.error(archRes.error); process.exit(1); } prd = prdRes.data; arch = archRes.data; } else if (opts.inputText) { try { const sf01 = await import("./project-intake-agent.mjs"); const sf02 = await import("./architecture-agent.mjs"); prd = sf01.generatePRD(opts.inputText); if (prd.error) { console.error(`SF-01: ${prd.message}`); process.exit(1); } arch = sf02.generateArchitecture(prd); if (arch.error) { console.error(`SF-02: ${arch.message}`); process.exit(1); } } catch (e) { console.error(`Pipeline error: ${e.message}`); process.exit(1); } } if (!prd || !arch) { console.error("Error: --prd + --arch or --input-text is required. Use --help."); process.exit(1); } const result = buildBackend(prd, arch); if (result.error) { console.error(result.message); process.exit(1); } const outputDir = opts.output ? resolve(opts.output) : DEFAULT_OUTPUT; writeBackend(result, outputDir); if (opts.verbose) { console.error(`Project: ${prd.projectName}`); console.error(`Files: ${result.stats.totalFiles}`); console.error(`Routes: ${result.stats.routes}, Services: ${result.stats.services}, Tests: ${result.stats.tests}`); } console.log(JSON.stringify({ projectName: prd.projectName, outputDir, stats: result.stats, }, null, 2)); } if (process.argv[1] && (import.meta.url === `file://${process.argv[1]}` || import.meta.url.endsWith(process.argv[1]))) { main(); }