@weotro/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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +755 -0
  3. package/bin/dx-with-version-env.js +8 -0
  4. package/bin/dx.js +187 -0
  5. package/lib/artifact-deploy/artifact-builder.js +144 -0
  6. package/lib/artifact-deploy/config.js +180 -0
  7. package/lib/artifact-deploy/remote-script.js +301 -0
  8. package/lib/artifact-deploy/remote-transport.js +86 -0
  9. package/lib/artifact-deploy.js +70 -0
  10. package/lib/backend-artifact-deploy/artifact-builder.js +267 -0
  11. package/lib/backend-artifact-deploy/config.js +218 -0
  12. package/lib/backend-artifact-deploy/path-utils.js +18 -0
  13. package/lib/backend-artifact-deploy/remote-phases.js +14 -0
  14. package/lib/backend-artifact-deploy/remote-result.js +44 -0
  15. package/lib/backend-artifact-deploy/remote-script.js +507 -0
  16. package/lib/backend-artifact-deploy/remote-transport.js +123 -0
  17. package/lib/backend-artifact-deploy/rollback.js +5 -0
  18. package/lib/backend-artifact-deploy/runtime-package.js +46 -0
  19. package/lib/backend-artifact-deploy.js +91 -0
  20. package/lib/backend-package.js +674 -0
  21. package/lib/cli/args.js +38 -0
  22. package/lib/cli/command-result.js +1 -0
  23. package/lib/cli/commands/contracts.js +60 -0
  24. package/lib/cli/commands/core.js +533 -0
  25. package/lib/cli/commands/db.js +231 -0
  26. package/lib/cli/commands/deploy.js +175 -0
  27. package/lib/cli/commands/env.js +120 -0
  28. package/lib/cli/commands/export.js +39 -0
  29. package/lib/cli/commands/package.js +22 -0
  30. package/lib/cli/commands/release.js +55 -0
  31. package/lib/cli/commands/stack.js +427 -0
  32. package/lib/cli/commands/start.js +58 -0
  33. package/lib/cli/commands/worktree.js +145 -0
  34. package/lib/cli/dx-cli.js +1072 -0
  35. package/lib/cli/flags.js +123 -0
  36. package/lib/cli/help-model.js +222 -0
  37. package/lib/cli/help-renderer.js +137 -0
  38. package/lib/cli/help-schema.js +552 -0
  39. package/lib/cli/help.js +141 -0
  40. package/lib/cli/index.js +4 -0
  41. package/lib/cli/nx-command.js +13 -0
  42. package/lib/codex-initial.js +271 -0
  43. package/lib/confirm.js +213 -0
  44. package/lib/env-policy.js +134 -0
  45. package/lib/env-profile.js +435 -0
  46. package/lib/env.js +261 -0
  47. package/lib/exec.js +692 -0
  48. package/lib/logger.js +239 -0
  49. package/lib/nx-ignore.js +45 -0
  50. package/lib/run-with-version-env.js +163 -0
  51. package/lib/sdk-build.js +424 -0
  52. package/lib/start-dev.js +401 -0
  53. package/lib/telegram-webhook.js +431 -0
  54. package/lib/validate-env.js +317 -0
  55. package/lib/vercel-deploy.js +549 -0
  56. package/lib/version.js +14 -0
  57. package/lib/worktree.js +1052 -0
  58. package/package.json +45 -0
  59. package/skills/create-issue/SKILL.md +90 -0
  60. package/skills/delivering-design-handoff/SKILL.md +290 -0
  61. package/skills/doctor/SKILL.md +76 -0
  62. package/skills/gh-dependabot-cleanup/SKILL.md +54 -0
  63. package/skills/gh-dependabot-cleanup/agents/openai.yaml +7 -0
  64. package/skills/git-release/SKILL.md +194 -0
  65. package/skills/git-release/agents/openai.yaml +7 -0
  66. package/skills/online-debug-guard/SKILL.md +111 -0
  67. package/skills/ship-issue-pr/SKILL.md +676 -0
  68. package/skills/stagewise-ui-debugging/SKILL.md +48 -0
