🎉 init: 小龙的工作空间
This commit is contained in:
@@ -0,0 +1,764 @@
|
||||
#!/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();
|
||||
}
|
||||
Reference in New Issue
Block a user