1465 lines
47 KiB
JavaScript
1465 lines
47 KiB
JavaScript
#!/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 JSON(SF-01 输出)
|
||
* --arch <path> Architecture JSON(SF-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 JSON(SF-01 输出)
|
||
--arch <path> Architecture JSON(SF-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();
|
||
}
|