diff --git a/package-lock.json b/package-lock.json index df1e0b3..8c11318 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { + "@inquirer/prompts": "^8.5.2", "commander": "^15.0.0", "js-yaml": "^5.0.0" }, @@ -24,6 +25,334 @@ "node": ">=18" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "http://192.168.5.47:4873/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.1", + "resolved": "http://192.168.5.47:4873/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "http://192.168.5.47:4873/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "http://192.168.5.47:4873/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.2.2", + "resolved": "http://192.168.5.47:4873/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.1", + "resolved": "http://192.168.5.47:4873/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.3", + "resolved": "http://192.168.5.47:4873/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "http://192.168.5.47:4873/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "resolved": "http://192.168.5.47:4873/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.1", + "resolved": "http://192.168.5.47:4873/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.1", + "resolved": "http://192.168.5.47:4873/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "resolved": "http://192.168.5.47:4873/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "resolved": "http://192.168.5.47:4873/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.1", + "resolved": "http://192.168.5.47:4873/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.1", + "resolved": "http://192.168.5.47:4873/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "http://192.168.5.47:4873/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "http://192.168.5.47:4873/@types/js-yaml/-/js-yaml-4.0.9.tgz", @@ -35,7 +364,7 @@ "version": "22.19.21", "resolved": "http://192.168.5.47:4873/@types/node/-/node-22.19.21.tgz", "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -47,6 +376,21 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "http://192.168.5.47:4873/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "http://192.168.5.47:4873/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/commander": { "version": "15.0.0", "resolved": "http://192.168.5.47:4873/commander/-/commander-15.0.0.tgz", @@ -56,6 +400,46 @@ "node": ">=22.12.0" } }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "http://192.168.5.47:4873/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "http://192.168.5.47:4873/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "http://192.168.5.47:4873/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "http://192.168.5.47:4873/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/js-yaml": { "version": "5.0.0", "resolved": "http://192.168.5.47:4873/js-yaml/-/js-yaml-5.0.0.tgz", @@ -78,6 +462,33 @@ "js-yaml": "bin/js-yaml.mjs" } }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "http://192.168.5.47:4873/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "http://192.168.5.47:4873/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "http://192.168.5.47:4873/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "http://192.168.5.47:4873/typescript/-/typescript-5.9.3.tgz", @@ -96,7 +507,7 @@ "version": "6.21.0", "resolved": "http://192.168.5.47:4873/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" } } diff --git a/package.json b/package.json index eb8884f..9640cd6 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "license": "MIT", "dependencies": { + "@inquirer/prompts": "^8.5.2", "commander": "^15.0.0", "js-yaml": "^5.0.0" } diff --git a/src/commands/create.ts b/src/commands/create.ts new file mode 100644 index 0000000..a9d6d6a --- /dev/null +++ b/src/commands/create.ts @@ -0,0 +1,343 @@ +import { Command } from "commander"; +import { checkbox, select, input } from "@inquirer/prompts"; +import { resolve, basename } from "node:path"; +import { writeFile } from "node:fs/promises"; +import { resolveSource } from "../lib/config.js"; +import { loadIndex } from "../lib/index-loader.js"; +import { inferRepoUrl, cloneBlock, cleanupTemp } from "../lib/git.js"; +import { applyTemplate } from "../lib/template.js"; +import { compileAiRules } from "../lib/ai-compiler.js"; +import type { Block, BrickJson } from "../types/index.js"; + +interface SelectedBlock { + block: Block; + branch: string; + variantLabel: string; +} + +const STYLES: { value: string; label: string }[] = [ + { value: "standard", label: "标准多模块 — 根 pom.xml 聚合所有积木子模块" }, + { + value: "bom", + label: "BOM 模式 — 根 pom + bom 子模块统一依赖版本管理", + }, + { value: "simple", label: "简单模式 — 不生成根 pom,积木模块平铺" }, +]; + +const AI_TOOLS: { value: string; label: string }[] = [ + { value: "claude", label: "Claude Code" }, + { value: "cursor", label: "Cursor" }, + { value: "copilot", label: "GitHub Copilot" }, + { value: "aider", label: "Aider" }, + { value: "generic", label: "通用(AI_CONTEXT.md)" }, +]; + +export function registerCreate(program: Command): void { + program + .command("create ") + .description("创建新项目") + .action(async (projectName: string) => { + try { + await runCreate(projectName); + } catch (err: unknown) { + const msg = (err as Error).message ?? ""; + if (msg.includes("User force closed") || msg.includes("ExitPromptError")) { + console.log("\n已取消。"); + process.exit(0); + } + throw err; + } + }); +} + +async function runCreate(projectName: string): Promise { + const cwd = process.cwd(); + + // 1. Load index + const source = resolveSource(); + console.log(`[1/7] 拉取积木索引...`); + const index = await loadIndex(source); + + if (index.blocks.length === 0) { + console.log("没有可用的积木。"); + return; + } + + // 2. Interactive prompts + // 2a. Select blocks + const chosenNames = await checkbox({ + message: "选择要集成的积木(空格选择,回车确认):", + choices: index.blocks.map((b) => ({ + name: `${b.name} — ${b.description}`, + value: b.name, + checked: true, + })), + }); + + if (chosenNames.length === 0) { + console.log("未选择任何积木,已取消。"); + return; + } + + const chosenBlocks = index.blocks.filter((b) => + chosenNames.includes(b.name) + ); + + // 2b. Resolve dependencies (auto-add missing) + const resolved = resolveDeps(chosenBlocks, index.blocks); + if (resolved.added.length > 0) { + console.log( + ` 自动补全依赖:${resolved.added.map((b) => b.name).join(", ")}` + ); + } + + // 2c. Select variant for blocks with multiple variants + const selected: SelectedBlock[] = []; + for (const block of resolved.all) { + let branch = block.defaultBranch; + let variantLabel = block.defaultBranch; + + if (block.variants.length > 1) { + const chosen = await select({ + message: `为 ${block.name} 选择变体:`, + choices: block.variants.map((v) => ({ + name: `${v.label} (${v.branch})`, + value: v.branch, + })), + default: block.defaultBranch, + }); + branch = chosen; + variantLabel = + block.variants.find((v) => v.branch === chosen)?.label ?? chosen; + } + + selected.push({ block, branch, variantLabel }); + } + + // 2d. Project style + const style = await select({ + message: "选择工程风格:", + choices: STYLES, + default: "standard", + }); + + // 2e. Variables + const defaultPkg = `com.example.${projectName.replace(/[^a-zA-Z0-9]/g, "")}`; + + const basePackage = await input({ + message: "基础包名 (basePackage):", + default: defaultPkg, + }); + + const groupId = await input({ + message: "Maven groupId:", + default: basePackage, + }); + + const javaVersion = await input({ + message: "Java 版本:", + default: "17", + }); + + // 2f. AI tool + const aiTool = await select({ + message: "选择 AI 工具:", + choices: AI_TOOLS, + default: "claude", + }); + + // Summary + console.log("\n--- 配置摘要 ---"); + console.log(` 项目名: ${projectName}`); + console.log(` 积木: ${selected.map((s) => `${s.block.name} (${s.variantLabel})`).join(", ")}`); + console.log(` 工程风格: ${STYLES.find((s) => s.value === style)?.label}`); + console.log(` 基础包名: ${basePackage}`); + console.log(` groupId: ${groupId}`); + console.log(` Java 版本: ${javaVersion}`); + console.log(` AI 工具: ${AI_TOOLS.find((t) => t.value === aiTool)?.label}`); + console.log("-----------------\n"); + + // 3. Clone blocks + console.log("[2/7] 拉取积木仓库..."); + const blockDirs: { name: string; dir: string }[] = []; + + for (const sel of selected) { + const url = inferRepoUrl(sel.block.name, sel.block.repo); + console.log(` clone ${url} (branch: ${sel.branch})...`); + const dir = await cloneBlock(url, sel.branch); + blockDirs.push({ name: sel.block.name, dir }); + } + + // 4. Apply templates + console.log("[3/7] 生成项目源码..."); + const targetDir = resolve(cwd, projectName); + const vars: Record = { + basePackage, + groupId, + projectName, + javaVersion, + }; + + for (const bd of blockDirs) { + await applyTemplate(bd.dir, targetDir, vars); + } + + // 5. Generate AI rules + console.log("[4/7] 生成 AI 规则文件..."); + await compileAiRules(blockDirs, targetDir, aiTool); + + // 6. Generate root pom.xml + console.log("[5/7] 生成根 pom.xml..."); + if (style !== "simple") { + const rootPom = buildRootPom( + projectName, + groupId, + javaVersion, + selected, + style + ); + await writeFile(resolve(targetDir, "pom.xml"), rootPom, "utf-8"); + } + + // 7. Generate brick.json + console.log("[6/7] 生成 brick.json..."); + const brickJson: BrickJson = { + projectName, + basePackage, + javaVersion, + aiTool, + style, + blocks: selected.map((s) => ({ + name: s.block.name, + variant: s.variantLabel, + version: s.block.version, + })), + }; + await writeFile( + resolve(targetDir, "brick.json"), + JSON.stringify(brickJson, null, 2), + "utf-8" + ); + + // 8. Cleanup temp dirs + console.log("[7/7] 清理临时文件..."); + for (const bd of blockDirs) { + await cleanupTemp(bd.dir); + } + + console.log(`\n项目已创建:${targetDir}`); + console.log("Done!"); +} + +function resolveDeps( + chosen: Block[], + all: Block[] +): { all: Block[]; added: Block[] } { + const blockMap = new Map(all.map((b) => [b.name, b])); + const result = new Map(chosen.map((b) => [b.name, b])); + const added: Block[] = []; + + // Iteratively resolve dependencies + let changed = true; + while (changed) { + changed = false; + for (const block of [...result.values()]) { + for (const depName of block.dependsOn) { + if (!result.has(depName)) { + const dep = blockMap.get(depName); + if (dep) { + result.set(depName, dep); + added.push(dep); + changed = true; + } else { + process.stderr.write( + `Warning: Dependency ${depName} not found in index\n` + ); + } + } + } + } + } + + return { all: [...result.values()], added }; +} + +function buildRootPom( + projectName: string, + groupId: string, + javaVersion: string, + selected: SelectedBlock[], + style: string +): string { + const modules = selected + .map((s) => ` ${projectName}-common-${s.block.module}`) + .join("\n"); + + if (style === "bom") { + return [ + '', + '', + ' 4.0.0', + "", + ` ${groupId}`, + ` ${projectName}`, + " 0.0.0", + " pom", + "", + ` ${projectName}`, + "", + " ", + ` ${javaVersion}`, + ` UTF-8`, + ` 0.0.0`, + " ", + "", + " ", + " ", + ...selected.map( + (s) => + ` \n` + + ` ${groupId}\n` + + ` ${projectName}-common-${s.block.module}\n` + + ` ${"$"}{brick.version}\n` + + ` ` + ), + " ", + " ", + "", + " ", + ` ${projectName}-common-bom`, + modules, + " ", + "", + ].join("\n"); + } + + // standard + return [ + '', + '', + ' 4.0.0', + "", + ` ${groupId}`, + ` ${projectName}`, + " 0.0.0", + " pom", + "", + ` ${projectName}`, + "", + " ", + ` ${javaVersion}`, + ` UTF-8`, + " ", + "", + " ", + modules, + " ", + "", + ].join("\n"); +} diff --git a/src/index.ts b/src/index.ts index 9db2718..c4089c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import { registerList } from "./commands/list.js"; +import { registerCreate } from "./commands/create.js"; const program = new Command(); @@ -10,5 +11,6 @@ program .description("Brick CLI — 源码级 Spring Boot 脚手架"); registerList(program); +registerCreate(program); program.parse(); diff --git a/src/lib/ai-compiler.ts b/src/lib/ai-compiler.ts new file mode 100644 index 0000000..06cbc5b --- /dev/null +++ b/src/lib/ai-compiler.ts @@ -0,0 +1,131 @@ +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { load } from "js-yaml"; +import { existsSync } from "node:fs"; +import type { AiModule } from "../types/index.js"; + +const AI_FILE_MAP: Record = { + claude: "CLAUDE.md", + cursor: ".cursorrules", + copilot: ".github/copilot-instructions.md", + aider: "CONVENTIONS.md", + generic: "AI_CONTEXT.md", +}; + +function parseAiModule(raw: string): AiModule { + const data = load(raw) as Record; + return { + module: String(data.module ?? ""), + summary: String(data.summary ?? ""), + dos: Array.isArray(data.dos) ? data.dos.map(String) : [], + donts: Array.isArray(data.donts) ? data.donts.map(String) : [], + examples: Array.isArray(data.examples) + ? data.examples.map((e: unknown) => { + const ex = e as Record; + return { path: String(ex.path ?? ""), note: String(ex.note ?? "") }; + }) + : [], + }; +} + +export async function compileAiRules( + blockDirs: { name: string; dir: string }[], + targetDir: string, + aiTool: string +): Promise { + const compiled = await buildAiContent(blockDirs); + if (!compiled) return; + + const fileName = AI_FILE_MAP[aiTool] ?? AI_FILE_MAP.generic; + const filePath = join(targetDir, fileName); + + const dir = dirname(filePath); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + + await writeFile(filePath, compiled, "utf-8"); +} + +async function buildAiContent( + blockDirs: { name: string; dir: string }[] +): Promise { + const sections: string[] = []; + + sections.push("# 项目 AI 编码规范\n"); + sections.push( + "本文件由 Brick CLI 自动生成,整合了以下积木的 AI 规范。\n" + ); + + const moduleList = blockDirs.map((b) => `- ${b.name}`).join("\n"); + sections.push(`## 项目模块\n\n${moduleList}\n`); + + const allDos: string[] = []; + const allDonts: string[] = []; + + for (const block of blockDirs) { + const moduleYaml = join(block.dir, "ai", "module.yaml"); + const conventionsMd = join(block.dir, "ai", "conventions.md"); + + if (!existsSync(moduleYaml)) continue; + + try { + const raw = await readFile(moduleYaml, "utf-8"); + const mod = parseAiModule(raw); + + sections.push(`### ${mod.module} — ${mod.summary}\n`); + + if (mod.dos.length > 0) { + sections.push("**DO:**\n"); + for (const d of mod.dos) { + sections.push(`- ${d}`); + allDos.push(`[${mod.module}] ${d}`); + } + sections.push(""); + } + + if (mod.donts.length > 0) { + sections.push("**DON'T:**\n"); + for (const d of mod.donts) { + sections.push(`- ${d}`); + allDonts.push(`[${mod.module}] ${d}`); + } + sections.push(""); + } + } catch { + // module.yaml is optional + } + + // Append conventions.md content if it exists + if (existsSync(conventionsMd)) { + try { + const raw = await readFile(conventionsMd, "utf-8"); + sections.push(raw); + sections.push(""); + } catch { + // conventions.md is optional + } + } + } + + // Global summary + if (allDos.length > 0 || allDonts.length > 0) { + sections.push("## 全局约束 (摘要)\n"); + if (allDos.length > 0) { + sections.push("**必须遵守:**\n"); + for (const d of allDos) { + sections.push(`- ${d}`); + } + sections.push(""); + } + if (allDonts.length > 0) { + sections.push("**严格禁止:**\n"); + for (const d of allDonts) { + sections.push(`- ${d}`); + } + sections.push(""); + } + } + + return sections.join("\n"); +} diff --git a/src/lib/git.ts b/src/lib/git.ts new file mode 100644 index 0000000..7cb9df7 --- /dev/null +++ b/src/lib/git.ts @@ -0,0 +1,51 @@ +import { exec } from "node:child_process"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const GITEA_BASE = "https://gitea.synoth.com/synoth"; + +export function inferRepoUrl(blockName: string, repo?: string): string { + if (repo) return repo; + return `${GITEA_BASE}/${blockName}.git`; +} + +function execAsync(command: string): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + exec(command, (error, stdout, stderr) => { + if (error) reject(error); + else resolve({ stdout, stderr }); + }); + }); +} + +export async function cloneBlock( + repoUrl: string, + branch: string +): Promise { + const dir = join(tmpdir(), `brick-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + + try { + await execAsync( + `git clone --depth 1 --branch "${branch}" "${repoUrl}" "${dir}"` + ); + } catch (err: unknown) { + const msg = (err as Error).message; + if (msg.includes("not found") || msg.includes("does not exist")) { + process.stderr.write( + `Error: Repository or branch not found: ${repoUrl} (branch: ${branch})\n` + ); + } else { + process.stderr.write( + `Error: Failed to clone ${repoUrl}: ${msg}\n` + ); + } + process.exit(1); + } + + return dir; +} + +export async function cleanupTemp(dir: string): Promise { + await rm(dir, { recursive: true, force: true }); +} diff --git a/src/lib/template.ts b/src/lib/template.ts new file mode 100644 index 0000000..e822881 --- /dev/null +++ b/src/lib/template.ts @@ -0,0 +1,111 @@ +import { cp, readFile, writeFile, rename, readdir, mkdir } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { existsSync } from "node:fs"; + +export async function applyTemplate( + sourceDir: string, + targetDir: string, + vars: Record +): Promise { + const templateDir = join(sourceDir, "template"); + + if (!existsSync(templateDir)) { + process.stderr.write(`Error: template/ not found in ${sourceDir}\n`); + process.exit(1); + } + + const varsToReplace: Record = { + ...vars, + basePkgPath: vars.basePackage.replace(/\./g, "/"), + }; + + // Step 1: copy template/ to target + await cp(templateDir, targetDir, { recursive: true }); + + // Step 2: replace variables in all file contents + await replaceInFiles(targetDir, varsToReplace); + + // Step 3: rename directories containing {{basePkgPath}} + await renamePlaceholders(targetDir, varsToReplace); +} + +async function replaceInFiles( + dir: string, + vars: Record +): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + // Skip .git + if (entry.name === ".git") continue; + await replaceInFiles(fullPath, vars); + } else if (entry.isFile()) { + let content = await readFile(fullPath, "utf-8"); + let changed = false; + + for (const [key, value] of Object.entries(vars)) { + const placeholder = `{{${key}}}`; + if (content.includes(placeholder)) { + content = content.replaceAll(placeholder, value); + changed = true; + } + } + + if (changed) { + await writeFile(fullPath, content, "utf-8"); + } + } + } +} + +async function renamePlaceholders( + dir: string, + vars: Record +): Promise { + // Walk directory tree bottom-up to rename {{basePkgPath}} segments + const dirsToRename = await findPlaceholderDirs(dir); + + // Sort by depth descending (deepest first) + dirsToRename.sort((a, b) => b.depth - a.depth); + + for (const { path } of dirsToRename) { + const parent = dirname(path); + const oldName = path.split(/[/\\]/).pop()!; + let newName = oldName; + + for (const [key, value] of Object.entries(vars)) { + newName = newName.replaceAll(`{{${key}}}`, value); + } + + if (newName !== oldName) { + const dest = join(parent, newName); + await mkdir(dirname(dest), { recursive: true }); + await rename(path, dest); + } + } +} + +async function findPlaceholderDirs( + dir: string +): Promise<{ path: string; depth: number }[]> { + const results: { path: string; depth: number }[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === ".git") continue; + + const fullPath = join(dir, entry.name); + const sub = await findPlaceholderDirs(fullPath); + results.push(...sub); + + if (entry.name.includes("{{")) { + const depth = fullPath.replace(/\\/g, "/").split("/").length; + results.push({ path: fullPath, depth }); + } + } + + return results; +} diff --git a/src/types/index.ts b/src/types/index.ts index 7906fc9..34323c1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,6 +10,7 @@ export interface Block { language: string; defaultBranch: string; module: string; + repo?: string; variants: BlockVariant[]; dependsOn: string[]; } @@ -17,3 +18,24 @@ export interface Block { export interface BlockIndex { blocks: Block[]; } + +export interface BrickJson { + projectName: string; + basePackage: string; + javaVersion: string; + aiTool: string; + style: string; + blocks: { + name: string; + variant: string; + version: string; + }[]; +} + +export interface AiModule { + module: string; + summary: string; + dos: string[]; + donts: string[]; + examples: { path: string; note: string }[]; +}