@weotro/dx 0.1.19 → 0.2.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 (71) hide show
  1. package/README.md +77 -718
  2. package/bin/dx.js +100 -173
  3. package/example/.env.development +1 -0
  4. package/example/README.md +24 -0
  5. package/example/dx/scripts/build +2 -0
  6. package/example/dx/scripts/db +2 -0
  7. package/example/dx/scripts/deploy +2 -0
  8. package/example/dx/scripts/setup +2 -0
  9. package/example/dx/scripts/start +12 -0
  10. package/example/dx/scripts/test +2 -0
  11. package/lib/env.js +40 -257
  12. package/lib/logger.js +5 -238
  13. package/lib/setup.js +72 -0
  14. package/package.json +27 -13
  15. package/skills/ask_3th/SKILL.md +2 -0
  16. package/skills/delegate_3th/SKILL.md +4 -2
  17. package/skills/delegate_3th/scripts/delegate_3th.py +37 -41
  18. package/skills/delegate_3th/scripts/test_delegate_3th.py +43 -17
  19. package/bin/dx-with-version-env.js +0 -8
  20. package/lib/artifact-deploy/artifact-builder.js +0 -144
  21. package/lib/artifact-deploy/config.js +0 -180
  22. package/lib/artifact-deploy/remote-script.js +0 -301
  23. package/lib/artifact-deploy/remote-transport.js +0 -86
  24. package/lib/artifact-deploy.js +0 -70
  25. package/lib/backend-artifact-deploy/artifact-builder.js +0 -267
  26. package/lib/backend-artifact-deploy/config.js +0 -218
  27. package/lib/backend-artifact-deploy/path-utils.js +0 -18
  28. package/lib/backend-artifact-deploy/remote-phases.js +0 -14
  29. package/lib/backend-artifact-deploy/remote-result.js +0 -44
  30. package/lib/backend-artifact-deploy/remote-script.js +0 -507
  31. package/lib/backend-artifact-deploy/remote-transport.js +0 -123
  32. package/lib/backend-artifact-deploy/rollback.js +0 -5
  33. package/lib/backend-artifact-deploy/runtime-package.js +0 -46
  34. package/lib/backend-artifact-deploy.js +0 -91
  35. package/lib/backend-package.js +0 -674
  36. package/lib/cli/args.js +0 -38
  37. package/lib/cli/command-result.js +0 -1
  38. package/lib/cli/commands/contracts.js +0 -60
  39. package/lib/cli/commands/core.js +0 -533
  40. package/lib/cli/commands/db.js +0 -231
  41. package/lib/cli/commands/deploy.js +0 -175
  42. package/lib/cli/commands/env.js +0 -120
  43. package/lib/cli/commands/export.js +0 -39
  44. package/lib/cli/commands/package.js +0 -22
  45. package/lib/cli/commands/release.js +0 -55
  46. package/lib/cli/commands/stack.js +0 -427
  47. package/lib/cli/commands/start.js +0 -58
  48. package/lib/cli/commands/worktree.js +0 -145
  49. package/lib/cli/dx-cli.js +0 -1072
  50. package/lib/cli/flags.js +0 -123
  51. package/lib/cli/help-model.js +0 -222
  52. package/lib/cli/help-renderer.js +0 -137
  53. package/lib/cli/help-schema.js +0 -552
  54. package/lib/cli/help.js +0 -141
  55. package/lib/cli/index.js +0 -4
  56. package/lib/cli/nx-command.js +0 -13
  57. package/lib/confirm.js +0 -213
  58. package/lib/env-policy.js +0 -134
  59. package/lib/env-profile.js +0 -531
  60. package/lib/exec.js +0 -692
  61. package/lib/nx-ignore.js +0 -45
  62. package/lib/run-with-version-env.js +0 -163
  63. package/lib/sdk-build.js +0 -424
  64. package/lib/start-dev.js +0 -401
  65. package/lib/telegram-webhook.js +0 -431
  66. package/lib/validate-env.js +0 -317
  67. package/lib/vercel-deploy.js +0 -549
  68. package/lib/worktree.js +0 -1052
  69. package/skills/delegate_3th/scripts/__pycache__/delegate_3th.cpython-314.pyc +0 -0
  70. package/skills/delegate_3th/scripts/__pycache__/delegate_cc.cpython-314.pyc +0 -0
  71. package/skills/delegate_3th/scripts/__pycache__/test_delegate_cc.cpython-314.pyc +0 -0
