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