@ranger1/dx 0.1.4 → 0.1.7

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
@@ -47,9 +47,7 @@ dx/
47
47
  config/
48
48
  commands.json
49
49
  env-layers.json
50
- required-env.jsonc
51
- local-env-allowlist.jsonc
52
- exempted-keys.jsonc
50
+ env-policy.jsonc
53
51
  ```
54
52
 
55
53
  可选覆盖:
@@ -115,25 +113,17 @@ DX_CONFIG_DIR=/path/to/your-repo/dx/config dx status
115
113
  }
116
114
  ```
117
115
 
118
- ### 3) dx/config/required-env.jsonc
116
+ ### 3) dx/config/env-policy.jsonc
119
117
 
120
- 用于定义哪些环境变量是「必须存在」的(dx 会在执行命令前校验)。它是 jsonc(允许 // 注释)。
118
+ 统一的 env 策略配置(jsonc),同时覆盖:
121
119
 
122
- dx 的校验分组逻辑:
120
+ - env 文件布局约束(禁用 `.env` / `.env.local`;禁止子目录散落 `.env*`,仅允许少数特例路径)
121
+ - 机密键策略:机密 key 只能在 `.env.<env>.local` 放真实值;对应的 `.env.<env>` 必须存在同名 key 且为占位符 `__SET_IN_env.local__`
122
+ - 必填校验:按环境 + 按 target(端)定义 required keys,执行命令前校验是否缺失/仍为占位符
123
123
 
124
- - `_common`: 所有命令都会校验
125
- - `backend`: 当命令配置里 `app` 是 `backend` 时会校验
126
- - `frontend`: 当命令配置里 `app` 是 `front`/`admin-front` 等前端应用时会校验
127
- - `development`/`production`/`staging`/`test`/`e2e`: 按当前环境额外补充
124
+ target(端)不写死,由 `env-policy.jsonc.targets` 定义;`commands.json` 里的 `app` 通过 `env-policy.jsonc.appToTarget` 映射到某个 target。
128
125
 
129
- ### 4) dx/config/local-env-allowlist.jsonc + exempted-keys.jsonc
130
-
131
- 这是为了防止误提交机密:
132
-
133
- - `local-env-allowlist.jsonc`:允许出现在 `.env.*.local` 里的键(这些被认为是“机密”)
134
- - `exempted-keys.jsonc`:豁免键(允许在非 local 文件中出现真实值)
135
-
136
- 非 local 的 `.env.*` 文件里,机密键必须使用占位符:`__SET_IN_env.local__`。
126
+ 注:若未提供 `env-policy.jsonc`,dx 会继续使用旧的 `required-env.jsonc` / `local-env-allowlist.jsonc` / `exempted-keys.jsonc` 逻辑(兼容模式)。
137
127
 
138
128
  ## 示例工程
139
129
 
package/bin/dx.js CHANGED
@@ -31,6 +31,33 @@ function stripConfigDirArgs(argv) {
31
31
  return out
32
32
  }
33
33
 
34
+ function isVersionInvocation(argv) {
35
+ const raw = Array.isArray(argv) ? argv : []
36
+ const filtered = stripConfigDirArgs(raw)
37
+
38
+ const flags = []
39
+ const positionals = []
40
+ for (const token of filtered) {
41
+ if (token === '--') break
42
+ if (token.startsWith('-')) flags.push(token)
43
+ else positionals.push(token)
44
+ }
45
+
46
+ // Keep current semantics:
47
+ // - -V/--version always prints version and exits.
48
+ // - -v is verbose in commands, but `dx -v` is commonly expected to show version.
49
+ const hasCanonicalVersionFlag = flags.includes('-V') || flags.includes('--version')
50
+ if (hasCanonicalVersionFlag) return true
51
+
52
+ const isBareLowerV = flags.length === 1 && flags[0] === '-v' && positionals.length === 0
53
+ if (isBareLowerV) return true
54
+
55
+ const isVersionCommand = positionals.length === 1 && positionals[0] === 'version'
56
+ if (isVersionCommand) return true
57
+
58
+ return false
59
+ }
60
+
34
61
  function findProjectRootFrom(startDir) {
35
62
  let current = resolve(startDir)
36
63
  while (true) {
@@ -67,6 +94,13 @@ function inferProjectRootFromConfigDir(configDir, startDir) {
67
94
 
68
95
  async function main() {
69
96
  const rawArgs = process.argv.slice(2)
97
+
98
+ if (isVersionInvocation(rawArgs)) {
99
+ const { getPackageVersion } = await import('../lib/version.js')
100
+ console.log(getPackageVersion())
101
+ return
102
+ }
103
+
70
104
  const overrideConfigDir = parseConfigDir(rawArgs)
71
105
  const filteredArgs = stripConfigDirArgs(rawArgs)
72
106
 
@@ -20,6 +20,11 @@ import { parse as parseYaml } from 'yaml'
20
20
  import { logger } from './logger.js'
21
21
  import { execManager } from './exec.js'
22
22
  import { envManager } from './env.js'
23
+ import {
24
+ loadEnvPolicy,
25
+ resolvePolicyTargetId,
26
+ resolveTargetRequiredVars,
27
+ } from './env-policy.js'
23
28
 
24
29
  class BackendPackager {
25
30
  constructor(options = {}) {
@@ -233,7 +238,19 @@ class BackendPackager {
233
238
 
234
239
  async prepareEnvSnapshot() {
235
240
  logger.info('校验并快照环境变量')
236
- const requiredVars = envManager.getRequiredEnvVars(this.layerEnv, 'backend')
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')
253
+ }
237
254
  const collected = envManager.collectEnvFromLayers('backend', this.layerEnv)
238
255
  const effectiveEnv = { ...collected, ...process.env }
239
256
  const { valid, missing, placeholders } = envManager.validateRequiredVars(
package/lib/cli/dx-cli.js CHANGED
@@ -5,6 +5,11 @@ import { logger } from '../logger.js'
5
5
  import { envManager } from '../env.js'
6
6
  import { execManager } from '../exec.js'
7
7
  import { validateEnvironment } from '../validate-env.js'
8
+ import {
9
+ loadEnvPolicy,
10
+ resolvePolicyTargetId,
11
+ resolveTargetRequiredVars,
12
+ } from '../env-policy.js'
8
13
  import { FLAG_DEFINITIONS, parseFlags } from './flags.js'
9
14
  import { getCleanArgs } from './args.js'
10
15
  import { showHelp, showCommandHelp } from './help.js'
@@ -215,7 +220,19 @@ class DxCli {
215
220
  // 非 CI 环境下,检查 backend 组的环境变量
216
221
  if (process.env.CI !== '1') {
217
222
  const effectiveEnv = { ...process.env, ...layeredEnv }
218
- const requiredVars = envManager.getRequiredEnvVars(environment, 'backend')
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')
235
+ }
219
236
  if (requiredVars.length > 0) {
220
237
  const { valid, missing, placeholders } = envManager.validateRequiredVars(
221
238
  requiredVars,
@@ -0,0 +1,135 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const DEFAULT_POLICY_PATH = 'env-policy.jsonc'
5
+
6
+ let cachedPolicy = null
7
+ let cachedPolicyDir = null
8
+
9
+ function sanitizeJsonc(raw) {
10
+ return raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
11
+ }
12
+
13
+ function assertStringArray(value, fieldPath) {
14
+ if (!Array.isArray(value)) {
15
+ throw new Error(`${fieldPath} 必须是数组`)
16
+ }
17
+ const invalid = value.filter(v => typeof v !== 'string' || v.trim().length === 0)
18
+ if (invalid.length > 0) {
19
+ throw new Error(`${fieldPath} 中存在非法值,请使用非空字符串`)
20
+ }
21
+ }
22
+
23
+ function assertRecord(value, fieldPath) {
24
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
25
+ throw new Error(`${fieldPath} 必须是对象`)
26
+ }
27
+ }
28
+
29
+ export function loadEnvPolicy(configDir) {
30
+ if (cachedPolicy && cachedPolicyDir === configDir) return cachedPolicy
31
+
32
+ const policyPath = join(configDir, DEFAULT_POLICY_PATH)
33
+ if (!existsSync(policyPath)) {
34
+ cachedPolicy = null
35
+ cachedPolicyDir = configDir
36
+ return null
37
+ }
38
+
39
+ let parsed
40
+ try {
41
+ const raw = readFileSync(policyPath, 'utf8')
42
+ parsed = JSON.parse(sanitizeJsonc(raw) || '{}')
43
+ } catch (error) {
44
+ throw new Error(`无法解析 ${DEFAULT_POLICY_PATH}: ${error.message}`)
45
+ }
46
+
47
+ validateEnvPolicy(parsed)
48
+ cachedPolicy = parsed
49
+ cachedPolicyDir = configDir
50
+ return cachedPolicy
51
+ }
52
+
53
+ export function validateEnvPolicy(policy) {
54
+ assertRecord(policy, 'env-policy')
55
+
56
+ if (policy.version !== 1) {
57
+ throw new Error('env-policy.jsonc.version 必须为 1')
58
+ }
59
+
60
+ assertStringArray(policy.environments, 'env-policy.jsonc.environments')
61
+
62
+ if (typeof policy.secretPlaceholder !== 'string' || policy.secretPlaceholder.trim().length === 0) {
63
+ throw new Error('env-policy.jsonc.secretPlaceholder 必须为非空字符串')
64
+ }
65
+
66
+ assertRecord(policy.keys, 'env-policy.jsonc.keys')
67
+ assertStringArray(policy.keys.secret || [], 'env-policy.jsonc.keys.secret')
68
+ assertStringArray(policy.keys.localOnly || [], 'env-policy.jsonc.keys.localOnly')
69
+ assertStringArray(policy.keys.localOverride || [], 'env-policy.jsonc.keys.localOverride')
70
+
71
+ assertRecord(policy.layout, 'env-policy.jsonc.layout')
72
+ assertStringArray(policy.layout.forbidExact || [], 'env-policy.jsonc.layout.forbidExact')
73
+ assertStringArray(policy.layout.allowRoot || [], 'env-policy.jsonc.layout.allowRoot')
74
+ assertStringArray(policy.layout.allowSubdirGlobs || [], 'env-policy.jsonc.layout.allowSubdirGlobs')
75
+
76
+ assertRecord(policy.appToTarget || {}, 'env-policy.jsonc.appToTarget')
77
+ for (const [app, target] of Object.entries(policy.appToTarget || {})) {
78
+ if (typeof app !== 'string' || app.trim().length === 0) {
79
+ throw new Error('env-policy.jsonc.appToTarget 中存在非法 app key')
80
+ }
81
+ if (typeof target !== 'string' || target.trim().length === 0) {
82
+ throw new Error('env-policy.jsonc.appToTarget 中存在非法 target value')
83
+ }
84
+ }
85
+
86
+ assertRecord(policy.targets, 'env-policy.jsonc.targets')
87
+ const targetIds = Object.keys(policy.targets)
88
+ if (targetIds.length === 0) {
89
+ throw new Error('env-policy.jsonc.targets 不能为空')
90
+ }
91
+
92
+ for (const [targetId, target] of Object.entries(policy.targets)) {
93
+ if (typeof targetId !== 'string' || targetId.trim().length === 0) {
94
+ throw new Error('env-policy.jsonc.targets 中存在非法 targetId')
95
+ }
96
+ assertRecord(target, `env-policy.jsonc.targets.${targetId}`)
97
+ assertRecord(target.files, `env-policy.jsonc.targets.${targetId}.files`)
98
+ if (typeof target.files.committed !== 'string' || target.files.committed.trim().length === 0) {
99
+ throw new Error(`env-policy.jsonc.targets.${targetId}.files.committed 必须为非空字符串`)
100
+ }
101
+ if (typeof target.files.local !== 'string' || target.files.local.trim().length === 0) {
102
+ throw new Error(`env-policy.jsonc.targets.${targetId}.files.local 必须为非空字符串`)
103
+ }
104
+ assertRecord(target.required || {}, `env-policy.jsonc.targets.${targetId}.required`)
105
+
106
+ for (const [group, keys] of Object.entries(target.required || {})) {
107
+ assertStringArray(keys, `env-policy.jsonc.targets.${targetId}.required.${group}`)
108
+ }
109
+ }
110
+
111
+ // Ensure appToTarget does not point to missing targets.
112
+ for (const [app, target] of Object.entries(policy.appToTarget || {})) {
113
+ if (!policy.targets[target]) {
114
+ throw new Error(
115
+ `env-policy.jsonc.appToTarget.${app} 指向不存在的 target: ${target}(请在 env-policy.jsonc.targets 中定义)`,
116
+ )
117
+ }
118
+ }
119
+ }
120
+
121
+ export function resolvePolicyTargetId(policy, app) {
122
+ if (!policy || !app) return null
123
+ const mapped = policy.appToTarget?.[app]
124
+ return mapped || null
125
+ }
126
+
127
+ export function resolveTargetRequiredVars(policy, targetId, environment) {
128
+ if (!policy || !targetId) return []
129
+ const target = policy.targets?.[targetId]
130
+ if (!target) return []
131
+ const required = target.required || {}
132
+ const common = Array.isArray(required._common) ? required._common : []
133
+ const envSpecific = Array.isArray(required[environment]) ? required[environment] : []
134
+ return Array.from(new Set([...common, ...envSpecific]))
135
+ }
package/lib/exec.js CHANGED
@@ -3,6 +3,11 @@ import { promisify } from 'node:util'
3
3
  import { logger } from './logger.js'
4
4
  import { envManager } from './env.js'
5
5
  import { validateEnvironment } from './validate-env.js'
6
+ import {
7
+ loadEnvPolicy,
8
+ resolvePolicyTargetId,
9
+ resolveTargetRequiredVars,
10
+ } from './env-policy.js'
6
11
  import { confirmManager } from './confirm.js'
7
12
 
8
13
  const execPromise = promisify(nodeExec)
@@ -69,17 +74,33 @@ export class ExecManager {
69
74
  })
70
75
  }
71
76
 
72
- const effectiveEnv = { ...process.env, ...layeredEnv }
73
- // CI 环境跳过后端环境变量校验(CI build backend 只生成 OpenAPI,不需要数据库连接)
74
- // 根据 app 参数确定需要检查的环境变量组
75
- const isCI = process.env.CI === '1'
76
- const appType = isCI ? null : (app === 'backend' ? 'backend' : app ? 'frontend' : null)
77
- const requiredVars = envManager.getRequiredEnvVars(environment, appType)
78
- if (requiredVars.length > 0) {
79
- const { valid, missing, placeholders } = envManager.validateRequiredVars(
80
- requiredVars,
81
- effectiveEnv,
82
- )
77
+ const effectiveEnv = { ...process.env, ...layeredEnv }
78
+ const isCI = process.env.CI === '1'
79
+
80
+ const policy = loadEnvPolicy(envManager.configDir)
81
+ let requiredVars = []
82
+
83
+ if (!isCI && policy && app) {
84
+ const targetId = resolvePolicyTargetId(policy, app)
85
+ if (!targetId) {
86
+ throw new Error(
87
+ `未找到 app 对应的 target 配置: app=${app}\n请在 dx/config/env-policy.jsonc 中配置 appToTarget.${app}`,
88
+ )
89
+ }
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
+ }
98
+
99
+ if (requiredVars.length > 0) {
100
+ const { valid, missing, placeholders } = envManager.validateRequiredVars(
101
+ requiredVars,
102
+ effectiveEnv,
103
+ )
83
104
  if (!valid) {
84
105
  const problems = ['环境变量校验未通过']
85
106
  if (missing.length > 0) {
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { loadEnvPolicy } from './env-policy.js'
3
4
 
4
5
  const ROOT_DIR = process.env.DX_PROJECT_ROOT || process.cwd()
5
6
  const CONFIG_DIR = process.env.DX_CONFIG_DIR || join(ROOT_DIR, 'dx', 'config')
@@ -41,22 +42,34 @@ export function validateEnvironment() {
41
42
  delete process.env.APP_ENV
42
43
  }
43
44
 
44
- enforceRootOnlyEnvFiles()
45
- enforceLocalSecretWhitelist()
45
+ const policy = loadEnvPolicy(CONFIG_DIR)
46
+
47
+ enforceRootOnlyEnvFiles(policy)
46
48
  enforceGlobalLocalFileProhibited()
47
- enforceNonLocalSecretPlaceholders()
48
- enforceEnvExampleSecrets()
49
+
50
+ if (policy) {
51
+ enforceSecretPolicy(policy)
52
+ enforceEnvExamplePolicy(policy)
53
+ } else {
54
+ // Backward-compatible behavior
55
+ enforceLocalSecretWhitelist()
56
+ enforceNonLocalSecretPlaceholders()
57
+ enforceEnvExampleSecrets()
58
+ }
49
59
 
50
60
  return { nodeEnv: process.env.NODE_ENV, appEnv: process.env.APP_ENV }
51
61
  }
52
62
 
53
- function enforceRootOnlyEnvFiles() {
63
+ function enforceRootOnlyEnvFiles(policy) {
54
64
  if (existsSync(ROOT_ENV_FILE)) {
55
65
  throw new Error(
56
66
  '检测到根目录存在 .env 文件,请迁移到 .env.<env> / .env.<env>.local 并删除 .env',
57
67
  )
58
68
  }
59
69
 
70
+ // 保留现有规则:禁止子目录出现任意 .env* 文件(除特例路径)
71
+ // 注:该规则对是否启用 env-policy 都有效。
72
+
60
73
  const violations = []
61
74
  const queue = ['.']
62
75
 
@@ -84,6 +97,11 @@ function enforceRootOnlyEnvFiles() {
84
97
 
85
98
  if (EXTRA_ENV_ALLOWED_PATHS.has(relativePath)) continue
86
99
 
100
+ if (policy) {
101
+ const globs = Array.isArray(policy.layout?.allowSubdirGlobs) ? policy.layout.allowSubdirGlobs : []
102
+ if (isAllowedBySimpleGlob(relativePath, globs)) continue
103
+ }
104
+
87
105
  violations.push(relativePath)
88
106
  }
89
107
  }
@@ -133,6 +151,175 @@ function enforceGlobalLocalFileProhibited() {
133
151
  }
134
152
  }
135
153
 
154
+ function enforceSecretPolicy(policy) {
155
+ const placeholder = String(policy.secretPlaceholder || '').trim()
156
+ const secretKeys = new Set(policy.keys?.secret || [])
157
+ const localOnlyKeys = new Set(policy.keys?.localOnly || [])
158
+ const localOverrideKeys = new Set(policy.keys?.localOverride || [])
159
+
160
+ const invalidOverlap = findOverlaps([
161
+ { name: 'keys.secret', set: secretKeys },
162
+ { name: 'keys.localOnly', set: localOnlyKeys },
163
+ { name: 'keys.localOverride', set: localOverrideKeys },
164
+ ])
165
+ if (invalidOverlap.length > 0) {
166
+ throw new Error(`env-policy.jsonc keys 分类存在重复: ${invalidOverlap.join(', ')}`)
167
+ }
168
+
169
+ const errors = []
170
+ const filePairs = listTargetEnvFilePairs(policy)
171
+
172
+ for (const envName of policy.environments || []) {
173
+ for (const pair of filePairs) {
174
+ const committed = replaceEnvToken(pair.committed, envName)
175
+ const local = replaceEnvToken(pair.local, envName)
176
+
177
+ const committedPath = join(ROOT_DIR, committed)
178
+ const localPath = join(ROOT_DIR, local)
179
+
180
+ const committedExists = existsSync(committedPath)
181
+ const localExists = existsSync(localPath)
182
+
183
+ if (!committedExists && !localExists) continue
184
+ if (!committedExists && localExists) {
185
+ errors.push(`${committed} 缺失(但存在 ${local}),请补充 committed 模板文件`)
186
+ continue
187
+ }
188
+
189
+ const committedEntries = committedExists ? parseEnvFile(committedPath) : new Map()
190
+ const localEntries = localExists ? parseEnvFile(localPath) : new Map()
191
+
192
+ // Committed: secret keys must exist and be placeholder.
193
+ for (const key of secretKeys) {
194
+ if (!committedEntries.has(key)) {
195
+ errors.push(`${committed}: 缺少机密键模板 ${key}`)
196
+ continue
197
+ }
198
+ const rawValue = String(committedEntries.get(key) ?? '')
199
+ if (rawValue.trim() !== placeholder) {
200
+ errors.push(`${committed}: 机密键 ${key} 必须使用占位符 ${placeholder}`)
201
+ }
202
+ }
203
+
204
+ // Committed: localOnly must not appear.
205
+ for (const key of localOnlyKeys) {
206
+ if (committedEntries.has(key)) {
207
+ errors.push(`${committed}: localOnly 键 ${key} 不允许出现在非 local 文件中`)
208
+ }
209
+ }
210
+
211
+ if (localExists) {
212
+ const allowedInLocal = new Set([...secretKeys, ...localOnlyKeys, ...localOverrideKeys])
213
+
214
+ for (const [key, value] of localEntries.entries()) {
215
+ if (!allowedInLocal.has(key)) {
216
+ errors.push(`${local}: 包含未声明的键 ${key}(请加入 env-policy.jsonc.keys.* 或迁移到 committed 文件)`)
217
+ continue
218
+ }
219
+
220
+ if (secretKeys.has(key)) {
221
+ if (String(value ?? '').trim() === placeholder) {
222
+ errors.push(`${local}: 机密键 ${key} 不允许使用占位符,请设置真实值`)
223
+ }
224
+ if (!committedEntries.has(key)) {
225
+ errors.push(`${committed}: 缺少机密键模板 ${key}(因为 ${local} 中存在该键)`)
226
+ }
227
+ }
228
+
229
+ if (localOnlyKeys.has(key) && committedEntries.has(key)) {
230
+ errors.push(`${committed}: localOnly 键 ${key} 不允许出现在 committed 文件中(已在 ${local} 中存在)`)
231
+ }
232
+ }
233
+ }
234
+ }
235
+ }
236
+
237
+ if (errors.length > 0) {
238
+ throw new Error(`环境变量机密策略校验未通过:\n${errors.join('\n')}`)
239
+ }
240
+ }
241
+
242
+ function enforceEnvExamplePolicy(policy) {
243
+ if (!existsSync(ENV_EXAMPLE_FILE)) return
244
+
245
+ const placeholder = String(policy.secretPlaceholder || '').trim()
246
+ const secretKeys = new Set(policy.keys?.secret || [])
247
+ const localOnlyKeys = new Set(policy.keys?.localOnly || [])
248
+ const entries = parseEnvFile(ENV_EXAMPLE_FILE)
249
+ const errors = []
250
+
251
+ for (const [key, value] of entries.entries()) {
252
+ if (localOnlyKeys.has(key)) {
253
+ errors.push(`.env.example 不允许包含 localOnly 键: ${key}`)
254
+ continue
255
+ }
256
+ if (secretKeys.has(key)) {
257
+ if (String(value ?? '').trim() !== placeholder) {
258
+ errors.push(`.env.example 中机密键 ${key} 必须使用占位符 ${placeholder}`)
259
+ }
260
+ }
261
+ }
262
+
263
+ if (errors.length > 0) {
264
+ throw new Error(errors.join('\n'))
265
+ }
266
+ }
267
+
268
+ function listTargetEnvFilePairs(policy) {
269
+ const pairs = []
270
+ const targets = policy.targets || {}
271
+ for (const target of Object.values(targets)) {
272
+ const committed = target?.files?.committed
273
+ const local = target?.files?.local
274
+ if (typeof committed !== 'string' || typeof local !== 'string') continue
275
+ pairs.push({ committed, local })
276
+ }
277
+
278
+ // De-dup
279
+ const seen = new Set()
280
+ return pairs.filter(p => {
281
+ const key = `${p.committed}@@${p.local}`
282
+ if (seen.has(key)) return false
283
+ seen.add(key)
284
+ return true
285
+ })
286
+ }
287
+
288
+ function replaceEnvToken(template, envName) {
289
+ return String(template).replace(/\{env\}/g, envName)
290
+ }
291
+
292
+ function isAllowedBySimpleGlob(path, globs) {
293
+ for (const raw of globs) {
294
+ const glob = String(raw)
295
+ if (!glob.includes('*')) {
296
+ if (glob === path) return true
297
+ continue
298
+ }
299
+ // Only support a single trailing '*' for now.
300
+ if (glob.endsWith('*')) {
301
+ const prefix = glob.slice(0, -1)
302
+ if (path.startsWith(prefix)) return true
303
+ }
304
+ }
305
+ return false
306
+ }
307
+
308
+ function findOverlaps(namedSets) {
309
+ const seen = new Map()
310
+ const overlaps = []
311
+ for (const { name, set } of namedSets) {
312
+ for (const key of set) {
313
+ if (seen.has(key)) {
314
+ overlaps.push(`${key} (${seen.get(key)} & ${name})`)
315
+ } else {
316
+ seen.set(key, name)
317
+ }
318
+ }
319
+ }
320
+ return overlaps
321
+ }
322
+
136
323
  function enforceNonLocalSecretPlaceholders() {
137
324
  const allowlist = loadLocalAllowlist()
138
325
  const exemptedKeys = loadExemptedKeys()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ranger1/dx",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {