Files
16gagent/scripts/backend-builder-agent.mjs
2026-06-06 10:40:48 +08:00

1465 lines
47 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <path> --arch <path> [options]
* node scripts/backend-builder-agent.mjs --input-text "<需求>" [options]
*
* Options:
* --prd <path> PRD JSONSF-01 输出)
* --arch <path> Architecture JSONSF-02 输出)
* --input-text <text> 直接传入需求(自动调 SF-01 → SF-02 → SF-04
* --output <dir> 输出目录(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<T> {`);
lines.push(` data: T;`);
lines.push(` message?: string;`);
lines.push(`}`);
lines.push(``);
lines.push(`export interface PaginatedResponse<T> {`);
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<Database> | null = null;
/** Initialize the database (call once at startup). */
export async function initDb(dbPath?: string): Promise<Database> {
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<number> | 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<void> {
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<void> {
if (db) {
await saveDb();
db.close();
db = null;
initPromise = null;
}
}
// Helper: run a query and return all rows as objects
export function queryAll<T = Record<string, unknown>>(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<T = Record<string, unknown>>(sql: string, params: BindParams = []): T | undefined {
const rows = queryAll<T>(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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
// 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<User>(
"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<User & { password_hash: string }>(
"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<User>(
"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<string, unknown>;
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 = Record<string, unknown>>(): T;
getColumnNames(): string[];
free(): boolean;
}
export type BindParams = unknown[] | Record<string, unknown>;
export interface SqlJsStatic {
Database: new (data?: ArrayLike<number>) => Database;
}
export default function initSqlJs(config?: Record<string, unknown>): Promise<SqlJsStatic>;
}
`;
// ── 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 <path> --arch <path> [options]
node scripts/backend-builder-agent.mjs --input-text "<需求>" [options]
Options:
--prd <path> PRD JSONSF-01 输出)
--arch <path> Architecture JSONSF-02 输出)
--input-text <text> 直接传入需求(自动调 SF-01 → SF-02 → SF-04
--output <dir> 输出目录(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();
}