@ranger1/dx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,864 @@
1
+ import { readFileSync, existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { execSync } from 'node:child_process'
4
+ import { logger } from '../logger.js'
5
+ import { envManager } from '../env.js'
6
+ import { execManager } from '../exec.js'
7
+ import { validateEnvironment } from '../validate-env.js'
8
+ import { FLAG_DEFINITIONS, parseFlags } from './flags.js'
9
+ import { getCleanArgs } from './args.js'
10
+ import { showHelp, showCommandHelp } from './help.js'
11
+ import {
12
+ handleHelp,
13
+ handleDev,
14
+ handleBuild,
15
+ handleTest,
16
+ handleLint,
17
+ handleClean,
18
+ handleCache,
19
+ handleInstall,
20
+ handleStatus,
21
+ } from './commands/core.js'
22
+ import { handleStart } from './commands/start.js'
23
+ import { handleDeploy } from './commands/deploy.js'
24
+ import { handleDatabase } from './commands/db.js'
25
+ import { handleWorktree } from './commands/worktree.js'
26
+ import { handlePackage } from './commands/package.js'
27
+ import { handleExport } from './commands/export.js'
28
+
29
+ class DxCli {
30
+ constructor(options = {}) {
31
+ this.projectRoot = options.projectRoot || process.env.DX_PROJECT_ROOT || process.cwd()
32
+ this.configDir = options.configDir || process.env.DX_CONFIG_DIR || join(this.projectRoot, 'dx', 'config')
33
+ this.invocation = options.invocation || 'dx'
34
+
35
+ this.commands = this.loadCommands()
36
+
37
+ this.args = process.argv.slice(2)
38
+ this.flags = parseFlags(this.args)
39
+ this.command = this.args[0]
40
+ this.subcommand = this.args[1]
41
+ this.environment = this.args[2]
42
+ this.worktreeManager = null
43
+ this.envCache = null
44
+ this.commandHandlers = {
45
+ help: args => handleHelp(this, args),
46
+ dev: args => handleDev(this, args),
47
+ start: args => handleStart(this, args),
48
+ build: args => handleBuild(this, args),
49
+ test: args => handleTest(this, args),
50
+ lint: args => handleLint(this, args),
51
+ clean: args => handleClean(this, args),
52
+ cache: args => handleCache(this, args),
53
+ install: args => handleInstall(this, args),
54
+ status: args => handleStatus(this, args),
55
+ deploy: args => handleDeploy(this, args),
56
+ db: args => handleDatabase(this, args),
57
+ worktree: args => handleWorktree(this, args),
58
+ package: args => handlePackage(this, args),
59
+ export: args => handleExport(this, args),
60
+ }
61
+
62
+ this.flagDefinitions = FLAG_DEFINITIONS
63
+ }
64
+
65
+ // 加载命令配置
66
+ loadCommands() {
67
+ try {
68
+ const configPath = join(this.configDir, 'commands.json')
69
+ return JSON.parse(readFileSync(configPath, 'utf8'))
70
+ } catch (error) {
71
+ logger.error(`无法加载命令配置文件: ${join(this.configDir, 'commands.json')}`)
72
+ logger.error(error?.message || String(error))
73
+ process.exit(1)
74
+ }
75
+ }
76
+
77
+ // 检测并安装依赖
78
+ async ensureDependencies() {
79
+ const nodeModulesPath = join(process.cwd(), 'node_modules')
80
+
81
+ // 检查 node_modules 是否存在且包含关键依赖
82
+ if (!existsSync(nodeModulesPath) || !existsSync(join(nodeModulesPath, '.pnpm'))) {
83
+ logger.warn('检测到依赖未安装,正在自动安装...')
84
+ logger.info('将以 NODE_ENV=development 安装完整依赖(含 devDependencies)')
85
+ try {
86
+ execSync('pnpm install --frozen-lockfile', {
87
+ stdio: 'inherit',
88
+ cwd: process.cwd(),
89
+ env: {
90
+ ...process.env,
91
+ NODE_ENV: 'development', // 确保安装完整依赖(含 devDependencies)
92
+ },
93
+ })
94
+ logger.success('依赖安装完成')
95
+ } catch (error) {
96
+ logger.error('依赖安装失败,请手动执行: pnpm install')
97
+ process.exit(1)
98
+ }
99
+ }
100
+ }
101
+
102
+ // 每次启动时执行的检查流程
103
+ async runStartupChecks() {
104
+ // 0. 跳过 db 命令的启动检查
105
+ // 原因:ensurePrismaClient() 会调用 `dx db generate`,如果不跳过会导致无限递归
106
+ // db 命令本身不需要 Prisma Client 或环境变量验证即可执行
107
+ if (this.command === 'db') return
108
+
109
+ // 1. 在 worktree 中自动同步根目录的 .env.*.local 文件
110
+ try {
111
+ const worktreeManager = await this.getWorktreeManager()
112
+ worktreeManager.syncEnvFilesFromMainRoot()
113
+ } catch (error) {
114
+ logger.warn(`自动同步 env 文件失败: ${error.message}`)
115
+ }
116
+
117
+ // 2. 检测 Prisma Client 是否存在,不存在则执行 db generate
118
+ await this.ensurePrismaClient()
119
+
120
+ // 3. 验证环境变量(尊重多种跳过机制)
121
+ // 跳过条件:
122
+ // - 命令配置了 skipEnvValidation: true(如 lint)
123
+ // - 子命令配置了 skipEnvValidation: true(如 build.shared)
124
+ // - 用户指定了 --no-env-check 标志
125
+ // - 环境变量 AI_SKIP_ENV_CHECK=true(CI 场景)
126
+ // - CI 环境(CI=1)- 由 exec.js 中的 executeCommand 进行精细检查
127
+ // - Vercel 构建环境(VERCEL=1)
128
+ const commandConfig = this.getCommandConfig(this.command)
129
+ const subcommandConfig = this.subcommand ? commandConfig?.[this.subcommand] : null
130
+ const isVercelEnv = String(process.env.VERCEL || '').toLowerCase() === '1'
131
+ // 兼容 GitHub Actions (CI=true) 和其他 CI 系统 (CI=1)
132
+ const isCIEnv = ['true', '1'].includes(String(process.env.CI || '').toLowerCase())
133
+ const skipEnvValidation =
134
+ commandConfig?.skipEnvValidation ||
135
+ subcommandConfig?.skipEnvValidation ||
136
+ this.flags.noEnvCheck ||
137
+ String(process.env.AI_SKIP_ENV_CHECK || '').toLowerCase() === 'true' ||
138
+ isCIEnv ||
139
+ isVercelEnv
140
+
141
+ if (!skipEnvValidation) {
142
+ await this.validateEnvVars()
143
+ }
144
+ }
145
+
146
+ // 获取命令配置
147
+ getCommandConfig(command) {
148
+ return this.commands[command]
149
+ }
150
+
151
+ // 检测并生成 Prisma Client
152
+ async ensurePrismaClient() {
153
+ // pnpm 结构下检测 @prisma/client 生成的 default.js 文件
154
+ const prismaClientPath = join(process.cwd(), 'node_modules', '@prisma', 'client', 'default.js')
155
+
156
+ if (!existsSync(prismaClientPath)) {
157
+ const environment = this.determineEnvironment()
158
+ logger.step('检测到 Prisma Client 未生成,正在生成...')
159
+ try {
160
+ const generateConfig = this.commands?.db?.generate
161
+ if (!generateConfig?.command) {
162
+ throw new Error('未找到 db.generate 命令配置,请检查 scripts/config/commands.json')
163
+ }
164
+
165
+ const envKey = this.normalizeEnvKey(environment)
166
+ const execFlags = { ...this.flags }
167
+ ;['dev', 'development', 'prod', 'production', 'test', 'e2e', 'staging', 'stage'].forEach(
168
+ key => delete execFlags[key]
169
+ )
170
+ if (envKey === 'prod') execFlags.prod = true
171
+ else if (envKey === 'dev') execFlags.dev = true
172
+ else if (envKey === 'test') execFlags.test = true
173
+ else if (envKey === 'e2e') execFlags.e2e = true
174
+ else if (envKey === 'staging') execFlags.staging = true
175
+
176
+ await execManager.executeCommand(generateConfig.command, {
177
+ app: generateConfig.app || 'backend',
178
+ flags: execFlags,
179
+ // Prisma generate 不应卡在环境变量校验上
180
+ skipEnvValidation: true,
181
+ })
182
+ logger.success('Prisma Client 生成完成')
183
+ } catch (error) {
184
+ logger.error('Prisma Client 生成失败')
185
+ logger.error(error?.message || String(error))
186
+ process.exit(1)
187
+ }
188
+ }
189
+ }
190
+
191
+ // 验证环境变量
192
+ async validateEnvVars() {
193
+ const environment = this.determineEnvironment()
194
+
195
+ if (this.envCache?.environment === environment) {
196
+ return this.envCache.layeredEnv
197
+ }
198
+
199
+ try {
200
+ validateEnvironment()
201
+
202
+ const layeredEnv = envManager.collectEnvFromLayers('backend', environment)
203
+ if (envManager.latestEnvWarnings && envManager.latestEnvWarnings.length > 0) {
204
+ envManager.latestEnvWarnings.forEach(message => {
205
+ logger.warn(message)
206
+ })
207
+ }
208
+
209
+ // CI 环境跳过后端环境变量校验(CI 中 build backend 只生成 OpenAPI,不需要数据库连接)
210
+ // 非 CI 环境下,检查 backend 组的环境变量
211
+ if (process.env.CI !== '1') {
212
+ const effectiveEnv = { ...process.env, ...layeredEnv }
213
+ const requiredVars = envManager.getRequiredEnvVars(environment, 'backend')
214
+ if (requiredVars.length > 0) {
215
+ const { valid, missing, placeholders } = envManager.validateRequiredVars(
216
+ requiredVars,
217
+ effectiveEnv,
218
+ )
219
+ if (!valid) {
220
+ const problems = ['环境变量校验未通过']
221
+ if (missing.length > 0) {
222
+ problems.push(`缺少必填环境变量: ${missing.join(', ')}`)
223
+ }
224
+ if (placeholders.length > 0) {
225
+ problems.push(`以下环境变量仍为占位值或空串: ${placeholders.join(', ')}`)
226
+ }
227
+ if (missing.length > 0 || placeholders.length > 0) {
228
+ problems.push(`请在 .env.${environment} / .env.${environment}.local 中补齐配置`)
229
+ }
230
+ throw new Error(problems.join('\n'))
231
+ }
232
+ }
233
+ }
234
+
235
+ this.envCache = { environment, layeredEnv }
236
+ return layeredEnv
237
+ } catch (error) {
238
+ logger.error('环境变量验证失败')
239
+ logger.error(error.message)
240
+ process.exit(1)
241
+ }
242
+ }
243
+
244
+ // 获取环境对应的命令行 flag
245
+ getEnvironmentFlag(environment) {
246
+ switch (environment) {
247
+ case 'production':
248
+ return '--prod'
249
+ case 'staging':
250
+ return '--staging'
251
+ case 'test':
252
+ return '--test'
253
+ case 'e2e':
254
+ return '--e2e'
255
+ case 'development':
256
+ default:
257
+ return '--dev'
258
+ }
259
+ }
260
+
261
+ // 主执行方法
262
+ async run() {
263
+ try {
264
+ // 显示帮助
265
+ if (this.flags.help || !this.command) {
266
+ if (this.flags.help && this.command && this.command !== 'help') {
267
+ showCommandHelp(this.command)
268
+ } else {
269
+ showHelp()
270
+ }
271
+ return
272
+ }
273
+
274
+ // 在执行命令前先校验参数与选项
275
+ await this.ensureDependencies()
276
+ await this.runStartupChecks()
277
+ this.validateInputs()
278
+
279
+ // 设置详细模式
280
+ if (this.flags.verbose) {
281
+ logger.debug('启用详细输出模式')
282
+ }
283
+
284
+ // 路由到对应的命令处理器
285
+ await this.routeCommand()
286
+
287
+ } catch (error) {
288
+ logger.error('命令执行失败')
289
+ logger.error(error.message)
290
+
291
+ if (this.flags.verbose) {
292
+ console.error(error.stack)
293
+ }
294
+
295
+ process.exit(1)
296
+ }
297
+ }
298
+
299
+ // 命令路由
300
+ async routeCommand() {
301
+ const cleanArgs = getCleanArgs(this.args)
302
+ const [command, ...subArgs] = cleanArgs
303
+
304
+ if (!command) {
305
+ showHelp()
306
+ return
307
+ }
308
+
309
+ const handler = this.commandHandlers[command]
310
+ if (!handler) {
311
+ logger.error(`未知命令: ${command}`)
312
+ showHelp()
313
+ process.exit(1)
314
+ }
315
+
316
+ await handler(subArgs)
317
+ }
318
+
319
+ // 校验原始输入,禁止未识别的选项或多余参数
320
+ validateInputs() {
321
+ const cleanArgs = getCleanArgs(this.args)
322
+ const command = cleanArgs[0]
323
+ const allowedFlags = this.getAllowedFlags(command)
324
+ const consumedFlagValueIndexes = this.validateFlags(command, allowedFlags)
325
+
326
+ // 收集所有位置参数(不含命令本身、选项及其值)
327
+ const positionalArgs = []
328
+ let commandConsumed = false
329
+ let afterDoubleDash = false
330
+ for (let i = 0; i < this.args.length; i++) {
331
+ const token = this.args[i]
332
+ if (token === '--') {
333
+ afterDoubleDash = true
334
+ continue
335
+ }
336
+ if (afterDoubleDash) continue
337
+ if (token.startsWith('-')) continue
338
+ if (consumedFlagValueIndexes.has(i)) continue
339
+
340
+ if (!commandConsumed && command) {
341
+ // 跳过命令本身
342
+ commandConsumed = true
343
+ continue
344
+ }
345
+
346
+ positionalArgs.push(token)
347
+ }
348
+
349
+ if (!command) {
350
+ if (positionalArgs.length > 0) {
351
+ this.reportExtraPositionals('全局', positionalArgs)
352
+ }
353
+ return
354
+ }
355
+
356
+ this.validatePositionalArgs(command, positionalArgs)
357
+ }
358
+
359
+ // 获取命令允许的选项
360
+ getAllowedFlags(command) {
361
+ const allowed = new Map()
362
+ const applyDefs = defs => {
363
+ defs?.forEach(({ flag, expectsValue }) => {
364
+ if (!flag) return
365
+ allowed.set(flag, { expectsValue: Boolean(expectsValue) })
366
+ })
367
+ }
368
+
369
+ applyDefs(this.flagDefinitions._global)
370
+ if (command && this.flagDefinitions[command]) {
371
+ applyDefs(this.flagDefinitions[command])
372
+ }
373
+
374
+ return allowed
375
+ }
376
+
377
+ // 校验选项合法性并返回被选项消耗的参数下标集合
378
+ validateFlags(command, allowedFlags) {
379
+ const consumedIndexes = new Set()
380
+ const doubleDashIndex = this.args.indexOf('--')
381
+
382
+ for (let i = 0; i < this.args.length; i++) {
383
+ if (doubleDashIndex !== -1 && i >= doubleDashIndex) break
384
+ const token = this.args[i]
385
+ if (!token.startsWith('-')) continue
386
+
387
+ const spec = allowedFlags.get(token)
388
+ if (!spec) {
389
+ this.reportUnknownFlag(command, token, allowedFlags)
390
+ process.exit(1)
391
+ }
392
+
393
+ if (spec.expectsValue) {
394
+ const next = this.args[i + 1]
395
+ if (next === undefined || next.startsWith('-')) {
396
+ logger.error(`选项 ${token} 需要提供参数值`)
397
+ process.exit(1)
398
+ }
399
+ consumedIndexes.add(i + 1)
400
+ }
401
+ }
402
+
403
+ return consumedIndexes
404
+ }
405
+
406
+ // 根据命令定义校验位置参数
407
+ validatePositionalArgs(command, positionalArgs) {
408
+ const ensureMax = (max) => {
409
+ if (positionalArgs.length > max) {
410
+ this.reportExtraPositionals(command, positionalArgs.slice(max))
411
+ }
412
+ }
413
+
414
+ switch (command) {
415
+ case 'help':
416
+ ensureMax(1)
417
+ break
418
+ case 'build': {
419
+ if (positionalArgs.length >= 2 && this.isEnvironmentToken(positionalArgs[1])) {
420
+ this.reportEnvironmentFlagRequired(command, positionalArgs[1], positionalArgs)
421
+ }
422
+ ensureMax(1)
423
+ break
424
+ }
425
+ case 'package': {
426
+ if (positionalArgs.length >= 2 && this.isEnvironmentToken(positionalArgs[1])) {
427
+ this.reportEnvironmentFlagRequired(command, positionalArgs[1], positionalArgs)
428
+ }
429
+ ensureMax(1)
430
+ break
431
+ }
432
+ case 'db': {
433
+ if (positionalArgs.length === 0) return
434
+ const action = positionalArgs[0]
435
+ const extras = positionalArgs.slice(1)
436
+ const envToken = extras.find(token => this.isEnvironmentToken(token))
437
+ if (envToken) {
438
+ this.reportEnvironmentFlagRequired(command, envToken, positionalArgs)
439
+ }
440
+
441
+ if (action === 'migrate') {
442
+ if (extras.length > 0) {
443
+ this.reportExtraPositionals(command, extras)
444
+ }
445
+ } else if (action === 'deploy') {
446
+ if (extras.length > 0) {
447
+ this.reportExtraPositionals(command, extras)
448
+ }
449
+ } else if (action === 'script') {
450
+ // script 子命令需要一个脚本名称参数
451
+ if (extras.length > 1) {
452
+ this.reportExtraPositionals(command, extras.slice(1))
453
+ }
454
+ } else if (extras.length > 0) {
455
+ this.reportExtraPositionals(command, extras)
456
+ }
457
+ break
458
+ }
459
+ case 'test':
460
+ ensureMax(3)
461
+ break
462
+ case 'worktree': {
463
+ if (positionalArgs.length === 0) return
464
+ const action = positionalArgs[0]
465
+ if (['del', 'delete', 'rm'].includes(action)) {
466
+ return
467
+ }
468
+ if (['make'].includes(action)) {
469
+ ensureMax(3)
470
+ break
471
+ }
472
+ if (['list', 'ls', 'clean', 'prune'].includes(action)) {
473
+ ensureMax(1)
474
+ break
475
+ }
476
+ break
477
+ }
478
+ case 'start': {
479
+ const extras = positionalArgs.slice(1)
480
+ const envToken = extras.find(token => this.isEnvironmentToken(token))
481
+ if (envToken) {
482
+ this.reportEnvironmentFlagRequired(command, envToken, positionalArgs)
483
+ }
484
+ ensureMax(1)
485
+ break
486
+ }
487
+ case 'lint':
488
+ ensureMax(0)
489
+ break
490
+ case 'clean':
491
+ ensureMax(1)
492
+ break
493
+ case 'cache':
494
+ ensureMax(1)
495
+ break
496
+ case 'status':
497
+ ensureMax(0)
498
+ break
499
+ default:
500
+ // 默认放行,具体命令内部再校验
501
+ break
502
+ }
503
+ }
504
+
505
+ isEnvironmentToken(token) {
506
+ if (!token) return false
507
+ const value = String(token).toLowerCase()
508
+ return (
509
+ value === 'dev' ||
510
+ value === 'development' ||
511
+ value === 'prod' ||
512
+ value === 'production' ||
513
+ value === 'staging' ||
514
+ value === 'stage' ||
515
+ value === 'test' ||
516
+ value === 'e2e'
517
+ )
518
+ }
519
+
520
+ reportEnvironmentFlagRequired(command, token, positionalArgs = []) {
521
+ const normalizedFlag = this.getEnvironmentFlagExample(token)
522
+ logger.error(`命令 ${command} 不再支持通过位置参数指定环境: ${token}`)
523
+ logger.info('请使用带前缀的环境标志,例如 --dev、--staging、--prod、--test 或 --e2e。')
524
+ const suggestion = normalizedFlag
525
+ ? this.buildEnvironmentSuggestion(command, normalizedFlag, positionalArgs, token)
526
+ : null
527
+ if (suggestion) {
528
+ logger.info(`建议命令: ${suggestion}`)
529
+ } else if (normalizedFlag) {
530
+ logger.info(`示例: ${this.invocation} ${command} ... ${normalizedFlag}`)
531
+ }
532
+ logger.info('未显式指定环境时将默认使用 --dev。')
533
+ process.exit(1)
534
+ }
535
+
536
+ getEnvironmentFlagExample(token) {
537
+ const key = this.normalizeEnvKey(token)
538
+ switch (key) {
539
+ case 'dev':
540
+ return '--dev'
541
+ case 'prod':
542
+ return '--prod'
543
+ case 'staging':
544
+ return '--staging'
545
+ case 'test':
546
+ return '--test'
547
+ case 'e2e':
548
+ return '--e2e'
549
+ default:
550
+ return null
551
+ }
552
+ }
553
+
554
+ buildEnvironmentSuggestion(command, normalizedFlag, positionalArgs, token) {
555
+ const parts = [this.invocation, command]
556
+ const rest = Array.isArray(positionalArgs) ? [...positionalArgs] : []
557
+ if (rest.length > 0) {
558
+ const matchIndex = rest.findIndex(arg => String(arg).toLowerCase() === String(token).toLowerCase())
559
+ if (matchIndex !== -1) rest.splice(matchIndex, 1)
560
+ }
561
+ if (!rest.includes(normalizedFlag)) {
562
+ rest.push(normalizedFlag)
563
+ }
564
+ return parts.concat(rest).join(' ')
565
+ }
566
+
567
+ reportDevCommandRemoved(args) {
568
+ const target = args?.[0]
569
+ logger.error('`dx dev` 命令已移除,统一使用 `dx start`。')
570
+ if (target) {
571
+ logger.info(`请执行: ${this.invocation} start ${target} --dev`)
572
+ } else {
573
+ logger.info(`示例: ${this.invocation} start backend --dev`)
574
+ logger.info(` ${this.invocation} start front --dev`)
575
+ logger.info(` ${this.invocation} start admin --dev`)
576
+ }
577
+ process.exit(1)
578
+ }
579
+
580
+ reportExtraPositionals(command, extras) {
581
+ const list = extras.join(', ')
582
+ if (command === '全局') {
583
+ logger.error(`检测到未识别的参数: ${list}`)
584
+ } else {
585
+ logger.error(`命令 ${command} 存在未识别的额外参数: ${list}`)
586
+ }
587
+ const hint = command && command !== '全局' ? `${this.invocation} help ${command}` : `${this.invocation} --help`
588
+ logger.info(`提示: 执行 ${hint} 或 ${this.invocation} --help 查看命令用法`)
589
+ if (command && command !== '全局') {
590
+ logger.info(`示例: ${this.invocation} ${command} --help`)
591
+ }
592
+ process.exit(1)
593
+ }
594
+
595
+ reportUnknownFlag(command, flag, allowedFlags) {
596
+ logger.error(`检测到未识别的选项: ${flag}`)
597
+ const supported = Array.from(allowedFlags.keys())
598
+ if (supported.length > 0) {
599
+ logger.info(`支持的选项: ${supported.join(', ')}`)
600
+ } else if (command) {
601
+ logger.info(`命令 ${command} 不接受额外选项`)
602
+ }
603
+ const hint = command ? `${this.invocation} help ${command}` : `${this.invocation} --help`
604
+ logger.info(`提示: 执行 ${hint} 或 ${this.invocation} --help 查看命令用法`)
605
+ if (command) {
606
+ logger.info(`示例: ${this.invocation} ${command} --help`)
607
+ }
608
+ }
609
+
610
+ // 校验是否在仓库根目录执行
611
+ ensureRepoRoot() {
612
+ const cwd = process.cwd()
613
+ const markers = [
614
+ 'pnpm-workspace.yaml',
615
+ 'package.json',
616
+ 'apps',
617
+ 'dx/config/commands.json',
618
+ ]
619
+ const missing = markers.filter(p => !existsSync(join(cwd, p)))
620
+ if (missing.length) {
621
+ logger.error(`请从仓库根目录运行此命令。缺少标识文件/目录: ${missing.join(', ')}`)
622
+ process.exit(1)
623
+ }
624
+ }
625
+
626
+ async getWorktreeManager() {
627
+ if (!this.worktreeManager) {
628
+ const { default: worktreeManager } = await import('../worktree.js')
629
+ this.worktreeManager = worktreeManager
630
+ }
631
+ return this.worktreeManager
632
+ }
633
+
634
+ // 并发命令处理
635
+ async handleConcurrentCommands(commandPaths, baseCommand, environment) {
636
+ const commands = []
637
+
638
+ for (const path of commandPaths) {
639
+ const config = this.resolveCommandPath(
640
+ path,
641
+ baseCommand,
642
+ this.normalizeEnvKey(environment)
643
+ )
644
+ if (!config) {
645
+ logger.warn(`未解析到命令配置: ${path} (${environment || '-'})`)
646
+ continue
647
+ }
648
+ commands.push({
649
+ command: this.applySdkOfflineFlag(config.command),
650
+ options: {
651
+ app: config.app,
652
+ ports: config.ports,
653
+ flags: this.flags,
654
+ },
655
+ })
656
+ }
657
+
658
+ if (commands.length > 0) {
659
+ await execManager.executeConcurrent(commands)
660
+ }
661
+ }
662
+
663
+ // 顺序命令处理
664
+ async handleSequentialCommands(commandPaths, environment) {
665
+ for (const path of commandPaths) {
666
+ const config = this.resolveCommandPath(path, null, this.normalizeEnvKey(environment))
667
+ if (!config) {
668
+ logger.warn(`未解析到命令配置: ${path} (${environment || '-'})`)
669
+ continue
670
+ }
671
+
672
+ // 支持在顺序执行中嵌套并发/顺序配置
673
+ if (config.concurrent && Array.isArray(config.commands)) {
674
+ await this.handleConcurrentCommands(config.commands, null, environment)
675
+ } else if (config.sequential && Array.isArray(config.commands)) {
676
+ await this.handleSequentialCommands(config.commands, environment)
677
+ } else {
678
+ await this.executeCommand(config)
679
+ }
680
+ }
681
+ }
682
+
683
+ // 解析命令路径
684
+ resolveCommandPath(path, baseCommand, environment) {
685
+ const parts = path.split('.')
686
+ let config = this.commands
687
+
688
+ for (const part of parts) {
689
+ config = config[part]
690
+ if (!config) break
691
+ }
692
+
693
+ // 如果有环境参数,尝试获取对应环境的配置
694
+ if (environment && config) {
695
+ const envKey = this.normalizeEnvKey(environment)
696
+ if (config[envKey]) config = config[envKey]
697
+ else if (envKey === 'staging' && config.prod) config = config.prod
698
+ }
699
+
700
+ return config
701
+ }
702
+
703
+ // SDK 构建命令当前不再暴露 --online/--offline 模式,保留该方法仅为兼容旧调用
704
+ applySdkModeFlags(command) {
705
+ return command
706
+ }
707
+
708
+ // 向后兼容的别名
709
+ applySdkOfflineFlag(command) {
710
+ return command
711
+ }
712
+
713
+ collectStartPorts(service, startConfig, envKey) {
714
+ const portSet = new Set()
715
+
716
+ if (startConfig && Array.isArray(startConfig.ports)) {
717
+ startConfig.ports.forEach(port => this.addPortToSet(portSet, port))
718
+ }
719
+
720
+ if (envKey === 'dev') {
721
+ const legacyConfig = this.commands.dev?.[service]
722
+ if (legacyConfig && Array.isArray(legacyConfig.ports)) {
723
+ legacyConfig.ports.forEach(port => this.addPortToSet(portSet, port))
724
+ }
725
+ }
726
+
727
+ return Array.from(portSet)
728
+ }
729
+
730
+ addPortToSet(target, port) {
731
+ const numeric = Number(port)
732
+ if (Number.isFinite(numeric) && numeric > 0) {
733
+ target.add(numeric)
734
+ }
735
+ }
736
+
737
+ // 执行单个命令
738
+ async executeCommand(config, overrideFlags) {
739
+ if (!config) {
740
+ logger.error('无效的命令配置')
741
+ return
742
+ }
743
+
744
+ const withTempEnv = async fn => {
745
+ const envPatch = config?.env && typeof config.env === 'object' ? config.env : null
746
+ if (!envPatch) return await fn()
747
+
748
+ const previous = {}
749
+ for (const [key, value] of Object.entries(envPatch)) {
750
+ previous[key] = Object.prototype.hasOwnProperty.call(process.env, key)
751
+ ? process.env[key]
752
+ : undefined
753
+ process.env[key] = String(value)
754
+ }
755
+
756
+ try {
757
+ return await fn()
758
+ } finally {
759
+ for (const [key, oldValue] of Object.entries(previous)) {
760
+ if (oldValue === undefined) delete process.env[key]
761
+ else process.env[key] = oldValue
762
+ }
763
+ }
764
+ }
765
+
766
+ // internal runners (for projects that only ship scripts/config)
767
+ if (config.internal) {
768
+ if (config.internal === 'sdk-build') {
769
+ await withTempEnv(async () => {
770
+ const { runSdkBuild } = await import('../sdk-build.js')
771
+ await runSdkBuild(Array.isArray(config.args) ? config.args : [])
772
+ })
773
+ return
774
+ }
775
+ if (config.internal === 'backend-package') {
776
+ await withTempEnv(async () => {
777
+ const { runBackendPackage } = await import('../backend-package.js')
778
+ await runBackendPackage(Array.isArray(config.args) ? config.args : [])
779
+ })
780
+ return
781
+ }
782
+
783
+ if (config.internal === 'start-dev') {
784
+ await withTempEnv(async () => {
785
+ const { runStartDev } = await import('../start-dev.js')
786
+ await runStartDev(Array.isArray(config.args) ? config.args : [])
787
+ })
788
+ return
789
+ }
790
+
791
+ throw new Error(`未知 internal runner: ${config.internal}`)
792
+ }
793
+
794
+ if (!config.command) {
795
+ logger.error('无效的命令配置: 缺少 command/internal')
796
+ return
797
+ }
798
+
799
+ const rawCommand = String(config.command).trim()
800
+ // backward compat: old commands.json referenced scripts/lib/*.js in the project
801
+ if (rawCommand.startsWith('node scripts/lib/sdk-build.js')) {
802
+ const argsText = rawCommand.replace(/^node\s+scripts\/lib\/sdk-build\.js\s*/g, '')
803
+ const args = argsText ? argsText.split(/\s+/).filter(Boolean) : []
804
+ await withTempEnv(async () => {
805
+ const { runSdkBuild } = await import('../sdk-build.js')
806
+ await runSdkBuild(args)
807
+ })
808
+ return
809
+ }
810
+
811
+ if (rawCommand.startsWith('node scripts/lib/backend-package.js')) {
812
+ const argsText = rawCommand.replace(/^node\s+scripts\/lib\/backend-package\.js\s*/g, '')
813
+ const args = argsText ? argsText.split(/\s+/).filter(Boolean) : []
814
+ await withTempEnv(async () => {
815
+ const { runBackendPackage } = await import('../backend-package.js')
816
+ await runBackendPackage(args)
817
+ })
818
+ return
819
+ }
820
+
821
+ const command = this.applySdkOfflineFlag(rawCommand)
822
+
823
+ const options = {
824
+ app: config.app,
825
+ flags: overrideFlags || this.flags,
826
+ ports: config.ports || [],
827
+ // 允许上游在 config.env 中注入环境变量(例如 NX_CACHE=false)
828
+ env: config.env || {},
829
+ skipEnvValidation: Boolean(config.skipEnvValidation),
830
+ forcePortCleanup: Boolean(config.forcePortCleanup),
831
+ }
832
+
833
+ await execManager.executeCommand(command, options)
834
+ }
835
+
836
+ // 确定环境
837
+ determineEnvironment() {
838
+ return envManager.detectEnvironment(this.flags)
839
+ }
840
+
841
+ // 规范化环境键到命令配置使用的命名(dev/prod/test/e2e)
842
+ normalizeEnvKey(env) {
843
+ switch (String(env || '').toLowerCase()) {
844
+ case 'development':
845
+ case 'dev':
846
+ return 'dev'
847
+ case 'production':
848
+ case 'prod':
849
+ return 'prod'
850
+ case 'staging':
851
+ case 'stage':
852
+ return 'staging'
853
+ case 'test':
854
+ return 'test'
855
+ case 'e2e':
856
+ return 'e2e'
857
+ default:
858
+ return env
859
+ }
860
+ }
861
+
862
+ }
863
+
864
+ export { DxCli }