Skip to content

Commit 7bc6726

Browse files
committed
feat: add fba-cli add command to register existing FBA projects
Scan an existing project directory to auto-detect backend/frontend subdirectories, infrastructure, database type, and ports, then write `.fba.json` and register the project into the global config. Made-with: Cursor
1 parent 6527900 commit 7bc6726

3 files changed

Lines changed: 361 additions & 0 deletions

File tree

src/commands/add.ts

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
// add.ts — 添加现有 FBA 项目到管理列表
2+
import * as clack from "@clack/prompts";
3+
import chalk from "chalk";
4+
import { basename, join, resolve } from "path";
5+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
6+
import { t } from "../lib/i18n.js";
7+
import {
8+
readGlobalConfig,
9+
addProject,
10+
writeProjectConfig,
11+
} from "../lib/config.js";
12+
import type { ProjectConfig } from "../types/config.js";
13+
import type { DatabaseType } from "../lib/infra.js";
14+
15+
// ─── 项目结构探测 ───
16+
17+
interface DetectResult {
18+
backendName: string | null;
19+
frontendName: string | null;
20+
hasInfra: boolean;
21+
infraServices: string[];
22+
dbType: DatabaseType | null;
23+
serverPort: number | null;
24+
webPort: number | null;
25+
}
26+
27+
function isBackendDir(dir: string): boolean {
28+
return (
29+
existsSync(join(dir, "pyproject.toml")) &&
30+
existsSync(join(dir, "backend")) &&
31+
statSync(join(dir, "backend")).isDirectory()
32+
);
33+
}
34+
35+
function isFrontendDir(dir: string): boolean {
36+
const webAppDir = join(dir, "apps", "web-antdv-next");
37+
return existsSync(webAppDir) && statSync(webAppDir).isDirectory();
38+
}
39+
40+
function parseBackendEnv(
41+
backendDir: string,
42+
): { dbType: DatabaseType | null } {
43+
const envPath = join(backendDir, "backend", ".env");
44+
if (!existsSync(envPath)) return { dbType: null };
45+
46+
try {
47+
const content = readFileSync(envPath, "utf-8");
48+
const dbTypeMatch = content.match(
49+
/DATABASE_TYPE\s*=\s*['"]?(\w+)['"]?/,
50+
);
51+
const dbType =
52+
dbTypeMatch?.[1] === "mysql"
53+
? "mysql"
54+
: dbTypeMatch?.[1] === "postgresql"
55+
? "postgresql"
56+
: null;
57+
return { dbType };
58+
} catch {
59+
return { dbType: null };
60+
}
61+
}
62+
63+
function parseFrontendEnv(
64+
frontendDir: string,
65+
): { webPort: number | null } {
66+
const envPath = join(
67+
frontendDir,
68+
"apps",
69+
"web-antdv-next",
70+
".env.development",
71+
);
72+
if (!existsSync(envPath)) return { webPort: null };
73+
74+
try {
75+
const content = readFileSync(envPath, "utf-8");
76+
const portMatch = content.match(/VITE_PORT\s*=\s*(\d+)/);
77+
return { webPort: portMatch?.[1] ? parseInt(portMatch[1]) : null };
78+
} catch {
79+
return { webPort: null };
80+
}
81+
}
82+
83+
function parseInfraServices(projectDir: string): string[] {
84+
const composePath = join(projectDir, "infra", "docker-compose.yml");
85+
if (!existsSync(composePath)) return [];
86+
87+
try {
88+
const content = readFileSync(composePath, "utf-8");
89+
const services: string[] = [];
90+
if (content.includes("fba-postgres")) services.push("postgres");
91+
if (content.includes("fba-mysql")) services.push("mysql");
92+
if (content.includes("fba-redis")) services.push("redis");
93+
if (content.includes("fba-rabbitmq")) services.push("rabbitmq");
94+
return services;
95+
} catch {
96+
return [];
97+
}
98+
}
99+
100+
function detectProject(projectDir: string): DetectResult {
101+
const result: DetectResult = {
102+
backendName: null,
103+
frontendName: null,
104+
hasInfra: false,
105+
infraServices: [],
106+
dbType: null,
107+
serverPort: null,
108+
webPort: null,
109+
};
110+
111+
// 扫描直接子目录
112+
let entries: string[];
113+
try {
114+
entries = readdirSync(projectDir).filter((name) => {
115+
const full = join(projectDir, name);
116+
return !name.startsWith(".") && statSync(full).isDirectory();
117+
});
118+
} catch {
119+
return result;
120+
}
121+
122+
for (const entry of entries) {
123+
const fullPath = join(projectDir, entry);
124+
125+
if (!result.backendName && isBackendDir(fullPath)) {
126+
result.backendName = entry;
127+
const { dbType } = parseBackendEnv(fullPath);
128+
result.dbType = dbType;
129+
}
130+
131+
if (!result.frontendName && isFrontendDir(fullPath)) {
132+
result.frontendName = entry;
133+
const { webPort } = parseFrontendEnv(fullPath);
134+
result.webPort = webPort;
135+
}
136+
}
137+
138+
// 检测基础设施
139+
const infraDir = join(projectDir, "infra");
140+
if (existsSync(infraDir) && statSync(infraDir).isDirectory()) {
141+
result.hasInfra = true;
142+
result.infraServices = parseInfraServices(projectDir);
143+
}
144+
145+
return result;
146+
}
147+
148+
// ─── 命令入口 ───
149+
150+
export async function addAction() {
151+
clack.intro(chalk.bgCyan(" fba-cli add "));
152+
153+
// 1. 输入项目目录
154+
const projectDirInput = await clack.text({
155+
message: t("addProjectDir"),
156+
placeholder: process.cwd(),
157+
defaultValue: process.cwd(),
158+
validate: (v) => {
159+
const dir = v?.trim() ? resolve(v.trim()) : process.cwd();
160+
if (!existsSync(dir)) return t("projectRootNotExist");
161+
if (!statSync(dir).isDirectory()) return t("projectRootNotDirectory");
162+
return undefined;
163+
},
164+
});
165+
if (clack.isCancel(projectDirInput)) {
166+
clack.outro(chalk.dim("Cancelled"));
167+
return;
168+
}
169+
170+
const projectDir = projectDirInput?.trim()
171+
? resolve(String(projectDirInput).trim())
172+
: process.cwd();
173+
174+
// 检查是否已注册
175+
const globalConfig = readGlobalConfig();
176+
if (globalConfig.projects.some((p) => p.path === projectDir)) {
177+
clack.log.error(chalk.red(t("addAlreadyRegistered")));
178+
clack.outro("");
179+
return;
180+
}
181+
182+
// 2. 扫描项目结构
183+
const spinner = clack.spinner();
184+
spinner.start(t("addScanning"));
185+
const detected = detectProject(projectDir);
186+
spinner.stop(t("addScanning"));
187+
188+
// 3. 验证结果
189+
if (!detected.backendName) {
190+
clack.log.error(chalk.red(t("addNoBackend")));
191+
clack.outro("");
192+
return;
193+
}
194+
if (!detected.frontendName) {
195+
clack.log.error(chalk.red(t("addNoFrontend")));
196+
clack.outro("");
197+
return;
198+
}
199+
200+
// 4. 展示探测结果
201+
clack.log.step(t("addConfirmDetected"));
202+
clack.log.info(
203+
`${chalk.bold(t("addBackendDetected"))}: ${chalk.green(detected.backendName)}`,
204+
);
205+
clack.log.info(
206+
`${chalk.bold(t("addFrontendDetected"))}: ${chalk.green(detected.frontendName)}`,
207+
);
208+
clack.log.info(
209+
`${chalk.bold(t("addInfraDetected"))}: ${detected.hasInfra ? chalk.green(t("addInfraYes")) : chalk.dim(t("addInfraNo"))}` +
210+
(detected.infraServices.length > 0
211+
? ` (${detected.infraServices.join(", ")})`
212+
: ""),
213+
);
214+
if (detected.dbType) {
215+
clack.log.info(
216+
`${chalk.bold(t("addDbTypeDetected"))}: ${chalk.green(detected.dbType)}`,
217+
);
218+
}
219+
220+
const confirmed = await clack.confirm({
221+
message: t("addConfirmDetected"),
222+
initialValue: true,
223+
});
224+
if (clack.isCancel(confirmed) || !confirmed) {
225+
clack.outro(chalk.dim("Cancelled"));
226+
return;
227+
}
228+
229+
// 5. 收集需要用户输入的配置
230+
const userConfig = await clack.group(
231+
{
232+
projectName: () =>
233+
clack.text({
234+
message: t("addProjectName"),
235+
placeholder: basename(projectDir),
236+
defaultValue: basename(projectDir),
237+
validate: (v) => {
238+
if (!v?.trim()) return t("projectNameRequired");
239+
return undefined;
240+
},
241+
}),
242+
dbType: () => {
243+
if (detected.dbType) return Promise.resolve(detected.dbType);
244+
return clack.select({
245+
message: t("dbTypeSelect"),
246+
options: [
247+
{ value: "postgresql", label: t("infraPostgres") },
248+
{ value: "mysql", label: t("infraMysql") },
249+
],
250+
});
251+
},
252+
serverPort: () =>
253+
clack.text({
254+
message: t("addServerPort"),
255+
defaultValue: "8000",
256+
}),
257+
webPort: () =>
258+
clack.text({
259+
message: t("addWebPort"),
260+
defaultValue: detected.webPort ? String(detected.webPort) : "5173",
261+
}),
262+
},
263+
{
264+
onCancel: () => {
265+
clack.outro(chalk.dim("Cancelled"));
266+
process.exit(0);
267+
},
268+
},
269+
);
270+
271+
const projectName = String(userConfig.projectName).trim();
272+
const dbType = String(userConfig.dbType) as DatabaseType;
273+
const serverPort = parseInt(String(userConfig.serverPort)) || 8000;
274+
const webPort = parseInt(String(userConfig.webPort)) || 5173;
275+
276+
// 6. 写入项目配置
277+
const projConfig: ProjectConfig = {
278+
name: projectName,
279+
backend_name: detected.backendName,
280+
frontend_name: detected.frontendName,
281+
server_port: serverPort,
282+
web_port: webPort,
283+
infra: detected.hasInfra,
284+
infra_services: detected.infraServices,
285+
db_type: dbType,
286+
};
287+
writeProjectConfig(projectDir, projConfig);
288+
289+
// 7. 注册到全局配置
290+
addProject({
291+
name: projectName,
292+
path: projectDir,
293+
createdAt: new Date().toISOString(),
294+
});
295+
296+
// 8. 完成
297+
clack.log.success(chalk.green.bold(t("addSuccess")));
298+
clack.note(
299+
[
300+
`${chalk.bold(t("addProjectName"))}: ${projectName}`,
301+
`${chalk.bold(t("addBackendDetected"))}: ${detected.backendName}`,
302+
`${chalk.bold(t("addFrontendDetected"))}: ${detected.frontendName}`,
303+
`${chalk.bold(t("addDbTypeDetected"))}: ${dbType}`,
304+
`${chalk.bold(t("serverPort"))}: ${serverPort}`,
305+
`${chalk.bold(t("webPort"))}: ${webPort}`,
306+
].join("\n"),
307+
projectDir,
308+
);
309+
clack.outro(chalk.cyan(t("happyCoding")));
310+
}

src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ program
4646
await createAction()
4747
})
4848

