Files
16gagent/scripts/fullstack-composer-agent.mjs
T
2026-06-06 10:40:48 +08:00

765 lines
26 KiB
JavaScript
Raw 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
/**
* 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 <path> --arch <path> [options]
* node scripts/fullstack-composer-agent.mjs --input-text "<需求>" [options]
*
* Options:
* --prd <path> PRD JSONSF-01 输出)
* --arch <path> Architecture JSONSF-02 输出)
* --input-text <text> 直接传入需求
* --output <dir> 输出目录(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<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(``);
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 <path> --arch <path> [options]
node scripts/fullstack-composer-agent.mjs --input-text "<需求>" [options]
Options:
--prd <path> PRD JSONSF-01 输出)
--arch <path> Architecture JSONSF-02 输出)
--input-text <text> 直接传入需求
--output <dir> 输出目录(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();
}