@@ -0,0 +1,1072 @@
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 {
9
+ loadEnvPolicy,
10
+ resolvePolicyTargetId,
11
+ resolveTargetRequiredVars,
12
+ } from '../env-policy.js'
13
+ import { FLAG_DEFINITIONS, parseFlags } from './flags.js'
14
+ import { getCleanArgs, getCleanArgsWithConsumedValues } from './args.js'
15
+ import { showHelp, showCommandHelp } from './help.js'
16
+ import { buildStrictHelpValidationContext, validateHelpConfig } from './help-schema.js'
17
+ import { appendNxVerboseFlag } from './nx-command.js'
18
+ import { getPackageVersion } from '../version.js'
19
+ import { confirmManager } from '../confirm.js'
20
+ import {
21
+ handleHelp,
22
+ handleBuild,
23
+ handleTest,
24
+ handleLint,
25
+ handleClean,
26
+ handleCache,
27
+ handleInstall,
28
+ handleStatus,
29
+ } from './commands/core.js'
30
+ import { handleStart } from './commands/start.js'
31
+ import { handleDeploy } from './commands/deploy.js'
32
+ import { handleDatabase } from './commands/db.js'
33
+ import { handleWorktree } from './commands/worktree.js'
34
+ import { handlePackage } from './commands/package.js'
35
+ import { handleExport } from './commands/export.js'
36
+ import { handleContracts } from './commands/contracts.js'
37
+ import { handleRelease } from './commands/release.js'
38
+ import { handleEnv } from './commands/env.js'
39
+ import { COMMAND_NOT_HANDLED } from './command-result.js'
40
+
41
+ class DxCli {
42
+ constructor(options = {}) {
43
+ this.projectRoot = options.projectRoot || process.env.DX_PROJECT_ROOT || process.cwd()
44
+ this.configDir = options.configDir || process.env.DX_CONFIG_DIR || join(this.projectRoot, 'dx', 'config')
45
+ this.invocation = options.invocation || 'dx'
46
+
47
+ this.commands = this.loadCommands()
48
+
49
+ this.args = process.argv.slice(2)
50
+ this.flags = parseFlags(this.args)
51
+ this.command = this.args[0]
52
+ this.subcommand = this.args[1]
53
+ this.environment = this.args[2]
54
+ this.worktreeManager = null
55
+ this.envCache = null
56
+ this.commandHandlers = {
57
+ help: args => handleHelp(this, args),
58
+ start: args => handleStart(this, args),
59
+ build: args => handleBuild(this, args),
60
+ test: args => handleTest(this, args),
61
+ lint: args => handleLint(this, args),
62
+ clean: args => handleClean(this, args),
63
+ cache: args => handleCache(this, args),
64
+ install: args => handleInstall(this, args),
65
+ status: args => handleStatus(this, args),
66
+ deploy: args => handleDeploy(this, args),
67
+ db: args => handleDatabase(this, args),
68
+ worktree: args => handleWorktree(this, args),
69
+ package: args => handlePackage(this, args),
70
+ export: args => handleExport(this, args),
71
+ contracts: args => handleContracts(this, args),
72
+ release: args => handleRelease(this, args),
73
+ }
74
+ if (this.hasEnvProfileConfig()) {
75
+ this.commandHandlers.env = args => handleEnv(this, args)
76
+ }
77
+ this.registerConfiguredCommandHandlers()
78
+
79
+ this.flagDefinitions = FLAG_DEFINITIONS
80
+ this.validateLoadedHelpConfig()
81
+ }
82
+
83
+ // 加载命令配置
84
+ loadCommands() {
85
+ try {
86
+ const configPath = join(this.configDir, 'commands.json')
87
+ return JSON.parse(readFileSync(configPath, 'utf8'))
88
+ } catch (error) {
89
+ logger.error(`无法加载命令配置文件: ${join(this.configDir, 'commands.json')}`)
90
+ logger.error(error?.message || String(error))
91
+ process.exit(1)
92
+ }
93
+ }
94
+
95
+ validateLoadedHelpConfig() {
96
+ validateHelpConfig(this.commands, buildStrictHelpValidationContext(this))
97
+ }
98
+
99
+ // 检测并安装依赖
100
+ async ensureDependencies() {
101
+ const nodeModulesPath = join(process.cwd(), 'node_modules')
102
+
103
+ // 检查 node_modules 是否存在且包含关键依赖
104
+ if (!existsSync(nodeModulesPath) || !existsSync(join(nodeModulesPath, '.pnpm'))) {
105
+ logger.warn('检测到依赖未安装,正在自动安装...')
106
+ logger.info('将以 NODE_ENV=development 安装完整依赖(含 devDependencies)')
107
+ try {
108
+ execSync('pnpm install --frozen-lockfile', {
109
+ stdio: 'inherit',
110
+ cwd: process.cwd(),
111
+ env: {
112
+ ...process.env,
113
+ NODE_ENV: 'development', // 确保安装完整依赖(含 devDependencies)
114
+ },
115
+ })
116
+ logger.success('依赖安装完成')
117
+ } catch (error) {
118
+ logger.error('依赖安装失败,请手动执行: pnpm install')
119
+ process.exit(1)
120
+ }
121
+ }
122
+ }
123
+
124
+ requiresProjectDependencies() {
125
+ if (this.command !== 'deploy') return true
126
+ const targetConfig = this.commands?.deploy?.[this.subcommand]
127
+ if (targetConfig?.internal === 'artifact-deploy') return false
128
+ if (!this.flags.artifact) return true
129
+ return targetConfig?.internal !== 'backend-artifact-deploy'
130
+ }
131
+
132
+ // 每次启动时执行的检查流程
133
+ async runStartupChecks() {
134
+ // 0. 跳过 db 命令的启动检查
135
+ // 原因:ensurePrismaClient() 会调用 `dx db generate`,如果不跳过会导致无限递归
136
+ // db 命令本身不需要 Prisma Client 或环境变量验证即可执行
137
+ if (this.command === 'db') return
138
+ // 通用 artifact target 的构建与部署生命周期完全由自身命令定义,
139
+ // 不应隐式安装 pnpm 依赖、生成 Prisma Client 或校验 backend 环境。
140
+ if (
141
+ this.command === 'deploy'
142
+ && this.commands?.deploy?.[this.subcommand]?.internal === 'artifact-deploy'
143
+ ) return
144
+
145
+ // 1. 在 worktree 中自动同步根目录的 .env.*.local 文件
146
+ try {
147
+ const worktreeManager = await this.getWorktreeManager()
148
+ worktreeManager.syncEnvFilesFromMainRoot()
149
+ } catch (error) {
150
+ logger.warn(`自动同步 env 文件失败: ${error.message}`)
151
+ }
152
+
153
+ // 2. 检测 Prisma Client 是否存在,不存在则执行 db generate
154
+ await this.ensurePrismaClient()
155
+
156
+ // 3. 验证环境变量(尊重多种跳过机制)
157
+ // 跳过条件:
158
+ // - 命令配置了 skipEnvValidation: true(如 lint)
159
+ // - 子命令配置了 skipEnvValidation: true(如 build.shared)
160
+ // - 用户指定了 --no-env-check 标志
161
+ // - 环境变量 AI_SKIP_ENV_CHECK=true(CI 场景)
162
+ // - CI 环境(CI=1)- 由 exec.js 中的 executeCommand 进行精细检查
163
+ // - Vercel 构建环境(VERCEL=1)
164
+ const commandConfig = this.getCommandConfig(this.command)
165
+ const subcommandConfig = this.subcommand ? commandConfig?.[this.subcommand] : null
166
+ const isVercelEnv = String(process.env.VERCEL || '').toLowerCase() === '1'
167
+ // 兼容 GitHub Actions (CI=true) 和其他 CI 系统 (CI=1)
168
+ const isCIEnv = ['true', '1'].includes(String(process.env.CI || '').toLowerCase())
169
+ const skipEnvValidation =
170
+ commandConfig?.skipEnvValidation ||
171
+ subcommandConfig?.skipEnvValidation ||
172
+ this.flags.noEnvCheck ||
173
+ String(process.env.AI_SKIP_ENV_CHECK || '').toLowerCase() === 'true' ||
174
+ isCIEnv ||
175
+ isVercelEnv
176
+
177
+ if (!skipEnvValidation) {
178
+ await this.validateEnvVars()
179
+ }
180
+ }
181
+
182
+ // 获取命令配置
183
+ getCommandConfig(command) {
184
+ return this.commands[command]
185
+ }
186
+
187
+ hasEnvProfileConfig() {
188
+ return existsSync(join(this.configDir, 'env-profiles.json'))
189
+ }
190
+
191
+ registerConfiguredCommandHandlers() {
192
+ for (const [commandName, config] of Object.entries(this.commands || {})) {
193
+ if (commandName === 'help') continue
194
+ if (this.commandHandlers[commandName]) continue
195
+ if (!this.isExecutableConfigTree(config)) continue
196
+
197
+ this.commandHandlers[commandName] = args => this.handleConfiguredCommand(commandName, args)
198
+ }
199
+ }
200
+
201
+ isExecutableConfigTree(node) {
202
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return false
203
+ if (node.command || node.internal || node.concurrent || node.sequential) return true
204
+
205
+ return Object.entries(node).some(([key, value]) => {
206
+ if (key === 'help' || key === 'description' || key === 'args') return false
207
+ if (key === 'app' || key === 'ports' || key === 'dangerous') return false
208
+ return this.isExecutableConfigTree(value)
209
+ })
210
+ }
211
+
212
+ async handleConfiguredCommand(commandName, args = []) {
213
+ const environment = this.determineEnvironment()
214
+ const config = this.resolveConfiguredCommand(commandName, args, environment)
215
+
216
+ if (!config) {
217
+ const path = [commandName, ...args].join('.')
218
+ logger.error(`未找到命令配置: ${path}`)
219
+ process.exitCode = 1
220
+ return
221
+ }
222
+
223
+ if (config.dangerous) {
224
+ const operation = config.description || [commandName, ...args].join(' ')
225
+ const confirmed = await confirmManager.confirmDangerous(operation, environment, this.flags.Y)
226
+ if (!confirmed) {
227
+ logger.warn('操作已取消')
228
+ return
229
+ }
230
+ }
231
+
232
+ if (config.concurrent && Array.isArray(config.commands)) {
233
+ await this.handleConcurrentCommands(config.commands, null, environment)
234
+ } else if (config.sequential && Array.isArray(config.commands)) {
235
+ await this.handleSequentialCommands(config.commands, environment)
236
+ } else {
237
+ await this.executeCommand(config)
238
+ }
239
+ }
240
+
241
+ resolveConfiguredCommand(commandName, args = [], environment) {
242
+ let config = this.commands?.[commandName]
243
+ if (!config) return null
244
+
245
+ for (const arg of args) {
246
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return null
247
+ config = config[arg]
248
+ if (!config) return null
249
+ }
250
+
251
+ if (environment && config && typeof config === 'object' && !Array.isArray(config)) {
252
+ const envKey = this.normalizeEnvKey(environment)
253
+ if (config[envKey]) config = config[envKey]
254
+ }
255
+
256
+ return config
257
+ }
258
+
259
+ // 检测并生成 Prisma Client
260
+ async ensurePrismaClient() {
261
+ // 仅对真正安装了 @prisma/client 的项目执行检查。
262
+ // 依据「node_modules/@prisma/client/package.json 是否存在」判断项目是否使用 Prisma,
263
+ // 避免在纯文档站、纯前端等未引入 Prisma 的项目上误触发生成流程,
264
+ // 进而报出形如「未找到 db.generate 命令配置」的误导性错误。
265
+ const prismaClientDir = join(process.cwd(), 'node_modules', '@prisma', 'client')
266
+ if (!existsSync(join(prismaClientDir, 'package.json'))) {
267
+ return
268
+ }
269
+
270
+ // pnpm 结构下检测 @prisma/client 生成的 default.js 文件
271
+ const prismaClientPath = join(prismaClientDir, 'default.js')
272
+
273
+ if (!existsSync(prismaClientPath)) {
274
+ const environment = this.determineEnvironment()
275
+ logger.step('检测到 Prisma Client 未生成,正在生成...')
276
+ try {
277
+ const generateConfig = this.commands?.db?.generate
278
+ if (!generateConfig?.command) {
279
+ throw new Error('未找到 db.generate 命令配置,请检查 dx/config/commands.json')
280
+ }
281
+
282
+ await execManager.executeCommand(generateConfig.command, {
283
+ app: generateConfig.app || 'backend',
284
+ flags: this.createExecutionFlags(environment),
285
+ // Prisma generate 不应卡在环境变量校验上
286
+ skipEnvValidation: true,
287
+ })
288
+ logger.success('Prisma Client 生成完成')
289
+ } catch (error) {
290
+ logger.error('Prisma Client 生成失败')
291
+ logger.error(error?.message || String(error))
292
+ process.exit(1)
293
+ }
294
+ }
295
+ }
296
+
297
+ // 验证环境变量
298
+ async validateEnvVars() {
299
+ const environment = this.determineEnvironment()
300
+
301
+ if (this.envCache?.environment === environment) {
302
+ return this.envCache.layeredEnv
303
+ }
304
+
305
+ try {
306
+ validateEnvironment()
307
+
308
+ const layeredEnv = envManager.collectEnvFromLayers('backend', environment)
309
+ if (envManager.latestEnvWarnings && envManager.latestEnvWarnings.length > 0) {
310
+ envManager.latestEnvWarnings.forEach(message => {
311
+ logger.warn(message)
312
+ })
313
+ }
314
+
315
+ // CI 环境跳过后端环境变量校验(CI 中 build backend 只生成 OpenAPI,不需要数据库连接)
316
+ // 非 CI 环境下,检查 backend 组的环境变量
317
+ if (process.env.CI !== '1') {
318
+ const effectiveEnv = { ...process.env, ...layeredEnv }
319
+ const policy = loadEnvPolicy(envManager.configDir)
320
+ const targetId = resolvePolicyTargetId(policy, 'backend')
321
+ if (!targetId) {
322
+ throw new Error('缺少 appToTarget.backend 配置(dx 内置逻辑需要 backend target)')
323
+ }
324
+ const requiredVars = resolveTargetRequiredVars(policy, targetId, environment)
325
+ if (requiredVars.length > 0) {
326
+ const { valid, missing, placeholders } = envManager.validateRequiredVars(
327
+ requiredVars,
328
+ effectiveEnv,
329
+ )
330
+ if (!valid) {
331
+ const problems = ['环境变量校验未通过']
332
+ if (missing.length > 0) {
333
+ problems.push(`缺少必填环境变量: ${missing.join(', ')}`)
334
+ }
335
+ if (placeholders.length > 0) {
336
+ problems.push(`以下环境变量仍为占位值或空串: ${placeholders.join(', ')}`)
337
+ }
338
+ if (missing.length > 0 || placeholders.length > 0) {
339
+ problems.push(`请在 .env.${environment} / .env.${environment}.local 中补齐配置`)
340
+ }
341
+ throw new Error(problems.join('\n'))
342
+ }
343
+ }
344
+ }
345
+
346
+ this.envCache = { environment, layeredEnv }
347
+ return layeredEnv
348
+ } catch (error) {
349
+ logger.error('环境变量验证失败')
350
+ logger.error(error.message)
351
+ process.exit(1)
352
+ }
353
+ }
354
+
355
+ // 获取环境对应的命令行 flag
356
+ getEnvironmentFlag(environment) {
357
+ switch (environment) {
358
+ case 'production':
359
+ return '--prod'
360
+ case 'staging':
361
+ return '--staging'
362
+ case 'test':
363
+ return '--test'
364
+ case 'e2e':
365
+ return '--e2e'
366
+ case 'development':
367
+ default:
368
+ return '--dev'
369
+ }
370
+ }
371
+
372
+ // 主执行方法
373
+ async run() {
374
+ try {
375
+ if (this.flags.version) {
376
+ console.log(getPackageVersion())
377
+ return
378
+ }
379
+
380
+ // 显示帮助
381
+ if (this.flags.help || !this.command) {
382
+ if (this.flags.help && this.command && this.command !== 'help') {
383
+ showCommandHelp(this.command, this)
384
+ } else {
385
+ showHelp(this)
386
+ }
387
+ return
388
+ }
389
+
390
+ this.validateInputs()
391
+
392
+ // Fail fast for unknown commands before dependency/env startup checks.
393
+ if (this.command && !this.commandHandlers[this.command]) {
394
+ logger.error(`未知命令: ${this.command}`)
395
+ showHelp(this)
396
+ process.exit(1)
397
+ }
398
+
399
+ // Commands that should work without env/dependency checks.
400
+ // - help: printing help should never require env vars.
401
+ // - status: should be available even when env is incomplete.
402
+ // - release: only edits package.json versions.
403
+ const skipStartupChecks = new Set(['help', 'status', 'release'])
404
+ if (this.command === 'env' && this.hasEnvProfileConfig()) skipStartupChecks.add('env')
405
+ if (skipStartupChecks.has(this.command)) {
406
+ await this.routeCommand()
407
+ return
408
+ }
409
+
410
+ // 在执行命令前先校验参数与选项
411
+ if (this.requiresProjectDependencies()) await this.ensureDependencies()
412
+ await this.runStartupChecks()
413
+
414
+ // 设置详细模式
415
+ if (this.flags.verbose) {
416
+ logger.debug('启用详细输出模式')
417
+ }
418
+
419
+ // 路由到对应的命令处理器
420
+ await this.routeCommand()
421
+
422
+ } catch (error) {
423
+ logger.error('命令执行失败')
424
+ logger.error(error.message)
425
+
426
+ if (this.flags.verbose) {
427
+ console.error(error.stack)
428
+ }
429
+
430
+ process.exit(1)
431
+ }
432
+ }
433
+
434
+ // 命令路由
435
+ async routeCommand() {
436
+ const command = getCleanArgs(this.args)[0]
437
+
438
+ const allowedFlags = this.getAllowedFlags(command)
439
+ const consumedFlagValueIndexes = this.validateFlags(command, allowedFlags)
440
+ const cleanArgs = getCleanArgsWithConsumedValues(this.args, consumedFlagValueIndexes)
441
+ const [, ...subArgs] = cleanArgs
442
+
443
+ if (!command) {
444
+ showHelp(this)
445
+ return
446
+ }
447
+
448
+ const handler = this.commandHandlers[command]
449
+ if (!handler) return
450
+
451
+ const result = await handler(subArgs)
452
+ if (result === COMMAND_NOT_HANDLED) {
453
+ if (this.isExecutableConfigTree(this.commands?.[command])) {
454
+ await this.handleConfiguredCommand(command, subArgs)
455
+ return
456
+ }
457
+
458
+ logger.error(`未找到命令: ${[command, ...subArgs].join(' ')}`)
459
+ showCommandHelp(command, this)
460
+ process.exitCode = 1
461
+ }
462
+ }
463
+
464
+ // 校验原始输入,禁止未识别的选项或多余参数
465
+ validateInputs() {
466
+ const cleanArgs = getCleanArgs(this.args)
467
+ const command = cleanArgs[0]
468
+ const allowedFlags = this.getAllowedFlags(command)
469
+ const consumedFlagValueIndexes = this.validateFlags(command, allowedFlags)
470
+
471
+ // 收集所有位置参数(不含命令本身、选项及其值)
472
+ const positionalArgs = []
473
+ let commandConsumed = false
474
+ let afterDoubleDash = false
475
+ for (let i = 0; i < this.args.length; i++) {
476
+ const token = this.args[i]
477
+ if (token === '--') {
478
+ afterDoubleDash = true
479
+ continue
480
+ }
481
+ if (afterDoubleDash) continue
482
+ if (token.startsWith('-')) continue
483
+ if (consumedFlagValueIndexes.has(i)) continue
484
+
485
+ if (!commandConsumed && command) {
486
+ // 跳过命令本身
487
+ commandConsumed = true
488
+ continue
489
+ }
490
+
491
+ positionalArgs.push(token)
492
+ }
493
+
494
+ if (!command) {
495
+ if (positionalArgs.length > 0) {
496
+ this.reportExtraPositionals('全局', positionalArgs)
497
+ }
498
+ return
499
+ }
500
+
501
+ this.validatePositionalArgs(command, positionalArgs)
502
+ }
503
+
504
+ // 获取命令允许的选项
505
+ getAllowedFlags(command) {
506
+ const allowed = new Map()
507
+ const applyDefs = defs => {
508
+ defs?.forEach(({ flag, expectsValue }) => {
509
+ if (!flag) return
510
+ allowed.set(flag, { expectsValue: Boolean(expectsValue) })
511
+ })
512
+ }
513
+
514
+ applyDefs(this.flagDefinitions._global)
515
+ if (command && this.flagDefinitions[command]) {
516
+ applyDefs(this.flagDefinitions[command])
517
+ }
518
+
519
+ return allowed
520
+ }
521
+
522
+ // 校验选项合法性并返回被选项消耗的参数下标集合
523
+ validateFlags(command, allowedFlags) {
524
+ const consumedIndexes = new Set()
525
+ const doubleDashIndex = this.args.indexOf('--')
526
+
527
+ for (let i = 0; i < this.args.length; i++) {
528
+ if (doubleDashIndex !== -1 && i >= doubleDashIndex) break
529
+ const token = this.args[i]
530
+ if (!token.startsWith('-')) continue
531
+
532
+ const spec = allowedFlags.get(token)
533
+ if (!spec) {
534
+ this.reportUnknownFlag(command, token, allowedFlags)
535
+ process.exit(1)
536
+ }
537
+
538
+ if (spec.expectsValue) {
539
+ const next = this.args[i + 1]
540
+ if (next === undefined || next.startsWith('-')) {
541
+ logger.error(`选项 ${token} 需要提供参数值`)
542
+ process.exit(1)
543
+ }
544
+ consumedIndexes.add(i + 1)
545
+ }
546
+ }
547
+
548
+ return consumedIndexes
549
+ }
550
+
551
+ // 根据命令定义校验位置参数
552
+ validatePositionalArgs(command, positionalArgs) {
553
+ const ensureMax = (max) => {
554
+ if (positionalArgs.length > max) {
555
+ this.reportExtraPositionals(command, positionalArgs.slice(max))
556
+ }
557
+ }
558
+
559
+ switch (command) {
560
+ case 'help':
561
+ ensureMax(1)
562
+ break
563
+ case 'build': {
564
+ this.validateBuildPositionals(positionalArgs)
565
+ if (positionalArgs.length >= 2 && this.isEnvironmentToken(positionalArgs[1])) {
566
+ this.reportEnvironmentFlagRequired(command, positionalArgs[1], positionalArgs)
567
+ }
568
+ ensureMax(1)
569
+ break
570
+ }
571
+ case 'package': {
572
+ if (positionalArgs.length >= 2 && this.isEnvironmentToken(positionalArgs[1])) {
573
+ this.reportEnvironmentFlagRequired(command, positionalArgs[1], positionalArgs)
574
+ }
575
+ ensureMax(1)
576
+ break
577
+ }
578
+ case 'db': {
579
+ if (positionalArgs.length === 0) return
580
+ const action = positionalArgs[0]
581
+ const extras = positionalArgs.slice(1)
582
+ const envToken = extras.find(token => this.isEnvironmentToken(token))
583
+ if (envToken) {
584
+ this.reportEnvironmentFlagRequired(command, envToken, positionalArgs)
585
+ }
586
+
587
+ if (action === 'migrate') {
588
+ if (extras.length > 0) {
589
+ this.reportExtraPositionals(command, extras)
590
+ }
591
+ } else if (action === 'deploy') {
592
+ if (extras.length > 0) {
593
+ this.reportExtraPositionals(command, extras)
594
+ }
595
+ } else if (action === 'script') {
596
+ // script 子命令需要一个脚本名称参数
597
+ if (extras.length > 1) {
598
+ this.reportExtraPositionals(command, extras.slice(1))
599
+ }
600
+ } else if (extras.length > 0) {
601
+ this.reportExtraPositionals(command, extras)
602
+ }
603
+ break
604
+ }
605
+ case 'test':
606
+ this.validateTestPositionals(positionalArgs)
607
+ if ((positionalArgs[0] || 'e2e') !== 'unit') {
608
+ ensureMax(3)
609
+ }
610
+ break
611
+ case 'worktree': {
612
+ if (positionalArgs.length === 0) return
613
+ const action = positionalArgs[0]
614
+ if (action === 'del') {
615
+ return
616
+ }
617
+ if (action === 'make') {
618
+ ensureMax(3)
619
+ break
620
+ }
621
+ if (action === 'list' || action === 'clean') {
622
+ ensureMax(1)
623
+ break
624
+ }
625
+ break
626
+ }
627
+ case 'start': {
628
+ this.validateStartPositionals(positionalArgs)
629
+ const extras = positionalArgs.slice(1)
630
+ const envToken = extras.find(token => this.isEnvironmentToken(token))
631
+ if (envToken) {
632
+ this.reportEnvironmentFlagRequired(command, envToken, positionalArgs)
633
+ }
634
+ ensureMax(1)
635
+ break
636
+ }
637
+ case 'lint':
638
+ ensureMax(0)
639
+ break
640
+ case 'clean':
641
+ ensureMax(1)
642
+ break
643
+ case 'cache':
644
+ ensureMax(1)
645
+ break
646
+ case 'status':
647
+ ensureMax(0)
648
+ break
649
+ default:
650
+ // 默认放行,具体命令内部再校验
651
+ break
652
+ }
653
+ }
654
+
655
+ isEnvironmentToken(token) {
656
+ if (!token) return false
657
+ const value = String(token).toLowerCase()
658
+ return (
659
+ value === 'dev' ||
660
+ value === 'prod' ||
661
+ value === 'staging' ||
662
+ value === 'test' ||
663
+ value === 'e2e'
664
+ )
665
+ }
666
+
667
+ reportEnvironmentFlagRequired(command, token, positionalArgs = []) {
668
+ const normalizedFlag = this.getEnvironmentFlagExample(token)
669
+ logger.error(`命令 ${command} 不再支持通过位置参数指定环境: ${token}`)
670
+ logger.info('请使用带前缀的环境标志,例如 --dev、--staging、--prod、--test 或 --e2e。')
671
+ const suggestion = normalizedFlag
672
+ ? this.buildEnvironmentSuggestion(command, normalizedFlag, positionalArgs, token)
673
+ : null
674
+ if (suggestion) {
675
+ logger.info(`建议命令: ${suggestion}`)
676
+ } else if (normalizedFlag) {
677
+ logger.info(`示例: ${this.invocation} ${command} ... ${normalizedFlag}`)
678
+ }
679
+ logger.info('未显式指定环境时将默认使用 --dev。')
680
+ process.exit(1)
681
+ }
682
+
683
+ getEnvironmentFlagExample(token) {
684
+ switch (String(token || '').toLowerCase()) {
685
+ case 'dev':
686
+ case 'development':
687
+ return '--dev'
688
+ case 'prod':
689
+ case 'production':
690
+ return '--prod'
691
+ case 'staging':
692
+ return '--staging'
693
+ case 'test':
694
+ return '--test'
695
+ case 'e2e':
696
+ return '--e2e'
697
+ default:
698
+ return null
699
+ }
700
+ }
701
+
702
+ buildEnvironmentSuggestion(command, normalizedFlag, positionalArgs, token) {
703
+ const parts = [this.invocation, command]
704
+ const rest = Array.isArray(positionalArgs) ? [...positionalArgs] : []
705
+ if (rest.length > 0) {
706
+ const matchIndex = rest.findIndex(arg => String(arg).toLowerCase() === String(token).toLowerCase())
707
+ if (matchIndex !== -1) rest.splice(matchIndex, 1)
708
+ }
709
+ if (!rest.includes(normalizedFlag)) {
710
+ rest.push(normalizedFlag)
711
+ }
712
+ return parts.concat(rest).join(' ')
713
+ }
714
+
715
+ validateTestPositionals(positionalArgs) {
716
+ const [type = 'e2e', target = 'all', testPath] = positionalArgs
717
+
718
+ if (type !== 'e2e') return
719
+
720
+ if (target === 'all') {
721
+ logger.error('dx test e2e all 不受支持,请指定 target 和测试文件或目录路径')
722
+ process.exit(1)
723
+ }
724
+
725
+ const testConfig = this.commands?.test?.[type]?.[target]
726
+ if (!testConfig) return
727
+ if (!testConfig.requiresPath) return
728
+
729
+ if (!testPath) {
730
+ logger.error(`dx test e2e ${target} 必须提供测试文件或目录路径`)
731
+ logger.info(`示例: ${this.invocation} test e2e ${target} apps/${target}/e2e/health`)
732
+ process.exit(1)
733
+ }
734
+
735
+ if (testPath === 'all') {
736
+ logger.error(`dx test e2e ${target} 不支持 all,必须提供测试文件或目录路径`)
737
+ process.exit(1)
738
+ }
739
+ }
740
+
741
+ validateStartPositionals(positionalArgs) {
742
+ const service = positionalArgs[0] || 'development'
743
+ const environment = this.determineEnvironment()
744
+ const envKey = this.normalizeEnvKey(environment)
745
+
746
+ if (service === 'development' && envKey !== 'development') {
747
+ logger.error('dx start 在未指定服务时仅允许使用开发环境')
748
+ logger.info(`示例: ${this.invocation} start --dev`)
749
+ logger.info(`示例: ${this.invocation} start backend --prod`)
750
+ process.exit(1)
751
+ }
752
+
753
+ const startConfig = this.commands?.start?.[service]
754
+ if (!startConfig) return
755
+
756
+ // 对 start 下的单层 command 配置,默认视为开发态目标,仅允许 --dev。
757
+ if (startConfig.command && envKey !== 'development') {
758
+ logger.error(`启动目标 ${service} 仅支持开发环境`)
759
+ logger.info(`示例: ${this.invocation} start ${service} --dev`)
760
+ process.exit(1)
761
+ }
762
+ }
763
+
764
+ validateBuildPositionals(positionalArgs) {
765
+ const target = positionalArgs[0] || 'all'
766
+ const environment = this.determineEnvironment()
767
+ const envKey = this.normalizeEnvKey(environment)
768
+ const explicitEnv = Boolean(
769
+ this.flags.dev || this.flags.prod || this.flags.staging || this.flags.test || this.flags.e2e,
770
+ )
771
+
772
+ if (!explicitEnv) return
773
+
774
+ const buildConfig = this.commands?.build?.[target]
775
+ if (!buildConfig || buildConfig.command) return
776
+
777
+ const supportsCurrentEnv = Boolean(buildConfig[envKey])
778
+ if (supportsCurrentEnv) return
779
+
780
+ const envFlag = this.getEnvironmentFlagExample(envKey) || `--${envKey}`
781
+ logger.error(`构建目标 ${target} 不支持 ${envFlag} 环境`)
782
+ logger.info('显式传入环境标志时,必须是该 target 实际支持的环境。')
783
+ const available = ['development', 'staging', 'production', 'test', 'e2e']
784
+ .filter(key => key in buildConfig)
785
+ .map(key => this.getEnvironmentFlagExample(key) || `--${key}`)
786
+ if (available.length > 0) {
787
+ logger.info(`支持的环境: ${available.join(', ')}`)
788
+ logger.info(`示例: ${this.invocation} build ${target} ${available[0]}`)
789
+ if (available.length > 1) {
790
+ logger.info(`示例: ${this.invocation} build ${target} ${available[1]}`)
791
+ }
792
+ }
793
+ process.exit(1)
794
+ }
795
+
796
+ reportExtraPositionals(command, extras) {
797
+ const list = extras.join(', ')
798
+ if (command === '全局') {
799
+ logger.error(`检测到未识别的参数: ${list}`)
800
+ } else {
801
+ logger.error(`命令 ${command} 存在未识别的额外参数: ${list}`)
802
+ }
803
+ const hint = command && command !== '全局' ? `${this.invocation} help ${command}` : `${this.invocation} --help`
804
+ logger.info(`提示: 执行 ${hint} 或 ${this.invocation} --help 查看命令用法`)
805
+ if (command && command !== '全局') {
806
+ logger.info(`示例: ${this.invocation} ${command} --help`)
807
+ }
808
+ process.exit(1)
809
+ }
810
+
811
+ reportUnknownFlag(command, flag, allowedFlags) {
812
+ logger.error(`检测到未识别的选项: ${flag}`)
813
+ const supported = Array.from(allowedFlags.keys())
814
+ if (supported.length > 0) {
815
+ logger.info(`支持的选项: ${supported.join(', ')}`)
816
+ } else if (command) {
817
+ logger.info(`命令 ${command} 不接受额外选项`)
818
+ }
819
+ const hint = command ? `${this.invocation} help ${command}` : `${this.invocation} --help`
820
+ logger.info(`提示: 执行 ${hint} 或 ${this.invocation} --help 查看命令用法`)
821
+ if (command) {
822
+ logger.info(`示例: ${this.invocation} ${command} --help`)
823
+ }
824
+ }
825
+
826
+ // 校验是否在仓库根目录执行
827
+ ensureRepoRoot() {
828
+ const cwd = process.cwd()
829
+
830
+ const markers = ['pnpm-workspace.yaml', 'package.json', 'apps']
831
+ const missing = markers.filter(p => !existsSync(join(cwd, p)))
832
+ if (missing.length) {
833
+ logger.error(`请从仓库根目录运行此命令。缺少标识文件/目录: ${missing.join(', ')}`)
834
+ process.exit(1)
835
+ }
836
+
837
+ const commandsPath = join(this.configDir, 'commands.json')
838
+ if (!existsSync(commandsPath)) {
839
+ logger.error(`未找到命令配置文件: ${commandsPath}`)
840
+ process.exit(1)
841
+ }
842
+ }
843
+
844
+ async getWorktreeManager() {
845
+ if (!this.worktreeManager) {
846
+ const { default: worktreeManager } = await import('../worktree.js')
847
+ this.worktreeManager = worktreeManager
848
+ }
849
+ return this.worktreeManager
850
+ }
851
+
852
+ // 并发命令处理
853
+ async handleConcurrentCommands(commandPaths, baseCommand, environment) {
854
+ const commands = []
855
+
856
+ for (const path of commandPaths) {
857
+ const config = this.resolveCommandPath(
858
+ path,
859
+ baseCommand,
860
+ this.normalizeEnvKey(environment)
861
+ )
862
+ if (!config) {
863
+ logger.warn(`未解析到命令配置: ${path} (${environment || '-'})`)
864
+ continue
865
+ }
866
+ commands.push({
867
+ command: config.command,
868
+ options: {
869
+ app: config.app,
870
+ ports: config.ports,
871
+ flags: this.flags,
872
+ },
873
+ })
874
+ }
875
+
876
+ if (commands.length > 0) {
877
+ await execManager.executeConcurrent(commands)
878
+ }
879
+ }
880
+
881
+ // 顺序命令处理
882
+ async handleSequentialCommands(commandPaths, environment) {
883
+ for (const path of commandPaths) {
884
+ const config = this.resolveCommandPath(path, null, this.normalizeEnvKey(environment))
885
+ if (!config) {
886
+ logger.warn(`未解析到命令配置: ${path} (${environment || '-'})`)
887
+ continue
888
+ }
889
+
890
+ // 支持在顺序执行中嵌套并发/顺序配置
891
+ if (config.concurrent && Array.isArray(config.commands)) {
892
+ await this.handleConcurrentCommands(config.commands, null, environment)
893
+ } else if (config.sequential && Array.isArray(config.commands)) {
894
+ await this.handleSequentialCommands(config.commands, environment)
895
+ } else {
896
+ await this.executeCommand(config)
897
+ }
898
+ }
899
+ }
900
+
901
+ // 解析命令路径
902
+ resolveCommandPath(path, baseCommand, environment) {
903
+ const parts = path.split('.')
904
+ let config = this.commands
905
+
906
+ for (const part of parts) {
907
+ config = config[part]
908
+ if (!config) break
909
+ }
910
+
911
+ // 如果有环境参数,尝试获取对应环境的配置
912
+ if (environment && config) {
913
+ const envKey = this.normalizeEnvKey(environment)
914
+ if (config[envKey]) config = config[envKey]
915
+ }
916
+
917
+ return config
918
+ }
919
+
920
+ collectStartPorts(service, startConfig, envKey) {
921
+ const portSet = new Set()
922
+
923
+ if (startConfig && Array.isArray(startConfig.ports)) {
924
+ startConfig.ports.forEach(port => {
925
+ this.addPortToSet(portSet, port)
926
+ })
927
+ }
928
+
929
+ return Array.from(portSet)
930
+ }
931
+
932
+ addPortToSet(target, port) {
933
+ const numeric = Number(port)
934
+ if (Number.isFinite(numeric) && numeric > 0) {
935
+ target.add(numeric)
936
+ }
937
+ }
938
+
939
+ // 执行单个命令
940
+ async executeCommand(config, overrideFlags) {
941
+ if (!config) {
942
+ logger.error('无效的命令配置')
943
+ return
944
+ }
945
+
946
+ const withTempEnv = async fn => {
947
+ const envPatch = config?.env && typeof config.env === 'object' ? config.env : null
948
+ if (!envPatch) return await fn()
949
+
950
+ const previous = {}
951
+ for (const [key, value] of Object.entries(envPatch)) {
952
+ previous[key] = Object.prototype.hasOwnProperty.call(process.env, key)
953
+ ? process.env[key]
954
+ : undefined
955
+ process.env[key] = String(value)
956
+ }
957
+
958
+ try {
959
+ return await fn()
960
+ } finally {
961
+ for (const [key, oldValue] of Object.entries(previous)) {
962
+ if (oldValue === undefined) delete process.env[key]
963
+ else process.env[key] = oldValue
964
+ }
965
+ }
966
+ }
967
+
968
+ // internal runners (for projects that only ship scripts/config)
969
+ if (config.internal) {
970
+ if (config.internal === 'sdk-build') {
971
+ await withTempEnv(async () => {
972
+ const { runSdkBuild } = await import('../sdk-build.js')
973
+ await runSdkBuild(Array.isArray(config.args) ? config.args : [])
974
+ })
975
+ return
976
+ }
977
+ if (config.internal === 'backend-package') {
978
+ await withTempEnv(async () => {
979
+ const { runBackendPackage } = await import('../backend-package.js')
980
+ await runBackendPackage(Array.isArray(config.args) ? config.args : [])
981
+ })
982
+ return
983
+ }
984
+
985
+ if (config.internal === 'start-dev') {
986
+ await withTempEnv(async () => {
987
+ const { runStartDev } = await import('../start-dev.js')
988
+ await runStartDev(Array.isArray(config.args) ? config.args : [])
989
+ })
990
+ return
991
+ }
992
+
993
+ if (config.internal === 'pm2-stack') {
994
+ await withTempEnv(async () => {
995
+ const { runPm2Stack } = await import('./commands/stack.js')
996
+ const stackConfig = config.stack && typeof config.stack === 'object' ? config.stack : {}
997
+ await runPm2Stack(stackConfig)
998
+ })
999
+ return
1000
+ }
1001
+
1002
+ throw new Error(`未知 internal runner: ${config.internal}`)
1003
+ }
1004
+
1005
+ if (!config.command) {
1006
+ logger.error('无效的命令配置: 缺少 command/internal')
1007
+ return
1008
+ }
1009
+
1010
+ const effectiveFlags = overrideFlags || this.flags
1011
+ let rawCommand = String(config.command).trim()
1012
+ if (effectiveFlags.verbose) {
1013
+ rawCommand = appendNxVerboseFlag(rawCommand)
1014
+ }
1015
+
1016
+ const options = {
1017
+ app: config.app,
1018
+ flags: effectiveFlags,
1019
+ cwd: config.cwd,
1020
+ ports: config.ports || [],
1021
+ // 允许上游在 config.env 中注入环境变量(例如 NX_CACHE=false)
1022
+ env: config.env || {},
1023
+ skipEnvValidation: Boolean(config.skipEnvValidation),
1024
+ forcePortCleanup: Boolean(config.forcePortCleanup),
1025
+ }
1026
+
1027
+ await execManager.executeCommand(rawCommand, options)
1028
+ }
1029
+
1030
+ // 确定环境
1031
+ determineEnvironment() {
1032
+ return envManager.detectEnvironment(this.flags)
1033
+ }
1034
+
1035
+ // 规范化环境键到命令配置使用的命名(development/staging/production/test/e2e)
1036
+ normalizeEnvKey(env) {
1037
+ switch (String(env || '').toLowerCase()) {
1038
+ case 'development':
1039
+ return 'development'
1040
+ case 'production':
1041
+ return 'production'
1042
+ case 'staging':
1043
+ return 'staging'
1044
+ case 'test':
1045
+ return 'test'
1046
+ case 'e2e':
1047
+ return 'e2e'
1048
+ default:
1049
+ return env
1050
+ }
1051
+ }
1052
+
1053
+ createExecutionFlags(environment) {
1054
+ const envKey = this.normalizeEnvKey(environment)
1055
+ const execFlags = { ...this.flags }
1056
+
1057
+ ;['dev', 'prod', 'staging', 'test', 'e2e'].forEach(key => {
1058
+ delete execFlags[key]
1059
+ })
1060
+
1061
+ if (envKey === 'production') execFlags.prod = true
1062
+ else if (envKey === 'development') execFlags.dev = true
1063
+ else if (envKey === 'test') execFlags.test = true
1064
+ else if (envKey === 'e2e') execFlags.e2e = true
1065
+ else if (envKey === 'staging') execFlags.staging = true
1066
+
1067
+ return execFlags
1068
+ }
1069
+
1070
+ }
1071
+
1072
+ export { DxCli }