@ranger1/dx 0.1.9 → 0.1.10

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(
package/lib/cli/dx-cli.js CHANGED
@@ -221,18 +221,11 @@ class DxCli {
221
221
  if (process.env.CI !== '1') {
222
222
  const effectiveEnv = { ...process.env, ...layeredEnv }
223
223
  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')
224
+ const targetId = resolvePolicyTargetId(policy, 'backend')
225
+ if (!targetId) {
226
+ throw new Error('缺少 appToTarget.backend 配置(dx 内置逻辑需要 backend target)')
235
227
  }
228
+ const requiredVars = resolveTargetRequiredVars(policy, targetId, environment)
236
229
  if (requiredVars.length > 0) {
237
230
  const { valid, missing, placeholders } = envManager.validateRequiredVars(
238
231
  requiredVars,
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.10",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {