feat(cli): add create command with interactive block selection
Publish to Gitea Registry / publish (push) Failing after 1m32s
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:
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user