#!/usr/bin/env node /** * Fullstack Composer Agent — SF-05 * * 组合 frontend-builder-agent (SF-03) + backend-builder-agent (SF-04), * 从同一个 SF-02 RequirementPackage 生成完整可运行的全栈项目。 * * 输出结构: * apps/web/ — Next.js 前端 (SF-03) * apps/api/ — Fastify 后端 (SF-04) * packages/shared-types/ — 前后端共享类型 * packages/shared-config/ — 前后端共享配置 * 根目录 — workspace package.json / README / scripts * * Usage: * node scripts/fullstack-composer-agent.mjs --prd --arch [options] * node scripts/fullstack-composer-agent.mjs --input-text "<需求>" [options] * * Options: * --prd PRD JSON(SF-01 输出) * --arch Architecture JSON(SF-02 输出) * --input-text 直接传入需求 * --output 输出目录(default: fullstack/) * --verbose 详细输出 * --help 显示帮助 * * @module fullstack-composer-agent */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { createContract, toCamel, tsType as contractTsType } from "./model-contract.mjs"; const __dirname = dirname(resolve(fileURLToPath(import.meta.url))); const WORKSPACE = resolve(__dirname, ".."); const DEFAULT_OUTPUT = resolve(WORKSPACE, "fullstack"); // ═══════════════════════════════════════════════════════ // 1. Shared Types Generator // ═══════════════════════════════════════════════════════ function sqliteTypeForTs(field) { const t = (field.type || "").toUpperCase(); if (t.includes("INT") || t.includes("SERIAL") || t.includes("BIGINT") || t.includes("DECIMAL") || t.includes("NUMERIC") || t.includes("FLOAT") || t.includes("DOUBLE") || t.includes("REAL")) return "number"; if (t.includes("BOOL")) return "boolean"; return "string"; } function isRequired(field) { const c = (field.constraints || "").toUpperCase(); return c.includes("NOT NULL") || c.includes("PK"); } function pascalCase(s) { return s.charAt(0).toUpperCase() + s.slice(1).replace(/[-_]([a-zA-Z])/g, (_, c) => c.toUpperCase()); } /** 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")) { const lastSeg = s.includes("_") ? s.split("_").pop() : s; if (SINGULAR_EXCEPTIONS.has(lastSeg)) return s; return s.slice(0, -1); } return s; } /** * Generate shared types that both frontend and backend can import. * Includes entity types + API response wrappers only. */ function generateSharedTypes(contract) { const entities = contract.entities || []; const lines = [ `// Shared types for fullstack project`, `// Auto-generated — used by both apps/web and apps/api`, ``, `// ─── Entities ────────────────────────────────────────`, ]; const generated = new Set(); // Include User and all entities from contract for (const entity of entities) { const name = entity.name; if (generated.has(name)) continue; generated.add(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}: ${contractTsType(f)};`); } lines.push(`}`); lines.push(``); } // ─── API Response Wrappers ────────────────────────── lines.push(`// ─── API Response Wrappers ───────────────────────────`); 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(``); return lines.join("\n"); } // ═══════════════════════════════════════════════════════ // 2. Shared Config Generator // ═══════════════════════════════════════════════════════ function generateSharedConfig() { return { "src/index.ts": `// Shared configuration for fullstack project /** API base URL — reads from env or defaults */ export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; /** API prefix for all endpoints */ export const API_PREFIX = "/api"; /** Full API URL */ export const API_URL = \`\${API_BASE_URL}\${API_PREFIX}\`; /** Auth token storage key */ export const AUTH_TOKEN_KEY = "auth_token"; /** Default page size for paginated endpoints */ export const DEFAULT_PAGE_SIZE = 20; `, "package.json": JSON.stringify({ name: "@shared/config", version: "0.1.0", private: true, main: "./src/index.ts", types: "./src/index.ts", }, null, 2), "tsconfig.json": JSON.stringify({ compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "bundler", strict: true, esModuleInterop: true, skipLibCheck: true, declaration: true, outDir: "./dist", }, include: ["src"], }, null, 2), }; } // ═══════════════════════════════════════════════════════ // 3. Shared Types Package Generator // ═══════════════════════════════════════════════════════ function generateSharedTypesPackage(contract) { return { "src/index.ts": generateSharedTypes(contract), "package.json": JSON.stringify({ name: "@shared/types", version: "0.1.0", private: true, main: "./src/index.ts", types: "./src/index.ts", }, null, 2), "tsconfig.json": JSON.stringify({ compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "bundler", strict: true, esModuleInterop: true, skipLibCheck: true, declaration: true, outDir: "./dist", }, include: ["src"], }, null, 2), }; } // ═══════════════════════════════════════════════════════ // 4. Root Files Generator // ═══════════════════════════════════════════════════════ function generateRootFiles(projectName, prdSummary, contract) { const safeName = (projectName || "fullstack").toLowerCase().replace(/[^a-z0-9-]/g, "-"); const entities = contract.entities || []; const tableList = entities.map(e => `- **${e.table}** — ${e.description || ""}`).join("\n"); return { "package.json": JSON.stringify({ name: safeName, version: "0.1.0", private: true, workspaces: [ "apps/*", "packages/*", ], scripts: { dev: "node scripts/dev.mjs", build: "node scripts/build.mjs", "dev:web": "npm run dev -w apps/web", "dev:api": "npm run dev -w apps/api", "build:web": "npm run build -w apps/web", "build:api": "npm run build -w apps/api", test: "npm run test -w apps/api", lint: "npm run lint -w apps/web", }, }, null, 2), ".env.example": `# ─── API Server ─────────────────────────────────── API_PORT=3001 API_HOST=0.0.0.0 NODE_ENV=development DATABASE_URL=./data/app.db JWT_SECRET=change-me-in-production CORS_ORIGIN=http://localhost:3000 # ─── Web Client ─────────────────────────────────── NEXT_PUBLIC_API_URL=http://localhost:3001 WEB_PORT=3000 `, "README.md": `# ${projectName} — Fullstack Project > ${prdSummary || "Auto-generated fullstack application"} ## Architecture \`\`\` apps/ ├── web/ # Next.js 15 + TypeScript + Tailwind CSS └── api/ # Fastify 5 + TypeScript + SQLite (sql.js) packages/ ├── shared-types/ # @shared/types — shared TypeScript interfaces └── shared-config/ # @shared/config — shared configuration \`\`\` ## Quick Start \`\`\`bash # Install all dependencies (root + workspaces) npm install # Start both frontend and backend in dev mode npm run dev # Or start individually npm run dev:web # http://localhost:3000 npm run dev:api # http://localhost:3001 \`\`\` ## Build \`\`\`bash # Build everything npm run build # Or individually npm run build:web npm run build:api \`\`\` ## Testing \`\`\`bash # Run API tests npm test \`\`\` ## API Endpoints ### Auth - \`POST /api/auth/register\` - \`POST /api/auth/login\` - \`GET /api/auth/me\` ### Resources ${entities.map(e => { const tn = e.table; return `#### ${tn}\n- \`GET /api/${tn}\` — List\n- \`GET /api/${tn}/:id\` — Get\n- \`POST /api/${tn}\` — Create\n- \`PUT /api/${tn}/:id\` — Update\n- \`DELETE /api/${tn}/:id\` — Delete`; }).join("\n\n")} ### System - \`GET /api/health\` — Health check ## Environment Variables See \`.env.example\` for all available configuration. `, ".gitignore": `node_modules/ dist/ .next/ data/ .env *.db *.db-journal *.db-wal `, }; } // ═══════════════════════════════════════════════════════ // 5. Scripts Generator // ═══════════════════════════════════════════════════════ function generateScripts() { return { "scripts/dev.mjs": `#!/usr/bin/env node /** * Dev script — starts both web and api in parallel. * Usage: node scripts/dev.mjs */ import { spawn } from "node:child_process"; function start(name, command, args, cwd) { const child = spawn(command, args, { cwd, stdio: "inherit", shell: true, env: { ...process.env, FORCE_COLOR: "1" }, }); child.on("error", (err) => console.error(\`[\${name}] Failed: \${err.message}\`)); child.on("exit", (code) => { if (code !== 0 && code !== null) console.error(\`[\${name}] Exited with code \${code}\`); }); return child; } const ROOT = new URL("..", import.meta.url).pathname; console.log("🚀 Starting fullstack dev servers...\\n"); const api = start("api", "npm", ["run", "dev"], \`\${ROOT}/apps/api\`); const web = start("web", "npm", ["run", "dev"], \`\${ROOT}/apps/web\`); process.on("SIGINT", () => { api.kill(); web.kill(); process.exit(0); }); process.on("SIGTERM", () => { api.kill(); web.kill(); process.exit(0); }); `, "scripts/build.mjs": `#!/usr/bin/env node /** * Build script — builds both web and api. * Usage: node scripts/build.mjs */ import { execSync } from "node:child_process"; const ROOT = new URL("..", import.meta.url).pathname; function run(cmd, cwd) { console.log(\`\\n🔨 \${cmd} (in \${cwd})\`); execSync(cmd, { cwd, stdio: "inherit" }); } console.log("🏗️ Building fullstack project...\\n"); try { run("npm run build", \`\${ROOT}/apps/api\`); run("npm run build", \`\${ROOT}/apps/web\`); console.log("\\n✅ Build complete!"); } catch (e) { console.error("\\n❌ Build failed:", e.message); process.exit(1); } `, }; } // ═══════════════════════════════════════════════════════ // 6. Frontend Post-Processor // ═══════════════════════════════════════════════════════ /** * Adapt the generated frontend to work in a monorepo with shared packages. * - Add @shared/types and @shared/config as dependencies * - Update services/api.ts to use @shared/config for API base URL */ function postProcessFrontend(files, projectName) { const modified = { ...files }; const baseName = (projectName || "app").toLowerCase().replace(/[^a-z0-9-]/g, "-"); // ── Update package.json ── if (modified["package.json"]) { const pkg = JSON.parse(modified["package.json"]); pkg.name = `${baseName}-web`; pkg.dependencies = pkg.dependencies || {}; pkg.dependencies["@shared/types"] = "*"; pkg.dependencies["@shared/config"] = "*"; modified["package.json"] = JSON.stringify(pkg, null, 2); } // ── Update services/api.ts (after prefixSrc, located at src/services/api.ts) ── if (modified["src/services/api.ts"]) { modified["src/services/api.ts"] = modified["src/services/api.ts"].replace( /(const API_BASE = .*?;)/s, `import { API_BASE_URL, API_PREFIX } from "@shared/config"; const API_BASE = \`\${API_BASE_URL}\${API_PREFIX}\`;` ); } // ── Create a shared-types re-export in src/types ── // We keep domain-specific types but re-export shared types if (modified["src/types/index.ts"]) { modified["src/types/index.ts"] = `// Re-export shared types for convenience export type { User, ApiResponse, PaginatedResponse, ErrorResponse, } from "@shared/types"; ${modified["src/types/index.ts"]} `; } return modified; } // ═══════════════════════════════════════════════════════ // 7. Backend Post-Processor // ═══════════════════════════════════════════════════════ function postProcessBackend(files, projectName, contract) { const modified = { ...files }; const baseName = (projectName || "app").toLowerCase().replace(/[^a-z0-9-]/g, "-"); const entities = contract.entities || []; // ── Update package.json ── if (modified["package.json"]) { const pkg = JSON.parse(modified["package.json"]); pkg.name = `${baseName}-api`; pkg.dependencies = pkg.dependencies || {}; pkg.dependencies["@shared/types"] = "*"; pkg.dependencies["@shared/config"] = "*"; modified["package.json"] = JSON.stringify(pkg, null, 2); } // ── Rewrite types to use @shared/types for entities & API wrappers ── if (modified["src/types/index.ts"]) { let content = modified["src/types/index.ts"]; // Build set of entity type names from contract const entityNames = new Set(["User"]); for (const entity of entities.filter(e => e.table !== "users")) { entityNames.add(entity.name); } // Remove specific duplicate type declarations (blocks ending with }) const duplicateTypes = [...entityNames, "ApiResponse", "PaginatedResponse", "ErrorResponse"]; for (const typeName of duplicateTypes) { // Remove `export interface TypeName ... }` including generics content = content.replace( new RegExp(`export interface ${typeName}(<[^>]*>)?\\s*\\{[^}]*\\}\\n\\n`, "g"), "" ); } // Clean extra blank lines content = content.replace(/\n{3,}/g, "\n\n"); // Build re-export list dynamically from schema const reExports = [...entityNames]; const reExportLines = reExports.map(n => ` ${n},`).join("\n"); // Use explicit import + re-export pattern for reliable TS resolution const entityImportNames = reExports.join(",\n "); modified["src/types/index.ts"] = `// Import and re-export shared entity types import type { ${entityImportNames}, ApiResponse, PaginatedResponse, ErrorResponse, } from "@shared/types"; export type { ${entityImportNames}, ApiResponse, PaginatedResponse, ErrorResponse, }; ${content}`; } return modified; } // ═══════════════════════════════════════════════════════ // 8. Main Composer Function // ═══════════════════════════════════════════════════════ /** * Compose a fullstack project from PRD + Architecture. * * @param {object} prd — SF-01 PRD * @param {object} arch — SF-02 Architecture * @returns {object} { files, stats, error, message } */ export async function composeFullstack(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 || "FullstackApp"; const prdSummary = prd.summary || ""; const contract = arch.contract || createContract(prd, { databaseSchema: arch.databaseSchema || [], domain: arch.domain }); // ── Dynamic imports of builder agents ── const fbMod = await import("./frontend-builder-agent.mjs"); const bbMod = await import("./backend-builder-agent.mjs"); // ── Build frontend ── const frontend = fbMod.buildFrontend(prd, arch); if (frontend.error) return { error: "FRONTEND_BUILD_FAILED", message: frontend.message, files: {}, stats: {} }; // ── Build backend ── const backend = bbMod.buildBackend(prd, arch); if (backend.error) return { error: "BACKEND_BUILD_FAILED", message: backend.message, files: {}, stats: {} }; // ── Post-process for monorepo ── // Prefix frontend paths with src/ for Next.js src directory structure const frontendSrc = prefixSrc(frontend.files); const webFiles = postProcessFrontend(frontendSrc, projectName); const apiFiles = postProcessBackend(backend.files, projectName, contract); // ── Shared packages ── const sharedTypesPkg = generateSharedTypesPackage(contract); const sharedConfigPkg = generateSharedConfig(); // ── Root files ── const rootFiles = generateRootFiles(projectName, prdSummary, contract); // ── Scripts ── const scripts = generateScripts(); // ══ Assemble final file tree ══ const files = {}; // apps/web/ (frontend) for (const [relPath, content] of Object.entries(webFiles)) { files[`apps/web/${relPath}`] = content; } // apps/api/ (backend) for (const [relPath, content] of Object.entries(apiFiles)) { files[`apps/api/${relPath}`] = content; } // packages/shared-types/ for (const [relPath, content] of Object.entries(sharedTypesPkg)) { files[`packages/shared-types/${relPath}`] = content; } // packages/shared-config/ for (const [relPath, content] of Object.entries(sharedConfigPkg)) { files[`packages/shared-config/${relPath}`] = content; } // Root files for (const [relPath, content] of Object.entries(rootFiles)) { files[relPath] = content; } // Scripts for (const [relPath, content] of Object.entries(scripts)) { files[relPath] = content; } // ══ Stats ══ const allPaths = Object.keys(files); const webPaths = allPaths.filter(p => p.startsWith("apps/web/")); const apiPaths = allPaths.filter(p => p.startsWith("apps/api/")); const sharedPaths = allPaths.filter(p => p.startsWith("packages/")); const rootPaths = allPaths.filter(p => !p.includes("/")); const stats = { totalFiles: allPaths.length, webFiles: webPaths.length, apiFiles: apiPaths.length, sharedFiles: sharedPaths.length, rootFiles: rootPaths.length, dbSchema: contract.entities.length, }; return { files, stats, error: null, message: null }; } // ═══════════════════════════════════════════════════════ // 9. 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 writeFullstack(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); } } /** * Prefix frontend source files with src/ for Next.js src directory structure. * Frontend builder outputs flat paths (app/layout.tsx, types/index.ts), * but monorepo structure uses apps/web/src/ prefix. * Config files (package.json, tsconfig.json, next.config.*, tailwind.config.*, * postcss.config.*, .env*) stay at root level. * * @param {object} raw — Frontend builder output paths (e.g. "app/layout.tsx") * @returns {object} — Source paths prefixed, config paths kept at root */ function prefixSrc(raw) { const CONFIG_FILES = /^(package\.json|tsconfig\.json|next\.config\.[a-z]+|tailwind\.config\.[a-z]+|postcss\.config\.[a-z]+|\.[a-z-]+)$/; const result = {}; for (const [relPath, content] of Object.entries(raw)) { if (relPath.startsWith("src/")) { // Already prefixed — keep as-is result[relPath] = content; } else if (CONFIG_FILES.test(relPath)) { // Config files — keep at root result[relPath] = content; } else { // Source code — prefix with src/ result[`src/${relPath}`] = content; } } return result; } // ═══════════════════════════════════════════════════════ // 10. 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(` Fullstack Composer Agent — SF-05 Usage: node scripts/fullstack-composer-agent.mjs --prd --arch [options] node scripts/fullstack-composer-agent.mjs --input-text "<需求>" [options] Options: --prd PRD JSON(SF-01 输出) --arch Architecture JSON(SF-02 输出) --input-text 直接传入需求 --output 输出目录(default: fullstack/) --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 = await composeFullstack(prd, arch); if (result.error) { console.error(result.message); process.exit(1); } const outputDir = opts.output ? resolve(opts.output) : DEFAULT_OUTPUT; writeFullstack(result, outputDir); if (opts.verbose) { console.error(`Project: ${prd.projectName}`); console.error(`Files: ${result.stats.totalFiles}`); console.error(`Web: ${result.stats.webFiles}, API: ${result.stats.apiFiles}, Shared: ${result.stats.sharedFiles}`); } 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(); }