package/bin/dx.js CHANGED
@@ -1,187 +1,114 @@
1
1
  #!/usr/bin/env node
2
-
3
- import { existsSync } from 'node:fs'
4
- import { resolve, dirname, join } from 'node:path'
2
+ import { statSync, lstatSync, readdirSync, openSync, readSync, closeSync, accessSync, constants } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import { spawn } from 'node:child_process'
5
5
  import { fileURLToPath } from 'node:url'
6
-
7
- function parseConfigDir(argv) {
8
- const envValue = process.env.DX_CONFIG_DIR
9
- if (envValue) return String(envValue)
10
-
11
- const args = Array.isArray(argv) ? argv : []
12
- const idx = args.indexOf('--config-dir')
13
- if (idx !== -1 && idx + 1 < args.length) return String(args[idx + 1])
14
- for (const token of args) {
15
- if (token.startsWith('--config-dir=')) return String(token.slice('--config-dir='.length))
6
+ import { logger } from '../lib/logger.js'
7
+ import { getPackageVersion } from '../lib/version.js'
8
+ import { loadEnvironment } from '../lib/env.js'
9
+ import { runBaseSetup } from '../lib/setup.js'
10
+
11
+ const [command, ...args] = process.argv.slice(2)
12
+ function hasShebang(entry) {
13
+ const descriptor = openSync(entry, 'r')
14
+ try {
15
+ const prefix = Buffer.alloc(4096)
16
+ const length = readSync(descriptor, prefix, 0, prefix.length, 0)
17
+ const firstLine = prefix.subarray(0, length).toString().split('\n', 1)[0]
18
+ // Keep the header within Linux's exec limit to prevent shell fallback or truncation.
19
+ return Buffer.byteLength(firstLine) <= 255 && /^#![ \t]*\/[^\s\0]+(?:[ \t]|$)/.test(firstLine)
20
+ } finally {
21
+ closeSync(descriptor)
16
22
  }
17
- return null
18
23
  }
19
-
20
- function stripConfigDirArgs(argv) {
21
- const args = Array.isArray(argv) ? [...argv] : []
22
- const out = []
23
- for (let i = 0; i < args.length; i++) {
24
- const token = args[i]
25
- if (token === '--config-dir') {
26
- i++
27
- continue
28
- }
29
- if (token.startsWith('--config-dir=')) continue
30
- out.push(token)
24
+ try {
25
+ if (command === '--version') {
26
+ process.stdout.write(`${getPackageVersion()}\n`)
27
+ process.exit(0)
31
28
  }
32
- return out
33
- }
34
-
35
- function isVersionInvocation(argv) {
36
- const raw = Array.isArray(argv) ? argv : []
37
- const filtered = stripConfigDirArgs(raw)
38
-
39
- const flags = []
40
- const positionals = []
41
- for (const token of filtered) {
42
- if (token === '--') break
43
- if (token.startsWith('-')) flags.push(token)
44
- else positionals.push(token)
45
- }
46
-
47
- // Keep current semantics:
48
- // - -V/--version always prints version and exits.
49
- // - -v is verbose in commands, but `dx -v` is commonly expected to show version.
50
- const hasCanonicalVersionFlag = flags.includes('-V') || flags.includes('--version')
51
- if (hasCanonicalVersionFlag) return true
52
-
53
- const isBareLowerV = flags.length === 1 && flags[0] === '-v' && positionals.length === 0
54
- if (isBareLowerV) return true
55
-
56
- const isVersionCommand = positionals.length === 1 && positionals[0] === 'version'
57
- if (isVersionCommand) return true
58
-
59
- return false
60
- }
61
-
62
- function isInitialInvocation(argv) {
63
- const raw = Array.isArray(argv) ? argv : []
64
- const filtered = stripConfigDirArgs(raw)
65
-
66
- for (const token of filtered) {
67
- if (token === '--') break
68
- if (token.startsWith('-')) continue
69
- return token === 'initial'
70
- }
71
-
72
- return false
73
- }
74
-
75
- function findProjectRootFrom(startDir) {
76
- let current = resolve(startDir)
77
- while (true) {
78
- const marker = join(current, 'dx', 'config', 'commands.json')
79
- if (existsSync(marker)) return current
80
-
81
- const parent = dirname(current)
82
- if (parent === current) return null
83
- current = parent
84
- }
85
- }
86
-
87
- function findRepoRootFrom(startDir) {
88
- let current = resolve(startDir)
89
- while (true) {
90
- // ai-monorepo style marker
91
- if (existsSync(join(current, 'pnpm-workspace.yaml'))) return current
92
- // fallback marker
93
- if (existsSync(join(current, 'package.json'))) return current
94
-
95
- const parent = dirname(current)
96
- if (parent === current) return null
97
- current = parent
29
+ if (command === 'initial') {
30
+ if (args.length === 1 && args[0] === '--help') {
31
+ process.stdout.write('用法:dx initial\n将包内 skills 同步到 ~/.agents/skills,并在 ~/.claude/skills 建立链接;更新托管技能并清理历史旧副本。无需应用工程。\n')
32
+ } else {
33
+ if (args.length) throw new Error('initial 不接受应用参数;请使用 dx initial 或 dx initial --help。')
34
+ const { runCodexInitial } = await import('../lib/codex-initial.js')
35
+ await runCodexInitial({ packageRoot: fileURLToPath(new URL('../', import.meta.url)) })
36
+ }
37
+ process.exit(0)
98
38
  }
99
- }
100
-
101
- function inferProjectRootFromConfigDir(configDir, startDir) {
102
- const normalized = String(configDir).replace(/\\/g, '/')
103
- if (normalized.endsWith('/dx/config')) return resolve(configDir, '..', '..')
104
- if (normalized.endsWith('/scripts/config')) return resolve(configDir, '..', '..')
105
-
106
- return findRepoRootFrom(configDir) || findRepoRootFrom(startDir) || resolve(startDir)
107
- }
108
-
109
- async function main() {
110
- const rawArgs = process.argv.slice(2)
111
-
112
- if (isVersionInvocation(rawArgs)) {
113
- const { getPackageVersion } = await import('../lib/version.js')
114
- console.log(getPackageVersion())
115
- return
39
+ const help = command === undefined || command === '--help'
40
+ if (!help && !/^[a-z][a-z0-9-]*$/.test(command)) {
41
+ throw new Error('非法命令名:仅接受小写字母开头,后接小写字母、数字或连字符。')
116
42
  }
117
-
118
- if (isInitialInvocation(rawArgs)) {
119
- const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
120
- const [{ logger }, { runCodexInitial }] = await Promise.all([
121
- import('../lib/logger.js'),
122
- import('../lib/codex-initial.js'),
123
- ])
124
-
43
+ let root = process.cwd()
44
+ for (;;) {
45
+ const marker = join(root, 'dx/scripts')
46
+ let markerExists = false
125
47
  try {
126
- await runCodexInitial({ packageRoot })
127
- return
48
+ lstatSync(marker)
49
+ markerExists = true
50
+ if (!statSync(marker).isDirectory()) throw new Error(`工程标记 ${marker} 不是目录;请改为脚本目录。`)
51
+ accessSync(marker, constants.R_OK | constants.X_OK)
52
+ break
128
53
  } catch (error) {
129
- logger.error('initial 执行失败')
130
- logger.error(error?.message || String(error))
131
- process.exit(1)
54
+ if (markerExists || error.code !== 'ENOENT') throw new Error(`无法读取工程标记 ${marker}:${error.message};请检查目录类型、链接目标与读取、搜索权限。`)
132
55
  }
133
- }
134
-
135
- const overrideConfigDir = parseConfigDir(rawArgs)
136
- const filteredArgs = stripConfigDirArgs(rawArgs)
137
-
138
- const startDir = process.cwd()
139
- let projectRoot
140
- let configDir
141
-
142
- if (overrideConfigDir) {
143
- // When dx is installed globally, users may prefer providing DX_CONFIG_DIR/--config-dir.
144
- // In that case, do not require dx/config marker discovery.
145
- configDir = resolve(startDir, overrideConfigDir)
146
- if (!existsSync(join(configDir, 'commands.json'))) {
147
- console.error(`dx: 配置目录无效: ${configDir}`)
148
- console.error('dx: 期望存在 commands.json')
149
- process.exit(1)
56
+ const parent = dirname(root)
57
+ if (parent === root) {
58
+ if (help) { root = null; break }
59
+ throw new Error('未找到工程:请创建 dx/scripts/<command> 可执行脚本。')
150
60
  }
151
- projectRoot = inferProjectRootFromConfigDir(configDir, startDir)
152
- } else {
153
- projectRoot = findProjectRootFrom(startDir)
154
- if (!projectRoot) {
155
- console.error('dx: 未找到项目配置目录: dx/config/commands.json')
156
- console.error('dx: 请在项目目录内执行 dx,或先创建 dx/config 并放置 commands.json')
157
- console.error('dx: 也可通过 DX_CONFIG_DIR 或 --config-dir 指定配置目录')
158
- process.exit(1)
159
- }
160
- configDir = join(projectRoot, 'dx', 'config')
61
+ root = parent
161
62
  }
162
-
163
- process.env.DX_PROJECT_ROOT = projectRoot
164
- process.env.DX_CONFIG_DIR = configDir
165
-
166
- process.chdir(projectRoot)
167
-
168
- process.argv = [process.argv[0], process.argv[1], ...filteredArgs]
169
-
170
- const [{ logger }, { DxCli }] = await Promise.all([
171
- import('../lib/logger.js'),
172
- import('../lib/cli/dx-cli.js'),
173
- ])
174
-
175
- const cli = new DxCli({ projectRoot, configDir: process.env.DX_CONFIG_DIR, invocation: 'dx' })
176
- await cli.run().catch(error => {
177
- logger.error('CLI启动失败')
178
- logger.error(error?.message || String(error))
179
- process.exit(1)
63
+ if (help) {
64
+ const entries = root ? readdirSync(join(root, 'dx/scripts')).filter(name => {
65
+ if (name === 'initial' || !/^[a-z][a-z0-9-]*$/.test(name)) return false
66
+ const entry = join(root, 'dx/scripts', name)
67
+ try {
68
+ if (!statSync(entry).isFile()) return false
69
+ accessSync(entry, constants.R_OK | constants.X_OK)
70
+ return hasShebang(entry)
71
+ } catch { return false }
72
+ }).sort() : []
73
+ process.stdout.write(`用法:dx <command> [args...]\n内置命令:initial(同步包内 skills,无需应用工程)\n标准入口:setup start build test db deploy\n当前工程入口:${entries.join(' ') || '无'}\n入口:dx/scripts/<command>,需可执行权限和 shebang。\n--help 显示帮助;--version 显示版本。应用命令后的参数原样传给应用脚本。\n`)
74
+ process.exit(0)
75
+ }
76
+ const entry = join(root, 'dx/scripts', command)
77
+ try {
78
+ if (!statSync(entry).isFile()) throw new Error('入口不是普通文件')
79
+ accessSync(entry, constants.R_OK | constants.X_OK)
80
+ } catch (error) {
81
+ throw new Error(`命令 ${command} 无法使用入口 ${entry}:${error.message};请创建带 shebang 的脚本并执行 chmod +x "${entry}",检查读取与执行权限。`)
82
+ }
83
+ if (!hasShebang(entry)) throw new Error(`命令 ${command} 的入口 ${entry} 缺少有效 shebang;请在首行指定解释器绝对路径,例如 #!/bin/sh,首行不得超过 255 字节(不含换行)。`)
84
+ if (command === 'setup') runBaseSetup(root)
85
+ const env = loadEnvironment(root, args)
86
+ const startupError = error => `命令 ${command} 启动失败,入口 ${entry}:${error.message};请检查 shebang 解释器已安装、PATH 与执行权限。`
87
+ let child
88
+ try {
89
+ child = spawn(entry, args, { cwd: root, env, stdio: 'inherit' })
90
+ } catch (error) {
91
+ throw new Error(startupError(error))
92
+ }
93
+ const forwardInt = () => child.kill('SIGINT')
94
+ const forwardTerm = () => child.kill('SIGTERM')
95
+ process.on('SIGINT', forwardInt)
96
+ process.on('SIGTERM', forwardTerm)
97
+ const cleanup = () => {
98
+ process.removeListener('SIGINT', forwardInt)
99
+ process.removeListener('SIGTERM', forwardTerm)
100
+ }
101
+ child.on('error', error => {
102
+ cleanup()
103
+ logger.error(startupError(error))
104
+ process.exitCode = 1
105
+ })
106
+ child.on('exit', (code, signal) => {
107
+ cleanup()
108
+ if (signal) process.kill(process.pid, signal)
109
+ else process.exitCode = code
180
110
  })
111
+ } catch (error) {
112
+ logger.error(error.message)
113
+ process.exitCode = 1
181
114
  }
182
-
183
- main().catch(error => {
184
- console.error('dx: CLI启动失败')
185
- console.error(error?.message || String(error))
186
- process.exit(1)
187
- })
@@ -0,0 +1 @@
1
+ EXAMPLE_GREETING=开发环境演示
@@ -0,0 +1,24 @@
1
+ # 应用脚本最小示例
2
+
3
+ 本目录展示目标 monorepo 如何提供 `dx/scripts/` 入口。脚本仅演示接口,不执行真实依赖安装、数据库写入或部署。
4
+
5
+ 在本目录执行:
6
+
7
+ ```bash
8
+ node ../bin/dx.js --help
9
+ node ../bin/dx.js setup
10
+ node ../bin/dx.js start stack
11
+ node ../bin/dx.js start admin
12
+ node ../bin/dx.js build admin
13
+ node ../bin/dx.js test
14
+ node ../bin/dx.js db --help
15
+ node ../bin/dx.js deploy --help
16
+ ```
17
+
18
+ `setup` 演示 clone 或新 worktree 后显式进行准备。dx 默认加载工程根 `.env.development` 和可选的 `.env.development.local`;`start` 直接使用注入的环境。`stack` 对应示例定义的全部服务,`admin` 对应单个应用。
19
+
20
+ 在 linked worktree 中运行 setup 时,dx 先从主 Git 工作目录按相对路径复制所有缺失的 .local 文件,再加载环境并调用示例脚本;已有目标不覆盖。该公共步骤无需应用手动调用。这里的示例位于 Git 根的 example 子目录,因此其本地覆盖文件为 example/.env.development.local。
21
+
22
+ 实际工程应替换这些示例的应用逻辑。入口使用 shebang 和可执行权限,内部实现可以拆分到工程自己的其他文件。多服务并发启动、失败处理和子进程清理由工程自行决定。
23
+
24
+ 环境文件中的值只用于示例,不包含真实凭据。本地真实值放入被 Git 忽略的 `.env.development.local`。标准流和参数保留原样,应用自行提供 `--help`。可用 `node ../bin/dx.js start admin --prod` 选择 production 文件。
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ printf '演示构建,收到 %s 个参数。\n' "$#"
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ printf '演示数据库入口,收到 %s 个参数。\n' "$#"
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ printf '演示部署入口,实际确认、安全策略和恢复由应用负责。\n'
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ printf '演示工程准备,具体安装和生成步骤由应用维护。\n'
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // dx 注入环境;应用定义启动目标及具体启动方式。
3
+ const [target = 'stack'] = process.argv.slice(2)
4
+ const environment = process.env.APP_ENV
5
+ const applications = ['backend', 'admin']
6
+ if (target !== 'stack' && !applications.includes(target)) {
7
+ console.error(`未知目标 ${target},可选 stack、backend、admin。`)
8
+ process.exit(1)
9
+ }
10
+ const selected = target === 'stack' ? applications : [target]
11
+ console.log(`${process.env.EXAMPLE_GREETING || '启动演示'}:environment=${environment} target=${target}`)
12
+ for (const application of selected) console.log(`演示启动 ${application}`)
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ printf '演示测试,收到 %s 个参数。\n' "$#"
package/lib/env.js CHANGED
@@ -1,261 +1,44 @@
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() {
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { parse } from 'dotenv'
4
+ import { expand } from 'dotenv-expand'
5
+
6
+ const flags = new Map([
7
+ ['--prod', 'production'], ['--staging', 'staging'], ['--dev', 'development'],
8
+ ['--test', 'test'], ['--e2e', 'e2e'],
9
+ ])
10
+ const nodeEnvironments = new Map([
11
+ ['development', 'development'], ['staging', 'production'], ['production', 'production'],
12
+ ['test', 'test'], ['e2e', 'test'],
13
+ ])
14
+
15
+ export function loadEnvironment(root, args, inherited = process.env) {
16
+ const separator = args.indexOf('--')
17
+ const selectors = separator < 0 ? args : args.slice(0, separator)
18
+ const explicit = [...flags].find(([flag]) => selectors.includes(flag))?.[1]
19
+ const requested = String(explicit || inherited.APP_ENV || inherited.NODE_ENV || 'development').toLowerCase()
20
+ const environment = requested === 'local' || requested === 'dev' ? 'development' : requested
21
+ if (!nodeEnvironments.has(environment)) {
22
+ throw new Error('无法选择环境;请使用 --dev、--prod、--staging、--test、--e2e,或设置有效的 APP_ENV / NODE_ENV。')
23
+ }
24
+
25
+ const env = { ...inherited, APP_ENV: environment, NODE_ENV: nodeEnvironments.get(environment) }
26
+ for (const name of [`.env.${environment}`, `.env.${environment}.local`]) {
27
+ const path = join(root, name)
28
+ let content
34
29
  try {
35
- const configPath = join(this.configDir, 'env-layers.json')
36
- return JSON.parse(readFileSync(configPath, 'utf8'))
30
+ content = readFileSync(path, 'utf8')
37
31
  } 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测试环境',
32
+ if (error.code === 'ENOENT') continue
33
+ throw new Error(`无法读取环境文件 ${path};请检查文件类型和读取权限(${error.code})。`)
251
34
  }
252
- return descriptions[environment] || environment
253
- }
254
-
255
- // 检查危险操作环境
256
- isDangerousEnvironment(environment) {
257
- return environment === 'production'
258
- }
35
+ const parsed = parse(content)
36
+ delete parsed.APP_ENV
37
+ delete parsed.NODE_ENV
38
+ // File values override inherited values while expansion can reference earlier layers.
39
+ const context = { ...env }
40
+ for (const key of Object.keys(parsed)) delete context[key]
41
+ Object.assign(env, expand({ parsed, processEnv: context }).parsed)
42
+ }
43
+ return env
259
44
  }
260
-
261
- export const envManager = new EnvManager()