🎉 init: 小龙的工作空间

This commit is contained in:
大海
2026-06-06 10:40:48 +08:00
commit a188ee1426
3201 changed files with 231817 additions and 0 deletions
+764
View File
@@ -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 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();
}