@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,435 @@
1
+ import { spawn, spawnSync } from 'node:child_process'
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import {
4
+ chmodSync,
5
+ closeSync,
6
+ existsSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ renameSync,
12
+ rmSync,
13
+ statSync,
14
+ writeFileSync,
15
+ } from 'node:fs'
16
+ import { tmpdir } from 'node:os'
17
+ import { basename, join, relative } from 'node:path'
18
+ import { loadEnvPolicy, resolveTargetRequiredVars } from './env-policy.js'
19
+
20
+ const PROFILE_CONFIG_FILE = 'env-profiles.json'
21
+ const PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/
22
+ const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }
23
+
24
+ function assert(condition, message) {
25
+ if (!condition) throw new Error(message)
26
+ }
27
+
28
+ function parseJsonFile(filePath) {
29
+ try {
30
+ return JSON.parse(readFileSync(filePath, 'utf8'))
31
+ } catch (error) {
32
+ throw new Error(`无法解析 ${filePath}: ${error.message}`)
33
+ }
34
+ }
35
+
36
+ export function loadEnvProfileConfig(configDir) {
37
+ const filePath = join(configDir, PROFILE_CONFIG_FILE)
38
+ assert(existsSync(filePath), `缺少配置文件 ${filePath}`)
39
+ const config = parseJsonFile(filePath)
40
+
41
+ assert(config.version === 1, `${PROFILE_CONFIG_FILE}.version 必须为 1`)
42
+ assert(
43
+ config.profiles && typeof config.profiles === 'object' && !Array.isArray(config.profiles),
44
+ `${PROFILE_CONFIG_FILE}.profiles 必须为对象`,
45
+ )
46
+ const profileNames = Object.keys(config.profiles)
47
+ assert(profileNames.length > 0, `${PROFILE_CONFIG_FILE}.profiles 不能为空`)
48
+ for (const name of profileNames) {
49
+ assert(PROFILE_NAME_PATTERN.test(name), `非法 profile 名称: ${name}`)
50
+ const profile = config.profiles[name]
51
+ assert(profile && typeof profile === 'object' && !Array.isArray(profile), `profile ${name} 必须为对象`)
52
+ if (profile.label !== undefined) {
53
+ assert(typeof profile.label === 'string' && profile.label.trim(), `profile ${name}.label 必须为非空字符串`)
54
+ }
55
+ }
56
+
57
+ assert(Array.isArray(config.environments), `${PROFILE_CONFIG_FILE}.environments 必须为数组`)
58
+ assert(config.environments.length > 0, `${PROFILE_CONFIG_FILE}.environments 不能为空`)
59
+ for (const environment of config.environments) {
60
+ assert(typeof environment === 'string' && environment.trim(), 'environment 必须为非空字符串')
61
+ }
62
+
63
+ const requiredLocalKeys = config.requiredLocalKeys || {}
64
+ assert(
65
+ requiredLocalKeys && typeof requiredLocalKeys === 'object' && !Array.isArray(requiredLocalKeys),
66
+ `${PROFILE_CONFIG_FILE}.requiredLocalKeys 必须为对象`,
67
+ )
68
+ for (const [environment, keys] of Object.entries(requiredLocalKeys)) {
69
+ assert(config.environments.includes(environment), `requiredLocalKeys 包含未声明环境: ${environment}`)
70
+ assert(Array.isArray(keys), `requiredLocalKeys.${environment} 必须为数组`)
71
+ for (const key of keys) {
72
+ assert(/^[A-Za-z_][A-Za-z0-9_]*$/.test(key), `requiredLocalKeys.${environment} 包含非法键: ${key}`)
73
+ }
74
+ }
75
+
76
+ return config
77
+ }
78
+
79
+ export function resolveEnvProfilePaths({ projectRoot, profile, environment }) {
80
+ assert(PROFILE_NAME_PATTERN.test(profile || ''), `非法 profile 名称: ${profile || '<empty>'}`)
81
+ assert(typeof environment === 'string' && environment.trim(), 'environment 不能为空')
82
+ const profileDirectory = join(projectRoot, 'dx', 'env', 'templates', profile)
83
+ return {
84
+ source: join(profileDirectory, `${environment}.local`),
85
+ target: join(projectRoot, `.env.${environment}.local`),
86
+ template: join(profileDirectory, `${environment}.local.example`),
87
+ }
88
+ }
89
+
90
+ function parseEnvContent(content, filePath) {
91
+ const entries = new Map()
92
+ const errors = []
93
+ const lines = String(content).split(/\r?\n/)
94
+
95
+ lines.forEach((rawLine, index) => {
96
+ let line = rawLine.trim()
97
+ if (!line || line.startsWith('#')) return
98
+ if (line.startsWith('export ')) line = line.slice('export '.length).trim()
99
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)
100
+ if (!match) {
101
+ errors.push(`${filePath}:${index + 1}: 不是合法的 KEY=VALUE`)
102
+ return
103
+ }
104
+ if (entries.has(match[1])) {
105
+ errors.push(`${filePath}:${index + 1}: 重复键 ${match[1]}`)
106
+ return
107
+ }
108
+ entries.set(match[1], match[2])
109
+ })
110
+
111
+ if (errors.length > 0) throw new Error(errors.join('\n'))
112
+ return entries
113
+ }
114
+
115
+ function normalizedValue(value) {
116
+ const text = String(value ?? '').trim()
117
+ if (
118
+ text.length >= 2 &&
119
+ ((text.startsWith('"') && text.endsWith('"')) ||
120
+ (text.startsWith("'") && text.endsWith("'")))
121
+ ) {
122
+ return text.slice(1, -1).trim()
123
+ }
124
+ return text
125
+ }
126
+
127
+ function assertPrivatePermissions(filePath) {
128
+ const linkStat = lstatSync(filePath)
129
+ assert(!linkStat.isSymbolicLink(), `profile 不允许使用符号链接: ${filePath}`)
130
+ const stat = statSync(filePath)
131
+ assert(stat.isFile(), `profile 必须是普通文件: ${filePath}`)
132
+ if (process.platform === 'win32') return
133
+ const publicBits = stat.mode & 0o077
134
+ assert(
135
+ publicBits === 0,
136
+ `${filePath} 权限过宽(当前 ${(stat.mode & 0o777).toString(8).padStart(3, '0')}),请执行 chmod 600 ${basename(filePath)}`,
137
+ )
138
+ }
139
+
140
+ function assertGitIgnored(projectRoot, filePath) {
141
+ const inside = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
142
+ cwd: projectRoot,
143
+ encoding: 'utf8',
144
+ stdio: ['ignore', 'pipe', 'ignore'],
145
+ })
146
+ if (inside.status !== 0) return
147
+
148
+ const ignored = spawnSync('git', ['check-ignore', '--quiet', '--', relative(projectRoot, filePath)], {
149
+ cwd: projectRoot,
150
+ stdio: 'ignore',
151
+ })
152
+ assert(
153
+ ignored.status === 0,
154
+ `${basename(filePath)} 未被 Git 忽略;为防止泄密,拒绝读取该 profile`,
155
+ )
156
+ }
157
+
158
+ function mergeEntries(...maps) {
159
+ const merged = new Map()
160
+ for (const map of maps) {
161
+ for (const [key, value] of map) merged.set(key, value)
162
+ }
163
+ return merged
164
+ }
165
+
166
+ export function validateEnvProfile({ projectRoot, configDir, profile, environment }) {
167
+ const config = loadEnvProfileConfig(configDir)
168
+ assert(config.profiles[profile], `未声明 env profile: ${profile}`)
169
+ assert(config.environments.includes(environment), `profile 不支持环境: ${environment}`)
170
+
171
+ const paths = resolveEnvProfilePaths({ projectRoot, profile, environment })
172
+ assert(
173
+ existsSync(paths.source),
174
+ `缺少私有配置 ${basename(paths.source)};请从 ${relative(projectRoot, paths.template)} 复制并填写`,
175
+ )
176
+ assertPrivatePermissions(paths.source)
177
+ assertGitIgnored(projectRoot, paths.source)
178
+
179
+ const policy = loadEnvPolicy(configDir)
180
+ const placeholder = normalizedValue(policy.secretPlaceholder)
181
+ const secretKeys = new Set(policy.keys?.secret || [])
182
+ const allowedLocalKeys = new Set([
183
+ ...(policy.keys?.secret || []),
184
+ ...(policy.keys?.localOnly || []),
185
+ ...(policy.keys?.localOverride || []),
186
+ ])
187
+ const profileEntries = parseEnvContent(readFileSync(paths.source, 'utf8'), paths.source)
188
+ const errors = []
189
+ const invalidKeys = new Set()
190
+
191
+ for (const [key, value] of profileEntries) {
192
+ if (!allowedLocalKeys.has(key)) {
193
+ errors.push(`${basename(paths.source)}: 未在 env-policy keys.* 声明的键 ${key}`)
194
+ invalidKeys.add(key)
195
+ }
196
+ const normalized = normalizedValue(value)
197
+ if (secretKeys.has(key) && (!normalized || normalized === placeholder)) {
198
+ errors.push(`${basename(paths.source)}: 机密键 ${key} 必须设置真实值`)
199
+ invalidKeys.add(key)
200
+ }
201
+ }
202
+
203
+ for (const key of config.requiredLocalKeys?.[environment] || []) {
204
+ if (!profileEntries.has(key) || !normalizedValue(profileEntries.get(key))) {
205
+ if (invalidKeys.has(key)) continue
206
+ errors.push(`${basename(paths.source)}: 缺少 profile 必填键 ${key}`)
207
+ invalidKeys.add(key)
208
+ }
209
+ }
210
+
211
+ const checkedRequired = new Set()
212
+ for (const [targetId, target] of Object.entries(policy.targets || {})) {
213
+ const committedTemplate = target?.files?.committed
214
+ if (typeof committedTemplate !== 'string') continue
215
+ const committedFile = committedTemplate.replace(/\{env\}/g, environment)
216
+ const committedPath = join(projectRoot, committedFile)
217
+ const committedEntries = existsSync(committedPath)
218
+ ? parseEnvContent(readFileSync(committedPath, 'utf8'), committedPath)
219
+ : new Map()
220
+ const effective = mergeEntries(committedEntries, profileEntries)
221
+ for (const key of resolveTargetRequiredVars(policy, targetId, environment)) {
222
+ const identity = `${targetId}:${key}`
223
+ if (checkedRequired.has(identity)) continue
224
+ checkedRequired.add(identity)
225
+ const value = normalizedValue(effective.get(key))
226
+ if (!value || value === placeholder) {
227
+ if (invalidKeys.has(key)) continue
228
+ errors.push(`${targetId}@${environment}: 必填键 ${key} 未由 committed env 或 profile 提供`)
229
+ invalidKeys.add(key)
230
+ }
231
+ }
232
+ }
233
+
234
+ if (errors.length > 0) throw new Error(`env profile 校验未通过:\n${errors.join('\n')}`)
235
+
236
+ return {
237
+ profile,
238
+ label: config.profiles[profile].label || profile,
239
+ environment,
240
+ source: paths.source,
241
+ target: paths.target,
242
+ keyCount: profileEntries.size,
243
+ }
244
+ }
245
+
246
+ function sameFileContent(left, right) {
247
+ if (!existsSync(left) || !existsSync(right)) return false
248
+ const digest = file => createHash('sha256').update(readFileSync(file)).digest('hex')
249
+ return digest(left) === digest(right)
250
+ }
251
+
252
+ export function describeEnvProfiles({ projectRoot, configDir }) {
253
+ const config = loadEnvProfileConfig(configDir)
254
+ const rows = []
255
+ for (const [profile, metadata] of Object.entries(config.profiles)) {
256
+ for (const environment of config.environments) {
257
+ const paths = resolveEnvProfilePaths({ projectRoot, profile, environment })
258
+ let mode = '-'
259
+ if (existsSync(paths.source) && process.platform !== 'win32') {
260
+ mode = (statSync(paths.source).mode & 0o777).toString(8).padStart(3, '0')
261
+ }
262
+ rows.push({
263
+ profile,
264
+ label: metadata.label || profile,
265
+ environment,
266
+ exists: existsSync(paths.source),
267
+ mode,
268
+ active: sameFileContent(paths.source, paths.target),
269
+ source: paths.source,
270
+ })
271
+ }
272
+ }
273
+ return rows
274
+ }
275
+
276
+ function lockPathFor(projectRoot, environment) {
277
+ const key = createHash('sha256').update(`${projectRoot}\0${environment}`).digest('hex').slice(0, 20)
278
+ return join(tmpdir(), `dx-env-profile-${key}.lock`)
279
+ }
280
+
281
+ function isProcessAlive(pid) {
282
+ if (!Number.isInteger(pid) || pid <= 0) return false
283
+ try {
284
+ process.kill(pid, 0)
285
+ return true
286
+ } catch (error) {
287
+ return error?.code === 'EPERM'
288
+ }
289
+ }
290
+
291
+ function acquireLock(projectRoot, environment, profile) {
292
+ const lockPath = lockPathFor(projectRoot, environment)
293
+ for (let attempt = 0; attempt < 2; attempt += 1) {
294
+ try {
295
+ const fd = openSync(lockPath, 'wx', 0o600)
296
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, profile, environment }))
297
+ closeSync(fd)
298
+ return lockPath
299
+ } catch (error) {
300
+ if (error?.code !== 'EEXIST') throw error
301
+ let owner = null
302
+ try {
303
+ owner = JSON.parse(readFileSync(lockPath, 'utf8'))
304
+ } catch {}
305
+ if (owner && isProcessAlive(owner.pid)) {
306
+ throw new Error(
307
+ `已有 env profile 操作运行中: pid=${owner.pid}, profile=${owner.profile}, environment=${owner.environment}`,
308
+ )
309
+ }
310
+ recoverStaleTransaction(projectRoot, environment, owner?.transaction)
311
+ rmSync(lockPath, { force: true })
312
+ }
313
+ }
314
+ throw new Error(`无法取得 env profile 锁: ${lockPath}`)
315
+ }
316
+
317
+ function writeLockState(lockPath, state) {
318
+ const temporary = `${lockPath}.${randomUUID()}.tmp`
319
+ writeFileSync(temporary, JSON.stringify(state), { flag: 'wx', mode: 0o600 })
320
+ renameSync(temporary, lockPath)
321
+ }
322
+
323
+ function recoverStaleTransaction(projectRoot, environment, transaction) {
324
+ if (!transaction || typeof transaction !== 'object') return
325
+ const expectedTarget = join(projectRoot, `.env.${environment}.local`)
326
+ if (transaction.target !== expectedTarget) return
327
+ if (
328
+ typeof transaction.temporary !== 'string' ||
329
+ !transaction.temporary.startsWith(`${expectedTarget}.dx-profile-`)
330
+ ) {
331
+ return
332
+ }
333
+
334
+ rmSync(expectedTarget, { force: true })
335
+ rmSync(transaction.temporary, { force: true })
336
+ }
337
+
338
+ function materializeProfile(source, target, onPrepared) {
339
+ const suffix = randomUUID()
340
+ const temporary = `${target}.dx-profile-${suffix}`
341
+ assert(
342
+ !existsSync(target),
343
+ `不允许存在持久根配置 ${basename(target)};请迁移到品牌 profile 后删除该文件`,
344
+ )
345
+ const transaction = { target, temporary }
346
+ onPrepared?.(transaction)
347
+
348
+ writeFileSync(temporary, readFileSync(source), { flag: 'wx', mode: 0o600 })
349
+ chmodSync(temporary, 0o600)
350
+
351
+ try {
352
+ renameSync(temporary, target)
353
+ } catch (error) {
354
+ rmSync(temporary, { force: true })
355
+ throw error
356
+ }
357
+
358
+ return {
359
+ transaction,
360
+ restore() {
361
+ rmSync(target, { force: true })
362
+ rmSync(temporary, { force: true })
363
+ },
364
+ }
365
+ }
366
+
367
+ function runChild(command, args, options) {
368
+ return new Promise((resolve, reject) => {
369
+ const child = spawn(command, args, {
370
+ cwd: options.cwd,
371
+ env: options.env,
372
+ stdio: 'inherit',
373
+ shell: false,
374
+ })
375
+ const handlers = new Map()
376
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
377
+ const handler = () => {
378
+ if (child.exitCode === null && child.signalCode === null) child.kill(signal)
379
+ }
380
+ handlers.set(signal, handler)
381
+ process.on(signal, handler)
382
+ }
383
+ const removeHandlers = () => {
384
+ for (const [signal, handler] of handlers) process.off(signal, handler)
385
+ }
386
+
387
+ child.once('error', error => {
388
+ removeHandlers()
389
+ reject(error)
390
+ })
391
+ child.once('close', (code, signal) => {
392
+ removeHandlers()
393
+ resolve(code ?? SIGNAL_EXIT_CODES[signal] ?? 1)
394
+ })
395
+ })
396
+ }
397
+
398
+ export async function executeWithEnvProfile({
399
+ projectRoot,
400
+ configDir,
401
+ profile,
402
+ environment,
403
+ command,
404
+ args = [],
405
+ }) {
406
+ assert(typeof command === 'string' && command.trim(), 'env exec 必须在 -- 后提供要执行的命令')
407
+ const validated = validateEnvProfile({ projectRoot, configDir, profile, environment })
408
+ const lockPath = acquireLock(projectRoot, environment, profile)
409
+ let materialized = null
410
+
411
+ try {
412
+ materialized = materializeProfile(validated.source, validated.target, transaction => {
413
+ writeLockState(lockPath, { pid: process.pid, profile, environment, transaction })
414
+ })
415
+ const profileEntries = parseEnvContent(readFileSync(validated.source, 'utf8'), validated.source)
416
+ const profileEnv = Object.fromEntries(
417
+ [...profileEntries].map(([key, value]) => [key, normalizedValue(value)]),
418
+ )
419
+ return await runChild(command, args, {
420
+ cwd: projectRoot,
421
+ env: {
422
+ ...process.env,
423
+ ...profileEnv,
424
+ DX_ENV_PROFILE: profile,
425
+ DX_ENV_PROFILE_ENVIRONMENT: environment,
426
+ },
427
+ })
428
+ } finally {
429
+ try {
430
+ materialized?.restore()
431
+ } finally {
432
+ rmSync(lockPath, { force: true })
433
+ }
434
+ }
435
+ }
package/lib/env.js ADDED
@@ -0,0 +1,261 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { isAbsolute, join } from 'node:path'
3
+
4
+ function resolveProjectRoot() {
5
+ return process.env.DX_PROJECT_ROOT || process.cwd()
6
+ }
7
+
8
+ function resolveConfigDir() {
9
+ const projectRoot = resolveProjectRoot()
10
+ return process.env.DX_CONFIG_DIR || join(projectRoot, 'dx', 'config')
11
+ }
12
+
13
+ export class EnvManager {
14
+ constructor() {
15
+ this.projectRoot = resolveProjectRoot()
16
+ this.configDir = resolveConfigDir()
17
+ this.envLayers = this.loadEnvLayers()
18
+ this.latestEnvWarnings = []
19
+
20
+ // APP_ENV → NODE_ENV 映射(用于运行时行为和工具链,如 Nx/Next)
21
+ // 注意:'e2e' 在 dotenv 层使用独立层(.env.e2e),但在 NODE_ENV 上归并为 'test'
22
+ this.APP_TO_NODE_ENV = {
23
+ local: 'development',
24
+ development: 'development',
25
+ staging: 'production',
26
+ production: 'production',
27
+ e2e: 'test',
28
+ test: 'test',
29
+ }
30
+ }
31
+
32
+ // 加载环境层级配置
33
+ loadEnvLayers() {
34
+ try {
35
+ const configPath = join(this.configDir, 'env-layers.json')
36
+ return JSON.parse(readFileSync(configPath, 'utf8'))
37
+ } catch (error) {
38
+ // 使用默认配置(按环境 → 全局本地 → 环境本地 的优先级)
39
+ return {
40
+ development: ['.env.development', '.env.development.local'],
41
+ staging: ['.env.staging', '.env.staging.local'],
42
+ production: ['.env.production', '.env.production.local'],
43
+ test: ['.env.test', '.env.test.local'],
44
+ e2e: ['.env.e2e', '.env.e2e.local'],
45
+ }
46
+ }
47
+ }
48
+
49
+ // 将 APP_ENV 规范化为 dotenv 层(development/production/test/e2e)
50
+ mapAppEnvToLayerEnv(appEnv) {
51
+ const env = String(appEnv || '').toLowerCase()
52
+ if (env === 'e2e') return 'e2e'
53
+ if (env === 'staging') return 'staging'
54
+ if (env === 'production') return 'production'
55
+ if (env === 'test') return 'test'
56
+ return 'development'
57
+ }
58
+
59
+ // 将 APP_ENV 规范化为 NODE_ENV(development/production/test)
60
+ mapAppEnvToNodeEnv(appEnv) {
61
+ const env = String(appEnv || '').toLowerCase()
62
+ return this.APP_TO_NODE_ENV[env] || 'development'
63
+ }
64
+
65
+ // 同步 APP_ENV 与 NODE_ENV(不改变现有 APP_ENV;仅在缺失或需规范化时设置 NODE_ENV)
66
+ syncEnvironments(appEnv) {
67
+ const app = String(appEnv || process.env.APP_ENV || '').toLowerCase()
68
+ if (app) {
69
+ const node = this.mapAppEnvToNodeEnv(app)
70
+ process.env.APP_ENV = app
71
+ process.env.NODE_ENV = node
72
+ return { appEnv: app, nodeEnv: node }
73
+ }
74
+ // 若没有 APP_ENV,仍保证 NODE_ENV 有合理默认值
75
+ process.env.NODE_ENV = process.env.NODE_ENV || 'development'
76
+ return { appEnv: process.env.APP_ENV, nodeEnv: process.env.NODE_ENV }
77
+ }
78
+
79
+ // 检测当前环境(用于选择 dotenv 层,如 .env.production/.env.e2e)
80
+ detectEnvironment(flags = {}) {
81
+ if (flags.prod) return 'production'
82
+ if (flags.staging) return 'staging'
83
+ if (flags.dev) return 'development'
84
+ if (flags.test) return 'test'
85
+ if (flags.e2e) return 'e2e'
86
+
87
+ // 优先基于 APP_ENV 选择 dotenv 层
88
+ if (process.env.APP_ENV) {
89
+ return this.mapAppEnvToLayerEnv(process.env.APP_ENV)
90
+ }
91
+
92
+ // 回退到 NODE_ENV
93
+ return process.env.NODE_ENV || 'development'
94
+ }
95
+
96
+ // 获取解析后的 dotenv 层级路径
97
+ getResolvedEnvLayers(app, environment) {
98
+ const layers = this.envLayers[environment] || []
99
+ if (!app) return layers
100
+ return layers.map(layer => layer.replace('{app}', app))
101
+ }
102
+
103
+ // 构建dotenv命令参数
104
+ buildEnvFlags(app, environment, options = {}) {
105
+ const absolute = Boolean(options.absolute)
106
+ return this.getResolvedEnvLayers(app, environment)
107
+ .map(layer => {
108
+ const envFile = absolute && !isAbsolute(layer) ? join(this.projectRoot, layer) : layer
109
+ return `-e ${envFile}`
110
+ })
111
+ .join(' ')
112
+ }
113
+
114
+ // 检查必需环境变量
115
+ validateRequiredVars(requiredVars = [], sourceEnv = process.env) {
116
+ const missing = []
117
+ const placeholders = []
118
+
119
+ requiredVars.forEach(varName => {
120
+ const value = sourceEnv[varName]
121
+ if (value === undefined || value === null) {
122
+ missing.push(varName)
123
+ return
124
+ }
125
+
126
+ if (this.isPlaceholderEnvValue(value)) {
127
+ placeholders.push(varName)
128
+ }
129
+ })
130
+
131
+ if (missing.length > 0 || placeholders.length > 0) {
132
+ return { valid: false, missing, placeholders }
133
+ }
134
+ return { valid: true, missing: [], placeholders: [] }
135
+ }
136
+
137
+ // 判断环境变量值是否缺失或仅为占位内容
138
+ isMissingEnvValue(value) {
139
+ if (value === undefined || value === null) return true
140
+ return this.isPlaceholderEnvValue(value)
141
+ }
142
+
143
+ // 占位符判定:空串/空格/包裹引号但内容为空/null/undefined
144
+ isPlaceholderEnvValue(value) {
145
+ const stringValue = String(value)
146
+ const trimmed = stringValue.trim()
147
+ if (trimmed.length === 0) return true
148
+
149
+ let unwrapped = trimmed
150
+ const firstChar = trimmed[0]
151
+ const lastChar = trimmed[trimmed.length - 1]
152
+ const isQuotedPair =
153
+ (firstChar === '"' || firstChar === "'" || firstChar === '`') && firstChar === lastChar
154
+ if (trimmed.length >= 2 && isQuotedPair) {
155
+ unwrapped = trimmed.slice(1, -1).trim()
156
+ if (unwrapped.length === 0) return true
157
+ }
158
+
159
+ if (unwrapped.includes('__SET_IN_env.local__')) return true
160
+
161
+ const normalized = unwrapped.toLowerCase()
162
+ return normalized === 'null' || normalized === 'undefined'
163
+ }
164
+
165
+ collectEnvFromLayers(app, environment) {
166
+ const layers = this.getResolvedEnvLayers(app, environment)
167
+ const result = {}
168
+ const warnings = []
169
+
170
+ const interpolate = value =>
171
+ value.replace(/\$\{([^}]+)\}/g, (_, name) => {
172
+ const key = name.trim()
173
+ if (Object.prototype.hasOwnProperty.call(result, key)) return result[key]
174
+ if (process.env[key] !== undefined) return process.env[key]
175
+ return ''
176
+ })
177
+
178
+ layers.forEach(layer => {
179
+ const filePath = join(this.projectRoot, layer)
180
+ if (!existsSync(filePath)) return
181
+
182
+ try {
183
+ const content = readFileSync(filePath, 'utf8')
184
+ const lines = content.split(/\r?\n/)
185
+ for (const rawLine of lines) {
186
+ const line = rawLine.trim()
187
+ if (!line || line.startsWith('#')) continue
188
+ const eqIdx = line.indexOf('=')
189
+ if (eqIdx <= 0) continue
190
+ const key = line.slice(0, eqIdx).trim()
191
+ if (!key) continue
192
+ let value = line.slice(eqIdx + 1)
193
+
194
+ // 移除行末注释(仅当值未被引号包裹时处理)
195
+ let commentIndex = -1
196
+ if (!/^\s*['"`]/.test(value)) {
197
+ commentIndex = value.indexOf(' #')
198
+ if (commentIndex !== -1) {
199
+ value = value.slice(0, commentIndex)
200
+ }
201
+ }
202
+
203
+ value = value.trim()
204
+ const isSingleQuoted = value.startsWith("'") && value.endsWith("'")
205
+ const isDoubleQuoted = value.startsWith('"') && value.endsWith('"')
206
+ const isBacktickQuoted = value.startsWith('`') && value.endsWith('`')
207
+
208
+ if (isSingleQuoted || isDoubleQuoted || isBacktickQuoted) {
209
+ value = value.slice(1, -1)
210
+ }
211
+
212
+ if (!isSingleQuoted) {
213
+ value = interpolate(value)
214
+ }
215
+
216
+ const previous = result[key]
217
+ const previousIsNonEmpty = previous !== undefined && String(previous).trim().length > 0
218
+ if (previousIsNonEmpty && value.trim().length === 0) {
219
+ warnings.push(`环境文件 ${layer} 将 ${key} 覆盖为空值,请确认层级顺序是否正确`)
220
+ }
221
+
222
+ result[key] = value
223
+ }
224
+ } catch (error) {
225
+ throw new Error(`读取环境文件失败 (${filePath}): ${error.message}`)
226
+ }
227
+ })
228
+
229
+ this.latestEnvWarnings = warnings
230
+ return result
231
+ }
232
+
233
+ // 智能错误修复建议
234
+ suggestFixes(missing, environment) {
235
+ const env = environment || this.detectEnvironment()
236
+ return missing.map(varName => ({
237
+ var: varName,
238
+ suggestion: `请检查以下文件中的 ${varName} 配置:`,
239
+ files: [`.env.${env}`, `.env.${env}.local`],
240
+ }))
241
+ }
242
+
243
+ // 获取环境描述
244
+ getEnvironmentDescription(environment) {
245
+ const descriptions = {
246
+ development: '开发环境',
247
+ staging: '预发环境',
248
+ production: '生产环境',
249
+ test: '测试环境',
250
+ e2e: 'E2E测试环境',
251
+ }
252
+ return descriptions[environment] || environment
253
+ }
254
+
255
+ // 检查危险操作环境
256
+ isDangerousEnvironment(environment) {
257
+ return environment === 'production'
258
+ }
259
+ }
260
+
261
+ export const envManager = new EnvManager()