@ranger1/dx 0.1.9 → 0.1.11

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.
package/README.md CHANGED
@@ -6,10 +6,10 @@
6
6
 
7
7
  ## 安装
8
8
 
9
- 全局安装(推荐):
9
+ 必须全局安装,并始终使用最新版本:
10
10
 
11
11
  ```bash
12
- pnpm add -g @ranger1/dx
12
+ pnpm add -g @ranger1/dx@latest
13
13
  ```
14
14
 
15
15
  安装后即可在任意目录使用:
@@ -19,11 +19,10 @@ dx --help
19
19
  dx status
20
20
  ```
21
21
 
22
- 项目内安装(可选,如果你更希望锁定版本):
22
+ 升级到最新版本:
23
23
 
24
24
  ```bash
25
- pnpm add -D @ranger1/dx
26
- pnpm exec dx --help
25
+ pnpm update -g @ranger1/dx
27
26
  ```
28
27
 
29
28
  ## 使用条件(必须满足)
@@ -32,7 +31,7 @@ pnpm exec dx --help
32
31
  - 包管理器:pnpm(dx 内部会调用 `pnpm`)
33
32
  - 构建系统:Nx(dx 默认命令配置里大量使用 `npx nx ...`)
34
33
  - 环境加载:建议项目依赖 `dotenv-cli`(dx 会用 `pnpm exec dotenv ...` 包裹命令来注入 `.env.*`)
35
- - 项目结构:默认按 `apps/backend` / `apps/front` / `apps/admin-front` / `apps/sdk` 这类布局编写命令配置
34
+ - 项目结构:推荐按 `apps/backend` / `apps/front` / `apps/admin-front` 这类布局组织;如有自定义目录结构,请通过 `dx/config/commands.json` 适配
36
35
 
37
36
  如果你的 monorepo 不完全一致,也能用:关键是你在 `dx/config/commands.json` 里把命令写成适配你项目的形式。
38
37
 
@@ -123,7 +122,7 @@ DX_CONFIG_DIR=/path/to/your-repo/dx/config dx status
123
122
 
124
123
  target(端)不写死,由 `env-policy.jsonc.targets` 定义;`commands.json` 里的 `app` 通过 `env-policy.jsonc.appToTarget` 映射到某个 target。
125
124
 
126
- 注:若未提供 `env-policy.jsonc`,dx 会继续使用旧的 `required-env.jsonc` / `local-env-allowlist.jsonc` / `exempted-keys.jsonc` 逻辑(兼容模式)。
125
+ 注:`env-policy.jsonc` 为必需配置;未提供时 dx 将直接报错。
127
126
 
128
127
  ## 示例工程
129
128
 
@@ -159,6 +158,15 @@ dx test e2e backend
159
158
  - 需要的前置构建(例如 `shared`、`api-contracts`、OpenAPI 导出、后端构建等)应由项目自己的 Nx 依赖图(`dependsOn`/项目依赖)或 Vercel 的 `buildCommand` 负责。
160
159
  - 这样 dx deploy 不会强依赖 `apps/sdk` 等目录结构,更容易适配不同 monorepo。
161
160
 
161
+ ## 依赖关系约定
162
+
163
+ dx 不负责管理「工程之间的构建依赖关系」。如果多个工程之间存在依赖(例如 `front/admin` 依赖 `shared` 或 `api-contracts`),必须由 Nx 的依赖图来表达并自动拉起:
164
+
165
+ - 使用 Nx 的项目依赖(基于 import graph 或 `implicitDependencies`)
166
+ - 使用 `nx.json` 的 `targetDefaults.dependsOn` / `targetDependencies`
167
+
168
+ dx 只会执行你在 `dx/config/commands.json` 中配置的命令,不会在执行过程中额外硬编码插入依赖构建。
169
+
162
170
  ## 给 Nx target 注入版本信息(可选)
163
171
 
164
172
  本包提供 `dx-with-version-env`,用于在 `nx:run-commands` 中注入版本/sha/构建时间等环境变量:
@@ -176,7 +184,7 @@ dx test e2e backend
176
184
  当前版本面向 pnpm + nx 的 monorepo,默认假设:
177
185
 
178
186
  - 使用 pnpm + nx
179
- - 项目布局包含 `apps/backend`、`apps/front`、`apps/admin-front`、`apps/sdk`(如果你的命令配置不依赖这些目录,可自行调整)
187
+ - 项目布局包含 `apps/backend`、`apps/front`、`apps/admin-front`(如有差异,通过 `dx/config/commands.json` 适配)
180
188
  - 版本注入脚本 `dx-with-version-env` 默认支持 app: `backend` / `front` / `admin`
181
189
 
182
190
  ## 发布到 npm(准备工作)
@@ -239,18 +239,11 @@ class BackendPackager {
239
239
  async prepareEnvSnapshot() {
240
240
  logger.info('校验并快照环境变量')
241
241
  const policy = loadEnvPolicy(envManager.configDir)
242
- let requiredVars = []
243
- if (policy) {
244
- const targetId = resolvePolicyTargetId(policy, 'backend')
245
- if (!targetId) {
246
- throw new Error(
247
- 'env-policy.jsonc 已启用,但缺少 appToTarget.backend 配置(dx 内置逻辑需要 backend target)',
248
- )
249
- }
250
- requiredVars = resolveTargetRequiredVars(policy, targetId, this.layerEnv)
251
- } else {
252
- requiredVars = envManager.getRequiredEnvVars(this.layerEnv, 'backend')
242
+ const targetId = resolvePolicyTargetId(policy, 'backend')
243
+ if (!targetId) {
244
+ throw new Error('缺少 appToTarget.backend 配置(dx 内置逻辑需要 backend target)')
253
245
  }
246
+ const requiredVars = resolveTargetRequiredVars(policy, targetId, this.layerEnv)
254
247
  const collected = envManager.collectEnvFromLayers('backend', this.layerEnv)
255
248
  const effectiveEnv = { ...collected, ...process.env }
256
249
  const { valid, missing, placeholders } = envManager.validateRequiredVars(
@@ -0,0 +1,168 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { spawn, spawnSync } from 'node:child_process'
4
+ import { logger } from '../../logger.js'
5
+ import { confirmManager } from '../../confirm.js'
6
+ import { getPassthroughArgs } from '../args.js'
7
+
8
+ function ensureOpencodeAvailable() {
9
+ const result = spawnSync('opencode', ['--version'], { stdio: 'ignore' })
10
+ if (result?.error?.code === 'ENOENT') {
11
+ return { ok: false, reason: 'missing' }
12
+ }
13
+ return { ok: true }
14
+ }
15
+
16
+ function resolveAiConfig(cli, name) {
17
+ const raw = cli.commands?.ai?.[name]
18
+ if (!raw) return null
19
+
20
+ const environment = cli.determineEnvironment()
21
+ const envKey = cli.normalizeEnvKey(environment)
22
+ let config = raw
23
+
24
+ // 允许按环境分支(保持与 build/start/export 一致)
25
+ if (typeof config === 'object' && config && !config.promptFile && !config.command) {
26
+ if (config[envKey]) config = config[envKey]
27
+ else if (envKey === 'staging' && config.prod) config = config.prod
28
+ else config = config.dev || config
29
+ }
30
+
31
+ return config
32
+ }
33
+
34
+ export async function handleAi(cli, args = []) {
35
+ const name = args[0]
36
+ if (!name) {
37
+ const names = Object.keys(cli.commands?.ai || {})
38
+ logger.error('请指定 ai 任务名称')
39
+ logger.info(`用法: ${cli.invocation} ai <name> [-- <opencode flags>...]`)
40
+ if (names.length > 0) {
41
+ logger.info(`可用任务: ${names.join(', ')}`)
42
+ }
43
+ process.exitCode = 1
44
+ return
45
+ }
46
+
47
+ const config = resolveAiConfig(cli, name)
48
+ if (!config) {
49
+ const names = Object.keys(cli.commands?.ai || {})
50
+ logger.error(`未找到 ai 任务配置: ${name}`)
51
+ if (names.length > 0) {
52
+ logger.info(`可用任务: ${names.join(', ')}`)
53
+ }
54
+ process.exitCode = 1
55
+ return
56
+ }
57
+
58
+ const availability = ensureOpencodeAvailable()
59
+ if (!availability.ok) {
60
+ logger.error('未找到 opencode 可执行文件(PATH 中不存在 opencode)')
61
+ logger.info('请先安装并确保 `opencode --version` 可用')
62
+ process.exitCode = 1
63
+ return
64
+ }
65
+
66
+ const promptFile = config?.promptFile
67
+ if (!promptFile) {
68
+ logger.error(`ai.${name} 缺少 promptFile 配置(指向一个 .md 文件)`)
69
+ process.exitCode = 1
70
+ return
71
+ }
72
+
73
+ const promptPath = resolve(process.cwd(), String(promptFile))
74
+ if (!existsSync(promptPath)) {
75
+ logger.error(`未找到提示词文件: ${promptFile}`)
76
+ logger.info(`解析后的路径: ${promptPath}`)
77
+ process.exitCode = 1
78
+ return
79
+ }
80
+
81
+ let promptText = ''
82
+ try {
83
+ promptText = readFileSync(promptPath, 'utf8')
84
+ } catch (error) {
85
+ logger.error(`读取提示词文件失败: ${promptFile}`)
86
+ logger.error(error?.message || String(error))
87
+ process.exitCode = 1
88
+ return
89
+ }
90
+
91
+ // 固定全权限:这会让 opencode 在当前目录拥有 bash/edit 等工具的自动执行权
92
+ if (!cli.flags.Y) {
93
+ const confirmed = await confirmManager.confirmDangerous(
94
+ `ai.${name}(将以 OPENCODE_PERMISSION="allow" 运行,全权限)`,
95
+ '当前目录',
96
+ false,
97
+ )
98
+ if (!confirmed) {
99
+ logger.info('操作已取消')
100
+ return
101
+ }
102
+ }
103
+
104
+ const model = config?.model ? String(config.model) : null
105
+ const agent = config?.agent ? String(config.agent) : null
106
+ const format = config?.format ? String(config.format) : null
107
+ const attach = config?.attach ? String(config.attach) : null
108
+
109
+ const passthrough = getPassthroughArgs(cli.args)
110
+ const configPassthrough = Array.isArray(config?.passthrough)
111
+ ? config.passthrough.map(v => String(v))
112
+ : []
113
+
114
+ const opencodeArgs = ['run']
115
+ if (model) opencodeArgs.push('--model', model)
116
+ if (agent) opencodeArgs.push('--agent', agent)
117
+ if (format) opencodeArgs.push('--format', format)
118
+ if (attach) opencodeArgs.push('--attach', attach)
119
+ if (configPassthrough.length > 0) opencodeArgs.push(...configPassthrough)
120
+ if (Array.isArray(passthrough) && passthrough.length > 0) opencodeArgs.push(...passthrough)
121
+ opencodeArgs.push(promptText)
122
+
123
+ logger.step(`ai ${name}`)
124
+ logger.command(`opencode ${opencodeArgs.filter(a => a !== promptText).join(' ')} <prompt-from-file>`)
125
+
126
+ await new Promise(resolvePromise => {
127
+ const child = spawn('opencode', opencodeArgs, {
128
+ cwd: process.cwd(),
129
+ stdio: 'inherit',
130
+ env: {
131
+ ...process.env,
132
+ // OpenCode expects OPENCODE_PERMISSION to be JSON (it JSON.parse's the value).
133
+ OPENCODE_PERMISSION: '"allow"',
134
+ },
135
+ })
136
+
137
+ const forwardSignal = signal => {
138
+ try {
139
+ child.kill(signal)
140
+ } catch {}
141
+ }
142
+
143
+ const onSigint = () => forwardSignal('SIGINT')
144
+ const onSigterm = () => forwardSignal('SIGTERM')
145
+ process.on('SIGINT', onSigint)
146
+ process.on('SIGTERM', onSigterm)
147
+
148
+ const cleanup = () => {
149
+ process.off('SIGINT', onSigint)
150
+ process.off('SIGTERM', onSigterm)
151
+ }
152
+
153
+ child.on('error', error => {
154
+ cleanup()
155
+ logger.error(error?.message || String(error))
156
+ process.exitCode = 1
157
+ resolvePromise()
158
+ })
159
+
160
+ child.on('exit', code => {
161
+ cleanup()
162
+ if (typeof code === 'number' && code !== 0) {
163
+ process.exitCode = code
164
+ }
165
+ resolvePromise()
166
+ })
167
+ })
168
+ }
package/lib/cli/dx-cli.js CHANGED
@@ -33,6 +33,7 @@ import { handlePackage } from './commands/package.js'
33
33
  import { handleExport } from './commands/export.js'
34
34
  import { handleContracts } from './commands/contracts.js'
35
35
  import { handleRelease } from './commands/release.js'
36
+ import { handleAi } from './commands/ai.js'
36
37
 
37
38
  class DxCli {
38
39
  constructor(options = {}) {
@@ -67,6 +68,7 @@ class DxCli {
67
68
  export: args => handleExport(this, args),
68
69
  contracts: args => handleContracts(this, args),
69
70
  release: args => handleRelease(this, args),
71
+ ai: args => handleAi(this, args),
70
72
  }
71
73
 
72
74
  this.flagDefinitions = FLAG_DEFINITIONS
@@ -221,18 +223,11 @@ class DxCli {
221
223
  if (process.env.CI !== '1') {
222
224
  const effectiveEnv = { ...process.env, ...layeredEnv }
223
225
  const policy = loadEnvPolicy(envManager.configDir)
224
- let requiredVars = []
225
- if (policy) {
226
- const targetId = resolvePolicyTargetId(policy, 'backend')
227
- if (!targetId) {
228
- throw new Error(
229
- 'env-policy.jsonc 已启用,但缺少 appToTarget.backend 配置(dx 内置逻辑需要 backend target)',
230
- )
231
- }
232
- requiredVars = resolveTargetRequiredVars(policy, targetId, environment)
233
- } else {
234
- requiredVars = envManager.getRequiredEnvVars(environment, 'backend')
226
+ const targetId = resolvePolicyTargetId(policy, 'backend')
227
+ if (!targetId) {
228
+ throw new Error('缺少 appToTarget.backend 配置(dx 内置逻辑需要 backend target)')
235
229
  }
230
+ const requiredVars = resolveTargetRequiredVars(policy, targetId, environment)
236
231
  if (requiredVars.length > 0) {
237
232
  const { valid, missing, placeholders } = envManager.validateRequiredVars(
238
233
  requiredVars,
@@ -304,7 +299,8 @@ class DxCli {
304
299
  // - help: printing help should never require env vars.
305
300
  // - status: should be available even when env is incomplete.
306
301
  // - release: only edits package.json versions.
307
- const skipStartupChecks = new Set(['help', 'status', 'release'])
302
+ // - ai: wraps external opencode CLI, should not require project deps/prisma/env.
303
+ const skipStartupChecks = new Set(['help', 'status', 'release', 'ai'])
308
304
  if (skipStartupChecks.has(this.command)) {
309
305
  await this.routeCommand()
310
306
  return
package/lib/cli/help.js CHANGED
@@ -2,115 +2,124 @@ import { getPackageVersion } from '../version.js'
2
2
 
3
3
  export function showHelp() {
4
4
  const version = getPackageVersion()
5
- console.log(`
6
- DX CLI v${version} - 统一开发环境管理工具
7
-
8
- 用法:
9
- dx <命令> [选项] [参数...]
10
-
11
- 命令:
12
- start [service] [环境标志] 启动/桥接服务
13
- service: backend, front, admin, all, dev, stack, stagewise-front, stagewise-admin (默认: dev)
14
- stack: PM2 交互式服务栈管理(推荐)- 同时启动三个服务并提供交互式命令
15
- 环境标志: --dev, --staging, --prod, --test, --e2e(支持别名 --development、--production 等)
16
- 说明: 传入 --staging 时会加载 '.env.staging(.local)' 层,同时复用生产构建/启动流程
17
-
18
- build [target] [环境标志] 构建应用
19
- target: backend, shared, front, admin, mobile, all, sdk, affected (默认: all)
20
- 环境标志: --dev, --staging, --prod, --test, --e2e(未指定时默认 --dev)
21
-
22
- deploy <target> [环境标志] 部署前端到 Vercel
23
- target: front, admin, telegram-bot, all
24
- 环境标志: --dev, --staging, --prod(默认 --staging)
25
-
26
- install 安装依赖(使用 frozen-lockfile 确保版本一致)
27
-
28
- package backend [环境标志] 构建后端部署包(生成 backend-<version>-<sha>.tar.gz)
29
- 环境标志: --dev, --staging, --prod, --test, --e2e(默认 --dev)
30
- 产物位置: dist/backend/backend-*.tar.gz
31
- 内含: dist/、node_modules(生产依赖)、prisma/、config/.env.runtime、bin/start.sh
32
-
33
- db [action] [环境标志] 数据库操作
34
- action: generate, migrate, deploy, reset, seed, format, script
35
- 用法示例:
36
- dx db migrate --dev --name add_user_table # 创建新的迁移(开发环境需指定名称)
37
- dx db deploy --dev # 应用开发环境已有迁移
38
- dx db deploy --prod # 生产环境迁移(复用 deploy 流程,需确认)
39
- dx db script fix-email-verified-status --dev # 运行数据库脚本(开发环境)
40
- dx db script fix-pending-transfer-status --prod # 运行数据库脚本(生产环境,需确认)
41
- dx db script my-script --dev -- --arg1 --arg2 # 向脚本传递额外参数(-- 后面的部分)
42
-
43
- test [type] [target] [path] [-t pattern] 运行测试
44
- type: e2e, unit (默认: e2e)
45
- target: backend, all (默认: all)
46
- path: 测试文件路径 (可选,仅支持e2e backend)
47
- -t pattern: 指定测试用例名称模式 (可选,需要和path一起使用)
48
-
49
- worktree [action] [num...] Git Worktree管理
50
- action: make, del, list, clean
51
- num: issue编号 (make时需要1个,del时支持多个)
52
- 支持批量删除: dx worktree del 123 456 789
53
- 支持非交互式: dx worktree del 123 -Y
54
- 注意:该封装与原生 git worktree 行为不同,勿混用
55
-
56
- lint 运行代码检查
57
-
58
- contracts [generate] 导出后端 OpenAPI 并生成 Zod 合约(packages/api-contracts)
59
-
60
- release version <semver> 统一同步 backend/front/admin-front 的版本号
61
-
62
- clean [target] 清理操作
63
- target: all, deps (默认: all)
64
-
65
- cache [action] 缓存清理
66
- action: clear (默认: clear)
67
-
68
- status 查看系统状态
69
-
70
- 选项:
71
- --dev, --development 使用开发环境
72
- --prod, --production 使用生产环境
73
- --staging, --stage 使用预发环境(加载 .env.staging*.,复用生产流程)
74
- --test 使用测试环境
75
- --e2e 使用E2E测试环境
76
- -Y, --yes 跳过所有确认提示
77
- -v, --verbose 详细输出
78
- -h, --help 显示此帮助信息
79
- -V, --version 显示版本号
80
-
81
- 示例:
82
- dx start stack # PM2 交互式服务栈(推荐)- 同时管理三个服务
83
- dx start backend --dev # 启动后端开发服务
84
- dx start front --dev # 启动用户前端开发服务
85
- dx start admin --dev # 启动管理后台开发服务
86
- dx start all # 同时启动所有开发服务(默认 --dev)
87
- dx build all --prod # 构建所有应用(生产环境)
88
- dx db deploy --dev # 应用开发环境数据库迁移
89
- dx db reset --prod -Y # 重置生产数据库(跳过确认)
90
- dx test e2e backend # 运行后端E2E测试
91
- dx test e2e backend e2e/activity/activity.admin.e2e-spec.ts # 运行单个E2E测试文件
92
- dx test e2e backend e2e/activity/activity.admin.e2e-spec.ts -t "should list all activity definitions" # 运行特定测试用例
93
- dx deploy front --staging # 部署前端到 Vercel(staging)
94
- dx worktree make 88 # 为issue #88创建worktree
95
- dx worktree del 88 # 删除issue #88的worktree
96
- dx worktree del 88 89 90 -Y # 批量删除多个worktree(非交互式)
97
- dx worktree list # 列出所有worktree
98
- dx clean deps # 清理并重新安装依赖
99
- dx cache clear # 清除 Nx 与依赖缓存
100
- dx contracts # 导出 OpenAPI 并生成 Zod 合约
101
- dx release version 1.2.3 # 同步 backend/front/admin-front 版本号
102
-
103
- # Stagewise 桥接(固定端口,自动清理占用)
104
- dx start stagewise-front # 桥接 front: 3001 -> 3002(工作目录 apps/front)
105
- dx start stagewise-admin # 桥接 admin-front: 3500 -> 3501(工作目录 apps/admin-front
106
-
107
- # Start 用法示例
108
- dx start backend --prod # 以生产环境变量启动后端
109
- dx start backend --dev # 以开发环境变量启动后端
110
- dx start backend --e2e # E2E 环境变量启动后端
111
-
112
-
113
- `)
5
+ const lines = [
6
+ '',
7
+ `DX CLI v${version} - 统一开发环境管理工具`,
8
+ '',
9
+ '用法:',
10
+ ' dx <命令> [选项] [参数...]',
11
+ '',
12
+ '命令:',
13
+ ' start [service] [环境标志] 启动/桥接服务',
14
+ ' service: backend, front, admin, all, dev, stack, stagewise-front, stagewise-admin (默认: dev)',
15
+ ' stack: PM2 交互式服务栈管理(推荐)- 同时启动三个服务并提供交互式命令',
16
+ ' 环境标志: --dev, --staging, --prod, --test, --e2e(支持别名 --development、--production 等)',
17
+ " 说明: 传入 --staging 时会加载 '.env.staging(.local)' 层,同时复用生产构建/启动流程",
18
+ '',
19
+ ' build [target] [环境标志] 构建应用',
20
+ ' target: backend, shared, front, admin, mobile, all, sdk, affected (默认: all)',
21
+ ' 环境标志: --dev, --staging, --prod, --test, --e2e(未指定时默认 --dev)',
22
+ '',
23
+ ' deploy <target> [环境标志] 部署前端到 Vercel',
24
+ ' target: front, admin, telegram-bot, all',
25
+ ' 环境标志: --dev, --staging, --prod(默认 --staging)',
26
+ '',
27
+ ' install 安装依赖(使用 frozen-lockfile 确保版本一致)',
28
+ '',
29
+ ' package backend [环境标志] 构建后端部署包(生成 backend-<version>-<sha>.tar.gz)',
30
+ ' 环境标志: --dev, --staging, --prod, --test, --e2e(默认 --dev)',
31
+ ' 产物位置: dist/backend/backend-*.tar.gz',
32
+ ' 内含: dist/、node_modules(生产依赖)、prisma/、config/.env.runtime、bin/start.sh',
33
+ '',
34
+ ' db [action] [环境标志] 数据库操作',
35
+ ' action: generate, migrate, deploy, reset, seed, format, script',
36
+ ' 用法示例:',
37
+ ' dx db migrate --dev --name add_user_table # 创建新的迁移(开发环境需指定名称)',
38
+ ' dx db deploy --dev # 应用开发环境已有迁移',
39
+ ' dx db deploy --prod # 生产环境迁移(复用 deploy 流程,需确认)',
40
+ ' dx db script fix-email-verified-status --dev # 运行数据库脚本(开发环境)',
41
+ ' dx db script fix-pending-transfer-status --prod # 运行数据库脚本(生产环境,需确认)',
42
+ ' dx db script my-script --dev -- --arg1 --arg2 # 向脚本传递额外参数(-- 后面的部分)',
43
+ '',
44
+ ' test [type] [target] [path] [-t pattern] 运行测试',
45
+ ' type: e2e, unit (默认: e2e)',
46
+ ' target: backend, all (默认: all)',
47
+ ' path: 测试文件路径 (可选,仅支持e2e backend)',
48
+ ' -t pattern: 指定测试用例名称模式 (可选,需要和path一起使用)',
49
+ '',
50
+ ' worktree [action] [num...] Git Worktree管理',
51
+ ' action: make, del, list, clean',
52
+ ' num: issue编号 (make时需要1个,del时支持多个)',
53
+ ' 支持批量删除: dx worktree del 123 456 789',
54
+ ' 支持非交互式: dx worktree del 123 -Y',
55
+ ' 注意:该封装与原生 git worktree 行为不同,勿混用',
56
+ '',
57
+ ' lint 运行代码检查',
58
+ '',
59
+ ' contracts [generate] 导出后端 OpenAPI 并生成 Zod 合约(packages/api-contracts)',
60
+ '',
61
+ ' release version <semver> 统一同步 backend/front/admin-front 的版本号',
62
+ '',
63
+ ' clean [target] 清理操作',
64
+ ' target: all, deps (默认: all)',
65
+ '',
66
+ ' cache [action] 缓存清理',
67
+ ' action: clear (默认: clear)',
68
+ '',
69
+ ' status 查看系统状态',
70
+ '',
71
+ ' ai <name> 运行一个预配置的 opencode 任务(从 commands.json 读取)',
72
+ ' 透传: -- 后的参数将原样传给 opencode run',
73
+ '',
74
+ '选项:',
75
+ ' --dev, --development 使用开发环境',
76
+ ' --prod, --production 使用生产环境',
77
+ ' --staging, --stage 使用预发环境(加载 .env.staging*.,复用生产流程)',
78
+ ' --test 使用测试环境',
79
+ ' --e2e 使用E2E测试环境',
80
+ ' -Y, --yes 跳过所有确认提示',
81
+ ' -v, --verbose 详细输出',
82
+ ' -h, --help 显示此帮助信息',
83
+ ' -V, --version 显示版本号',
84
+ '',
85
+ '示例:',
86
+ ' dx start stack # PM2 交互式服务栈(推荐)- 同时管理三个服务',
87
+ ' dx start backend --dev # 启动后端开发服务',
88
+ ' dx start front --dev # 启动用户前端开发服务',
89
+ ' dx start admin --dev # 启动管理后台开发服务',
90
+ ' dx start all # 同时启动所有开发服务(默认 --dev)',
91
+ ' dx build all --prod # 构建所有应用(生产环境)',
92
+ ' dx db deploy --dev # 应用开发环境数据库迁移',
93
+ ' dx db reset --prod -Y # 重置生产数据库(跳过确认)',
94
+ ' dx test e2e backend # 运行后端E2E测试',
95
+ ' dx test e2e backend e2e/activity/activity.admin.e2e-spec.ts # 运行单个E2E测试文件',
96
+ ' dx test e2e backend e2e/activity/activity.admin.e2e-spec.ts -t "should list all activity definitions" # 运行特定测试用例',
97
+ ' dx deploy front --staging # 部署前端到 Vercel(staging)',
98
+ ' dx worktree make 88 # 为issue #88创建worktree',
99
+ ' dx worktree del 88 # 删除issue #88的worktree',
100
+ ' dx worktree del 88 89 90 -Y # 批量删除多个worktree(非交互式)',
101
+ ' dx worktree list # 列出所有worktree',
102
+ ' dx clean deps # 清理并重新安装依赖',
103
+ ' dx cache clear # 清除 Nx 与依赖缓存',
104
+ ' dx contracts # 导出 OpenAPI 并生成 Zod 合约',
105
+ ' dx release version 1.2.3 # 同步 backend/front/admin-front 版本号',
106
+ '',
107
+ ' dx ai review',
108
+ ' dx ai review -- --share --title "my run"',
109
+ '',
110
+ ' # Stagewise 桥接(固定端口,自动清理占用)',
111
+ ' dx start stagewise-front # 桥接 front: 3001 -> 3002(工作目录 apps/front)',
112
+ ' dx start stagewise-admin # 桥接 admin-front: 3500 -> 3501(工作目录 apps/admin-front)',
113
+ '',
114
+ ' # Start 用法示例',
115
+ ' dx start backend --prod # 以生产环境变量启动后端',
116
+ ' dx start backend --dev # 以开发环境变量启动后端',
117
+ ' dx start backend --e2e # 以 E2E 环境变量启动后端',
118
+ '',
119
+ '',
120
+ ]
121
+
122
+ console.log(lines.join('\n'))
114
123
  }
115
124
 
116
125
  export function showCommandHelp(command) {
@@ -248,6 +257,38 @@ release 命令用法:
248
257
  `)
