feat(cli): implement create command with interactive block selection

- Add interactive prompts for project name, block selection, and variant/branch choice
- Support multiple block selection with scoped dependency ordering
- Clone blocks into project structure using git sparse checkout
- Apply Mustache-style template variable substitution
- Compile and merge AI rules (conventions.md, module.yaml) per block
- Integrate with block index, git utilities, and template engine
This commit is contained in:
yangzhaohan
2026-06-23 08:38:03 +08:00
parent 6b19fd4601
commit 4470f71d7f
2 changed files with 349 additions and 216 deletions
+228 -102
View File
@@ -1,7 +1,7 @@
import { Command } from "commander"; import { Command } from "commander";
import { checkbox, select, input } from "@inquirer/prompts"; import { checkbox, select, input } from "@inquirer/prompts";
import { resolve, basename } from "node:path"; import { resolve } from "node:path";
import { writeFile } from "node:fs/promises"; import { writeFile, mkdir } from "node:fs/promises";
import { resolveSource } from "../lib/config.js"; import { resolveSource } from "../lib/config.js";
import { loadIndex } from "../lib/index-loader.js"; import { loadIndex } from "../lib/index-loader.js";
import { inferRepoUrl, cloneBlock, cleanupTemp } from "../lib/git.js"; import { inferRepoUrl, cloneBlock, cleanupTemp } from "../lib/git.js";
@@ -15,13 +15,11 @@ interface SelectedBlock {
variantLabel: string; variantLabel: string;
} }
const STYLES: { value: string; label: string }[] = [ const APP_STYLES: { value: string; label: string }[] = [
{ value: "standard", label: "标准多模块 — 根 pom.xml 聚合所有积木子模块" }, { value: "mvc", label: "MVC 经典三层 — controller / service / mapper" },
{ { value: "ddd", label: "DDD 四层 — interfaces / application / domain / infrastructure" },
value: "bom", { value: "biz-api", label: "biz/api 分离 — api 接口层 + biz 实现层" },
label: "BOM 模式 — 根 pom + bom 子模块统一依赖版本管理", { value: "simple", label: "极简 — 只生成 Application 入口类" },
},
{ value: "simple", label: "简单模式 — 不生成根 pom,积木模块平铺" },
]; ];
const AI_TOOLS: { value: string; label: string }[] = [ const AI_TOOLS: { value: string; label: string }[] = [
@@ -54,8 +52,8 @@ async function runCreate(projectName: string): Promise<void> {
const cwd = process.cwd(); const cwd = process.cwd();
// 1. Load index // 1. Load index
console.log("[1/8] 拉取积木索引...");
const source = resolveSource(); const source = resolveSource();
console.log(`[1/7] 拉取积木索引...`);
const index = await loadIndex(source); const index = await loadIndex(source);
if (index.blocks.length === 0) { if (index.blocks.length === 0) {
@@ -63,10 +61,9 @@ async function runCreate(projectName: string): Promise<void> {
return; return;
} }
// 2. Interactive prompts
// 2a. Select blocks // 2a. Select blocks
const chosenNames = await checkbox({ const chosenNames = await checkbox({
message: "选择要集成的积木(空格选择,回车确认):", message: "选择要集成的通用模块:",
choices: index.blocks.map((b) => ({ choices: index.blocks.map((b) => ({
name: `${b.name}${b.description}`, name: `${b.name}${b.description}`,
value: b.name, value: b.name,
@@ -79,11 +76,9 @@ async function runCreate(projectName: string): Promise<void> {
return; return;
} }
const chosenBlocks = index.blocks.filter((b) => const chosenBlocks = index.blocks.filter((b) => chosenNames.includes(b.name));
chosenNames.includes(b.name)
);
// 2b. Resolve dependencies (auto-add missing) // 2b. Resolve dependencies
const resolved = resolveDeps(chosenBlocks, index.blocks); const resolved = resolveDeps(chosenBlocks, index.blocks);
if (resolved.added.length > 0) { if (resolved.added.length > 0) {
console.log( console.log(
@@ -91,7 +86,7 @@ async function runCreate(projectName: string): Promise<void> {
); );
} }
// 2c. Select variant for blocks with multiple variants // 2c. Select variants
const selected: SelectedBlock[] = []; const selected: SelectedBlock[] = [];
for (const block of resolved.all) { for (const block of resolved.all) {
let branch = block.defaultBranch; let branch = block.defaultBranch;
@@ -107,18 +102,17 @@ async function runCreate(projectName: string): Promise<void> {
default: block.defaultBranch, default: block.defaultBranch,
}); });
branch = chosen; branch = chosen;
variantLabel = variantLabel = block.variants.find((v) => v.branch === chosen)?.label ?? chosen;
block.variants.find((v) => v.branch === chosen)?.label ?? chosen;
} }
selected.push({ block, branch, variantLabel }); selected.push({ block, branch, variantLabel });
} }
// 2d. Project style // 2d. Business module style
const style = await select({ const appStyle = await select({
message: "选择工程风格:", message: "选择业务模块风格:",
choices: STYLES, choices: APP_STYLES,
default: "standard", default: "mvc",
}); });
// 2e. Variables // 2e. Variables
@@ -149,8 +143,8 @@ async function runCreate(projectName: string): Promise<void> {
// Summary // Summary
console.log("\n--- 配置摘要 ---"); console.log("\n--- 配置摘要 ---");
console.log(` 项目名: ${projectName}`); console.log(` 项目名: ${projectName}`);
console.log(` 积木: ${selected.map((s) => `${s.block.name} (${s.variantLabel})`).join(", ")}`); console.log(` 通用模块: ${selected.map((s) => `${s.block.name} (${s.variantLabel})`).join(", ")}`);
console.log(` 工程风格: ${STYLES.find((s) => s.value === style)?.label}`); console.log(` 业务模块风格: ${APP_STYLES.find((s) => s.value === appStyle)?.label}`);
console.log(` 基础包名: ${basePackage}`); console.log(` 基础包名: ${basePackage}`);
console.log(` groupId: ${groupId}`); console.log(` groupId: ${groupId}`);
console.log(` Java 版本: ${javaVersion}`); console.log(` Java 版本: ${javaVersion}`);
@@ -158,7 +152,7 @@ async function runCreate(projectName: string): Promise<void> {
console.log("-----------------\n"); console.log("-----------------\n");
// 3. Clone blocks // 3. Clone blocks
console.log("[2/7] 拉取积木仓库..."); console.log("[2/8] 拉取积木仓库...");
const blockDirs: { name: string; dir: string }[] = []; const blockDirs: { name: string; dir: string }[] = [];
for (const sel of selected) { for (const sel of selected) {
@@ -168,9 +162,10 @@ async function runCreate(projectName: string): Promise<void> {
blockDirs.push({ name: sel.block.name, dir }); blockDirs.push({ name: sel.block.name, dir });
} }
// 4. Apply templates // 4. Apply templates — common modules under demo-common/
console.log("[3/7] 生成项目源码..."); console.log("[3/8] 生成通用模块...");
const targetDir = resolve(cwd, projectName); const targetDir = resolve(cwd, projectName);
const commonDir = resolve(targetDir, `${projectName}-common`);
const vars: Record<string, string> = { const vars: Record<string, string> = {
basePackage, basePackage,
groupId, groupId,
@@ -178,35 +173,50 @@ async function runCreate(projectName: string): Promise<void> {
javaVersion, javaVersion,
}; };
for (const bd of blockDirs) { const commonModules: string[] = [];
await applyTemplate(bd.dir, targetDir, vars);
for (const sel of selected) {
const moduleName = `${projectName}-common-${sel.block.module}`;
const moduleDir = resolve(commonDir, moduleName);
const bd = blockDirs.find((d) => d.name === sel.block.name)!;
await applyTemplate(bd.dir, moduleDir, vars);
commonModules.push(moduleName);
} }
// 5. Generate AI rules // 5. Generate demo-common/pom.xml
console.log("[4/7] 生成 AI 规则文件..."); console.log("[4/8] 生成 common 父 POM...");
await mkdir(commonDir, { recursive: true });
await writeFile(
resolve(commonDir, "pom.xml"),
buildCommonParentPom(projectName, groupId, javaVersion, commonModules),
"utf-8"
);
// 6. Generate demo-boot/
console.log("[5/8] 生成业务模块...");
const bootDir = resolve(targetDir, `${projectName}-boot`);
await generateBootModule(bootDir, projectName, groupId, basePackage, javaVersion, appStyle, commonModules);
// 7. Generate AI rules
console.log("[6/8] 生成 AI 规则文件...");
await compileAiRules(blockDirs, targetDir, aiTool); await compileAiRules(blockDirs, targetDir, aiTool);
// 6. Generate root pom.xml // 8. Generate root pom.xml
console.log("[5/7] 生成根 pom.xml..."); console.log("[7/8] 生成根 pom.xml...");
if (style !== "simple") { await writeFile(
const rootPom = buildRootPom( resolve(targetDir, "pom.xml"),
projectName, buildRootPom(projectName, groupId, javaVersion),
groupId, "utf-8"
javaVersion,
selected,
style
); );
await writeFile(resolve(targetDir, "pom.xml"), rootPom, "utf-8");
}
// 7. Generate brick.json // 9. Generate brick.json
console.log("[6/7] 生成 brick.json..."); console.log("[8/8] 生成 brick.json...");
const brickJson: BrickJson = { const brickJson: BrickJson = {
projectName, projectName,
basePackage, basePackage,
javaVersion, javaVersion,
aiTool, aiTool,
style, style: appStyle,
blocks: selected.map((s) => ({ blocks: selected.map((s) => ({
name: s.block.name, name: s.block.name,
variant: s.variantLabel, variant: s.variantLabel,
@@ -219,8 +229,7 @@ async function runCreate(projectName: string): Promise<void> {
"utf-8" "utf-8"
); );
// 8. Cleanup temp dirs // Cleanup
console.log("[7/7] 清理临时文件...");
for (const bd of blockDirs) { for (const bd of blockDirs) {
await cleanupTemp(bd.dir); await cleanupTemp(bd.dir);
} }
@@ -237,7 +246,6 @@ function resolveDeps(
const result = new Map(chosen.map((b) => [b.name, b])); const result = new Map(chosen.map((b) => [b.name, b]));
const added: Block[] = []; const added: Block[] = [];
// Iteratively resolve dependencies
let changed = true; let changed = true;
while (changed) { while (changed) {
changed = false; changed = false;
@@ -262,18 +270,13 @@ function resolveDeps(
return { all: [...result.values()], added }; return { all: [...result.values()], added };
} }
// ── POM generators ──────────────────────────────────────────
function buildRootPom( function buildRootPom(
projectName: string, projectName: string,
groupId: string, groupId: string,
javaVersion: string, javaVersion: string
selected: SelectedBlock[],
style: string
): string { ): string {
const modules = selected
.map((s) => ` <module>${projectName}-common-${s.block.module}</module>`)
.join("\n");
if (style === "bom") {
return [ return [
'<?xml version="1.0" encoding="UTF-8"?>', '<?xml version="1.0" encoding="UTF-8"?>',
'<project xmlns="http://maven.apache.org/POM/4.0.0"', '<project xmlns="http://maven.apache.org/POM/4.0.0"',
@@ -290,54 +293,177 @@ function buildRootPom(
"", "",
" <properties>", " <properties>",
` <java.version>${javaVersion}</java.version>`, ` <java.version>${javaVersion}</java.version>`,
` <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>`, ' <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>',
` <brick.version>0.0.0</brick.version>`,
" </properties>",
"",
" <dependencyManagement>",
" <dependencies>",
...selected.map(
(s) =>
` <dependency>\n` +
` <groupId>${groupId}</groupId>\n` +
` <artifactId>${projectName}-common-${s.block.module}</artifactId>\n` +
` <version>${"$"}{brick.version}</version>\n` +
` </dependency>`
),
" </dependencies>",
" </dependencyManagement>",
"",
" <modules>",
` <module>${projectName}-common-bom</module>`,
modules,
" </modules>",
"</project>",
].join("\n");
}
// standard
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<project xmlns="http://maven.apache.org/POM/4.0.0"',
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
' xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">',
' <modelVersion>4.0.0</modelVersion>',
"",
` <groupId>${groupId}</groupId>`,
` <artifactId>${projectName}</artifactId>`,
" <version>0.0.0</version>",
" <packaging>pom</packaging>",
"",
` <name>${projectName}</name>`,
"",
" <properties>",
` <java.version>${javaVersion}</java.version>`,
` <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>`,
" </properties>", " </properties>",
"", "",
" <modules>", " <modules>",
modules, ` <module>${projectName}-common</module>`,
` <module>${projectName}-boot</module>`,
" </modules>", " </modules>",
"</project>", "</project>",
].join("\n"); ].join("\n");
} }
function buildCommonParentPom(
projectName: string,
groupId: string,
javaVersion: string,
modules: string[]
): string {
const moduleEntries = modules
.map((m) => ` <module>${m}</module>`)
.join("\n");
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<project xmlns="http://maven.apache.org/POM/4.0.0"',
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
' xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">',
' <modelVersion>4.0.0</modelVersion>',
"",
" <parent>",
" <groupId>org.springframework.boot</groupId>",
" <artifactId>spring-boot-starter-parent</artifactId>",
" <version>3.4.0</version>",
" <relativePath/>",
" </parent>",
"",
` <groupId>${groupId}</groupId>`,
` <artifactId>${projectName}-common</artifactId>`,
" <version>0.0.0</version>",
" <packaging>pom</packaging>",
"",
" <properties>",
` <java.version>${javaVersion}</java.version>`,
" </properties>",
"",
" <modules>",
moduleEntries,
" </modules>",
"</project>",
].join("\n");
}
function buildBootPom(
projectName: string,
groupId: string,
javaVersion: string,
commonModules: string[]
): string {
const deps = commonModules
.map(
(m) =>
" <dependency>\n" +
` <groupId>${groupId}</groupId>\n` +
` <artifactId>${m}</artifactId>\n` +
" <version>0.0.0</version>\n" +
" </dependency>"
)
.join("\n");
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<project xmlns="http://maven.apache.org/POM/4.0.0"',
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
' xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">',
' <modelVersion>4.0.0</modelVersion>',
"",
" <parent>",
" <groupId>org.springframework.boot</groupId>",
" <artifactId>spring-boot-starter-parent</artifactId>",
" <version>3.4.0</version>",
" <relativePath/>",
" </parent>",
"",
` <groupId>${groupId}</groupId>`,
` <artifactId>${projectName}-boot</artifactId>`,
" <version>0.0.0</version>",
" <packaging>jar</packaging>",
"",
" <properties>",
` <java.version>${javaVersion}</java.version>`,
" </properties>",
"",
" <dependencies>",
" <dependency>",
" <groupId>org.springframework.boot</groupId>",
" <artifactId>spring-boot-starter-web</artifactId>",
" </dependency>",
deps,
" </dependencies>",
"",
" <build>",
" <plugins>",
" <plugin>",
" <groupId>org.springframework.boot</groupId>",
" <artifactId>spring-boot-maven-plugin</artifactId>",
" </plugin>",
" </plugins>",
" </build>",
"</project>",
].join("\n");
}
// ── Business module generator ────────────────────────────────
async function generateBootModule(
bootDir: string,
projectName: string,
groupId: string,
basePackage: string,
javaVersion: string,
appStyle: string,
commonModules: string[]
): Promise<void> {
const pkgPath = basePackage.replace(/\./g, "/");
const srcDir = resolve(bootDir, "src", "main", "java", pkgPath);
await mkdir(srcDir, { recursive: true });
// Generate style-specific packages (placeholder .gitkeep)
const styleDirs: string[] = [];
switch (appStyle) {
case "mvc":
styleDirs.push("controller", "service", "mapper");
break;
case "ddd":
styleDirs.push("interfaces", "application", "domain", "infrastructure");
break;
case "biz-api":
styleDirs.push("api", "biz");
break;
// simple: no extra dirs
}
for (const d of styleDirs) {
await mkdir(resolve(srcDir, d), { recursive: true });
}
// Generate Application.java
const appName = projectName.charAt(0).toUpperCase() + projectName.slice(1) + "Application";
await writeFile(
resolve(srcDir, `${appName}.java`),
[
`package ${basePackage};`,
"",
"import org.springframework.boot.SpringApplication;",
"import org.springframework.boot.autoconfigure.SpringBootApplication;",
"",
"@SpringBootApplication",
`public class ${appName} {`,
"",
` public static void main(String[] args) {`,
` SpringApplication.run(${appName}.class, args);`,
" }",
"}",
].join("\n"),
"utf-8"
);
// Generate pom.xml
await writeFile(
resolve(bootDir, "pom.xml"),
buildBootPom(projectName, groupId, javaVersion, commonModules),
"utf-8"
);
}
+10 -3
View File
@@ -1,4 +1,4 @@
import { cp, readFile, writeFile, rename, readdir, mkdir } from "node:fs/promises"; import { cp, readFile, writeFile, readdir, mkdir, rm } from "node:fs/promises";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
@@ -82,8 +82,15 @@ async function renamePlaceholders(
if (newName !== oldName) { if (newName !== oldName) {
const dest = join(parent, newName); const dest = join(parent, newName);
await mkdir(dirname(dest), { recursive: true }); await mkdir(dest, { recursive: true });
await rename(path, dest); // Copy children to new location, then remove old dir
const children = await readdir(path, { withFileTypes: true });
for (const child of children) {
const srcChild = join(path, child.name);
const dstChild = join(dest, child.name);
await cp(srcChild, dstChild, { recursive: true });
}
await rm(path, { recursive: true, force: true });
} }
} }
} }