49+
// ─── add ───
50+
program
51+
.command('add')
52+
.description(t('cmdAdd'))
53+
.action(async () => {
54+
const { addAction } = await import('./commands/add.js')
55+
await addAction()
56+
})
57+
4958
// ─── dev ───
5059
program
5160
.command('dev')

src/lib/i18n.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,27 @@ const messages = {
153153
pluginNameAutoSuffix: "前端插件名称已自动添加 _ui 后缀",
154154
pluginMarketCacheUsed: "网络不可用,使用本地缓存数据",
155155

156+
// Add existing project
157+
addProjectDir: "项目目录路径",
158+
addScanning: "正在扫描项目结构...",
159+
addBackendDetected: "后端目录",
160+
addFrontendDetected: "前端目录",
161+
addInfraDetected: "基础设施",
162+
addInfraYes: "已检测到",
163+
addInfraNo: "未检测到",
164+
addDbTypeDetected: "数据库类型",
165+
addNoBackend: "未找到有效的后端目录 (需包含 pyproject.toml 和 backend/ 子目录)",
166+
addNoFrontend: "未找到有效的前端目录 (需包含 apps/web-antdv-next/ 子目录)",
167+
addNotSameParent: "前后端目录必须在同一项目目录下",
168+
addAlreadyRegistered: "该项目已注册",
169+
addConfirmDetected: "检测到以下项目结构,是否正确?",
170+
addProjectName: "项目名称",
171+
addServerPort: "后端服务端口",
172+
addWebPort: "前端服务端口",
173+
addSuccess: "项目添加成功!",
174+
addDetectFailed: "项目结构验证失败",
175+
cmdAdd: "添加现有 FBA 项目到管理列表",
176+
156177
// Completion
157178
createSuccess: "项目创建成功!",
158179
nextSteps: "下一步",
@@ -403,6 +424,27 @@ const messages = {
403424
pluginNameAutoSuffix: "Frontend plugin name auto-appended with _ui suffix",
404425
pluginMarketCacheUsed: "Network unavailable, using local cache",
405426

427+
// Add existing project
428+
addProjectDir: "Project directory path",
429+
addScanning: "Scanning project structure...",
430+
addBackendDetected: "Backend directory",
431+
addFrontendDetected: "Frontend directory",
432+
addInfraDetected: "Infrastructure",
433+
addInfraYes: "detected",
434+
addInfraNo: "not detected",
435+
addDbTypeDetected: "Database type",
436+
addNoBackend: "No valid backend directory found (must contain pyproject.toml and backend/ subdirectory)",
437+
addNoFrontend: "No valid frontend directory found (must contain apps/web-antdv-next/ subdirectory)",
438+
addNotSameParent: "Frontend and backend must be in the same project directory",
439+
addAlreadyRegistered: "This project is already registered",
440+
addConfirmDetected: "Detected the following project structure. Is this correct?",
441+
addProjectName: "Project name",
442+
addServerPort: "Backend server port",
443+
addWebPort: "Frontend server port",
444+
addSuccess: "Project added successfully!",
445+
addDetectFailed: "Project structure validation failed",
446+
cmdAdd: "Add an existing FBA project to the managed list",
447+
406448
createSuccess: "Project created successfully!",
407449
nextSteps: "Next steps",
408450

0 commit comments

Comments
 (0)