249
258
  return
250
259
 
260
+ case 'ai':
261
+ console.log(
262
+ [
263
+ '',
264
+ 'ai 命令用法:',
265
+ ' dx ai <name> [-- <opencode flags>...]',
266
+ '',
267
+ '说明:',
268
+ ' - 从 dx/config/commands.json 的 ai.<name> 读取一个固定配置并执行 opencode run',
269
+ ' - 本次运行固定注入 OPENCODE_PERMISSION="allow"(全权限)',
270
+ '',
271
+ 'commands.json 示例:',
272
+ ' {',
273
+ ' "ai": {',
274
+ ' "review": {',
275
+ ' "promptFile": "./prompts/review.md",',
276
+ ' "model": "openai/gpt-4.1",',
277
+ ' "agent": "general",',
278
+ ' "format": "default",',
279
+ ' "passthrough": ["--share"]',
280
+ ' }',
281
+ ' }',
282
+ ' }',
283
+ '',
284
+ '示例:',
285
+ ' dx ai review',
286
+ ' dx ai review -- --title "my run"',
287
+ '',
288
+ ].join('\n'),
289
+ )
290
+ return
291
+
251
292
  default:
252
293
  showHelp()
253
294
  }
package/lib/env-policy.js CHANGED
@@ -28,9 +28,11 @@ export function loadEnvPolicy(configDir) {
28
28
 
29
29
  const policyPath = join(configDir, DEFAULT_POLICY_PATH)
30
30
  if (!existsSync(policyPath)) {
31
- cachedPolicy = null
32
- cachedPolicyDir = configDir
33
- return null
31
+ throw new Error(
32
+ `缺少配置文件 ${DEFAULT_POLICY_PATH}。
33
+ 请在目标工程根目录创建 dx/config/${DEFAULT_POLICY_PATH}(或用 DX_CONFIG_DIR / --config-dir 指定配置目录)。
34
+ 当前配置目录: ${configDir}`,
35
+ )
34
36
  }
35
37
 
36
38
  let parsed
package/lib/env.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import stripJsonComments from 'strip-json-comments'
4
3
 
5
4
  function resolveProjectRoot() {
6
5
  return process.env.DX_PROJECT_ROOT || process.cwd()
@@ -16,7 +15,6 @@ export class EnvManager {
16
15
  this.projectRoot = resolveProjectRoot()
17
16
  this.configDir = resolveConfigDir()
18
17
  this.envLayers = this.loadEnvLayers()
19
- this.requiredEnvConfig = null
20
18
  this.latestEnvWarnings = []
21
19
 
22
20
  // APP_ENV → NODE_ENV 映射(用于运行时行为和工具链,如 Nx/Next)
@@ -103,43 +101,6 @@ export class EnvManager {
103
101
  return layers.map(layer => layer.replace('{app}', app))
104
102
  }
105
103
 
106
- loadRequiredEnvConfig() {
107
- if (this.requiredEnvConfig) return this.requiredEnvConfig
108
- const configPath = join(this.configDir, 'required-env.jsonc')
109
- if (!existsSync(configPath)) {
110
- this.requiredEnvConfig = { _common: [] }
111
- return this.requiredEnvConfig
112
- }
113
-
114
- try {
115
- const raw = readFileSync(configPath, 'utf8')
116
- const sanitized = stripJsonComments(raw)
117
- this.requiredEnvConfig = JSON.parse(sanitized || '{}') || { _common: [] }
118
- } catch (error) {
119
- throw new Error(`无法解析 required-env.jsonc: ${error.message}`)
120
- }
121
- return this.requiredEnvConfig
122
- }
123
-
124
- getRequiredEnvVars(environment, appType = null) {
125
- const config = this.loadRequiredEnvConfig()
126
- const base = Array.isArray(config._common) ? config._common : []
127
- const envSpecific = Array.isArray(config[environment]) ? config[environment] : []
128
-
129
- // 按应用类型添加对应的环境变量组
130
- let appSpecific = []
131
- if (appType) {
132
- const appTypes = Array.isArray(appType) ? appType : [appType]
133
- for (const type of appTypes) {
134
- if (Array.isArray(config[type])) {
135
- appSpecific = appSpecific.concat(config[type])
136
- }
137
- }
138
- }
139
-
140
- return Array.from(new Set([...base, ...envSpecific, ...appSpecific]))
141
- }
142
-
143
104
  // 构建dotenv命令参数
144
105
  buildEnvFlags(app, environment) {
145
106
  return this.getResolvedEnvLayers(app, environment)
package/lib/exec.js CHANGED
@@ -80,7 +80,7 @@ export class ExecManager {
80
80
  const policy = loadEnvPolicy(envManager.configDir)
81
81
  let requiredVars = []
82
82
 
83
- if (!isCI && policy && app) {
83
+ if (!isCI && app) {
84
84
  const targetId = resolvePolicyTargetId(policy, app)
85
85
  if (!targetId) {
86
86
  throw new Error(
@@ -88,12 +88,6 @@ export class ExecManager {
88
88
  )
89
89
  }
90
90
  requiredVars = resolveTargetRequiredVars(policy, targetId, environment)
91
- } else if (!policy) {
92
- // Backward-compatible behavior
93
- // CI 环境跳过后端环境变量校验(CI 中 build backend 只生成 OpenAPI,不需要数据库连接)
94
- // 根据 app 参数确定需要检查的环境变量组
95
- const appType = isCI ? null : (app === 'backend' ? 'backend' : app ? 'frontend' : null)
96
- requiredVars = envManager.getRequiredEnvVars(environment, appType)
97
91
  }
98
92
 
99
93
  if (requiredVars.length > 0) {
@@ -16,21 +16,7 @@ const EXTRA_ENV_IGNORED_DIRS = new Set([
16
16
  '.schaltwerk',
17
17
  ])
18
18
  const EXTRA_ENV_ALLOWED_PATHS = new Set(['docker/.env', 'docker/.env.example'])
19
- const LOCAL_ALLOWLIST_CONFIG = join(CONFIG_DIR, 'local-env-allowlist.jsonc')
20
- const EXEMPTED_KEYS_CONFIG = join(CONFIG_DIR, 'exempted-keys.jsonc')
21
19
  const ENV_EXAMPLE_FILE = join(ROOT_DIR, '.env.example')
22
- const PLACEHOLDER_TOKEN = '__SET_IN_env.local__'
23
-
24
- const LOCAL_ENV_FILES = [
25
- '.env.development.local',
26
- '.env.production.local',
27
- '.env.test.local',
28
- '.env.e2e.local',
29
- '.env.staging.local',
30
- ]
31
-
32
- let cachedAllowlist = null
33
- let cachedExemptedKeys = null
34
20
 
35
21
  export function validateEnvironment() {
36
22
  if (!process.env.NODE_ENV) {
@@ -47,15 +33,8 @@ export function validateEnvironment() {
47
33
  enforceRootOnlyEnvFiles(policy)
48
34
  enforceGlobalLocalFileProhibited()
49
35
 
50
- if (policy) {
51
- enforceSecretPolicy(policy)
52
- enforceEnvExamplePolicy(policy)
53
- } else {
54
- // Backward-compatible behavior
55
- enforceLocalSecretWhitelist()
56
- enforceNonLocalSecretPlaceholders()
57
- enforceEnvExampleSecrets()
58
- }
36
+ enforceSecretPolicy(policy)
37
+ enforceEnvExamplePolicy(policy)
59
38
 
60
39
  return { nodeEnv: process.env.NODE_ENV, appEnv: process.env.APP_ENV }
61
40
  }
@@ -114,36 +93,6 @@ function enforceRootOnlyEnvFiles(policy) {
114
93
  }
115
94
  }
116
95
 
117
- function enforceLocalSecretWhitelist() {
118
- const allowlist = loadLocalAllowlist()
119
- const errors = []
120
-
121
- for (const file of LOCAL_ENV_FILES) {
122
- const fullPath = join(ROOT_DIR, file)
123
- if (!existsSync(fullPath)) continue
124
-
125
- const entries = parseEnvFile(fullPath)
126
- const invalidKeys = []
127
-
128
- for (const key of entries.keys()) {
129
- if (!allowlist.has(key)) {
130
- invalidKeys.push(key)
131
- }
132
- }
133
-
134
- if (invalidKeys.length > 0) {
135
- errors.push(`${file}: ${invalidKeys.join(', ')}`)
136
- }
137
- }
138
-
139
- if (errors.length > 0) {
140
- const message = errors.join('\n')
141
- throw new Error(
142
- `检测到 *.local 文件包含非白名单键:\n${message}\n请将这些键迁移到对应的 .env.<env> 文件,仅保留白名单内的机密信息在 *.local 中。`,
143
- )
144
- }
145
- }
146
-
147
96
  function enforceGlobalLocalFileProhibited() {
148
97
  const legacyLocal = join(ROOT_DIR, '.env.local')
149
98
  if (existsSync(legacyLocal)) {
@@ -320,80 +269,6 @@ function findOverlaps(namedSets) {
320
269
  return overlaps
321
270
  }
322
271
 
323
- function enforceNonLocalSecretPlaceholders() {
324
- const allowlist = loadLocalAllowlist()
325
- const exemptedKeys = loadExemptedKeys()
326
- const violations = []
327
-
328
- for (const file of listRootEnvFiles()) {
329
- if (file === '.env.example') continue
330
- if (file.includes('.local')) continue
331
-
332
- const fullPath = join(ROOT_DIR, file)
333
- if (!existsSync(fullPath)) continue
334
-
335
- const entries = parseEnvFile(fullPath)
336
-
337
- for (const [key, value] of entries.entries()) {
338
- if (!allowlist.has(key)) continue
339
- if (exemptedKeys.has(key)) continue // 豁免的键允许使用非占位符值
340
-
341
- const normalized = value.trim()
342
- if (normalized !== PLACEHOLDER_TOKEN) {
343
- violations.push(`${file}: ${key}`)
344
- }
345
- }
346
- }
347
-
348
- if (violations.length > 0) {
349
- const message = violations.join('\n')
350
- throw new Error(
351
- `检测到非 *.local 文件包含敏感键但未使用占位符 ${PLACEHOLDER_TOKEN}:\n${message}\n` +
352
- `请仅在 .env.<env>.local 系列中设置真实值,并在其他文件中使用占位符。`,
353
- )
354
- }
355
- }
356
-
357
- function enforceEnvExampleSecrets() {
358
- if (!existsSync(ENV_EXAMPLE_FILE)) return
359
-
360
- const allowlist = loadLocalAllowlist()
361
- const entries = parseEnvFile(ENV_EXAMPLE_FILE)
362
- const disallowedKeys = []
363
- const invalidPlaceholders = []
364
-
365
- for (const [key, value] of entries.entries()) {
366
- const normalized = value.trim()
367
-
368
- if (!allowlist.has(key)) {
369
- disallowedKeys.push(key)
370
- continue
371
- }
372
-
373
- if (normalized !== PLACEHOLDER_TOKEN) {
374
- invalidPlaceholders.push(key)
375
- }
376
- }
377
-
378
- if (disallowedKeys.length > 0) {
379
- throw new Error(
380
- `.env.example 仅允许包含 scripts/config/local-env-allowlist.jsonc 中的键,检测到非法键: ${disallowedKeys.join(', ')}`,
381
- )
382
- }
383
-
384
- if (invalidPlaceholders.length > 0) {
385
- throw new Error(
386
- `.env.example 中以下键未使用占位符 ${PLACEHOLDER_TOKEN}: ${invalidPlaceholders.join(', ')}`,
387
- )
388
- }
389
- }
390
-
391
- function listRootEnvFiles() {
392
- return readdirSync(ROOT_DIR, { withFileTypes: true })
393
- .filter(entry => entry.isFile() && entry.name.startsWith('.env'))
394
- .map(entry => entry.name)
395
- }
396
-
397
272
  function parseEnvFile(filePath) {
398
273
  const content = readFileSync(filePath, 'utf8')
399
274
  const map = new Map()
@@ -415,57 +290,4 @@ function parseEnvFile(filePath) {
415
290
  return map
416
291
  }
417
292
 
418
- function loadLocalAllowlist() {
419
- if (cachedAllowlist) return cachedAllowlist
420
-
421
- if (!existsSync(LOCAL_ALLOWLIST_CONFIG)) {
422
- throw new Error('缺少配置文件 scripts/config/local-env-allowlist.jsonc,请补充后重试')
423
- }
424
-
425
- const raw = readFileSync(LOCAL_ALLOWLIST_CONFIG, 'utf8')
426
- const sanitized = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
427
- const parsed = JSON.parse(sanitized || '{}') || {}
428
-
429
- const allowedValues = Array.isArray(parsed.allowed) ? parsed.allowed : []
430
- if (allowedValues.length === 0) {
431
- throw new Error('local-env-allowlist.jsonc 中的 allowed 不能为空,请至少保留一个键')
432
- }
433
-
434
- const invalid = allowedValues.filter(
435
- value => typeof value !== 'string' || value.trim().length === 0,
436
- )
437
- if (invalid.length > 0) {
438
- throw new Error('local-env-allowlist.jsonc.allowed 中存在非法键,请使用非空字符串')
439
- }
440
-
441
- cachedAllowlist = new Set(allowedValues)
442
- return cachedAllowlist
443
- }
444
-
445
- function loadExemptedKeys() {
446
- if (cachedExemptedKeys) return cachedExemptedKeys
447
-
448
- // 如果豁免配置文件不存在,返回空集合(可选配置)
449
- if (!existsSync(EXEMPTED_KEYS_CONFIG)) {
450
- cachedExemptedKeys = new Set()
451
- return cachedExemptedKeys
452
- }
453
-
454
- const raw = readFileSync(EXEMPTED_KEYS_CONFIG, 'utf8')
455
- const sanitized = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
456
- const parsed = JSON.parse(sanitized || '{}') || {}
457
-
458
- const exemptedValues = Array.isArray(parsed.exempted) ? parsed.exempted : []
459
-
460
- const invalid = exemptedValues.filter(
461
- value => typeof value !== 'string' || value.trim().length === 0,
462
- )
463
- if (invalid.length > 0) {
464
- throw new Error('exempted-keys.jsonc.exempted 中存在非法键,请使用非空字符串')
465
- }
466
-
467
- cachedExemptedKeys = new Set(exemptedValues)
468
- return cachedExemptedKeys
469
- }
470
-
471
293
  export default { validateEnvironment }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ranger1/dx",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {