feat(cli): add create command with interactive block selection
Publish to Gitea Registry / publish (push) Failing after 1m32s

- register create command in Commander program
- add @inquirer/prompts dependency for interactive prompts
- extend Block type with optional repo field
- scaffold create command module with initial implementation
This commit is contained in:
yangzhaohan
2026-06-22 17:19:10 +08:00
parent 732f481462
commit 6b19fd4601
8 changed files with 1074 additions and 2 deletions
+131
View File
@@ -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<string, string> = {
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<string, unknown>;
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<string, unknown>;
return { path: String(ex.path ?? ""), note: String(ex.note ?? "") };
})
: [],
};
}
export async function compileAiRules(
blockDirs: { name: string; dir: string }[],
targetDir: string,
aiTool: string
): Promise<void> {
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<string | null> {
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");
}
+51
View File
@@ -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<string> {
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<void> {
await rm(dir, { recursive: true, force: true });
}
+111
View File
@@ -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<string, string>
): Promise<void> {
const templateDir = join(sourceDir, "template");
if (!existsSync(templateDir)) {
process.stderr.write(`Error: template/ not found in ${sourceDir}\n`);
process.exit(1);
}
const varsToReplace: Record<string, string> = {
...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<string, string>
): Promise<void> {
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<string, string>
): Promise<void> {
// 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;
}