@weotro/dx 0.1.21 → 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.
- package/README.md +76 -768
- package/bin/dx.js +100 -182
- package/example/.env.development +1 -0
- package/example/README.md +24 -0
- package/example/dx/scripts/build +2 -0
- package/example/dx/scripts/db +2 -0
- package/example/dx/scripts/deploy +2 -0
- package/example/dx/scripts/setup +2 -0
- package/example/dx/scripts/start +12 -0
- package/example/dx/scripts/test +2 -0
- package/lib/env.js +40 -257
- package/lib/logger.js +5 -238
- package/lib/setup.js +72 -0
- package/package.json +27 -13
- package/skills/ask_3th/SKILL.md +2 -0
- package/skills/delegate_3th/SKILL.md +4 -2
- package/skills/delegate_3th/scripts/delegate_3th.py +37 -41
- package/skills/delegate_3th/scripts/test_delegate_3th.py +43 -17
- package/bin/dx-with-version-env.js +0 -8
- package/lib/artifact-deploy/artifact-builder.js +0 -144
- package/lib/artifact-deploy/config.js +0 -180
- package/lib/artifact-deploy/remote-script.js +0 -301
- package/lib/artifact-deploy/remote-transport.js +0 -86
- package/lib/artifact-deploy.js +0 -70
- package/lib/backend-artifact-deploy/artifact-builder.js +0 -267
- package/lib/backend-artifact-deploy/config.js +0 -218
- package/lib/backend-artifact-deploy/path-utils.js +0 -18
- package/lib/backend-artifact-deploy/remote-phases.js +0 -14
- package/lib/backend-artifact-deploy/remote-result.js +0 -44
- package/lib/backend-artifact-deploy/remote-script.js +0 -507
- package/lib/backend-artifact-deploy/remote-transport.js +0 -123
- package/lib/backend-artifact-deploy/rollback.js +0 -5
- package/lib/backend-artifact-deploy/runtime-package.js +0 -46
- package/lib/backend-artifact-deploy.js +0 -91
- package/lib/backend-package.js +0 -674
- package/lib/cli/args.js +0 -38
- package/lib/cli/command-result.js +0 -1
- package/lib/cli/commands/contracts.js +0 -60
- package/lib/cli/commands/core.js +0 -533
- package/lib/cli/commands/db.js +0 -231
- package/lib/cli/commands/deploy.js +0 -175
- package/lib/cli/commands/env.js +0 -120
- package/lib/cli/commands/export.js +0 -39
- package/lib/cli/commands/package.js +0 -22
- package/lib/cli/commands/release.js +0 -55
- package/lib/cli/commands/stack.js +0 -427
- package/lib/cli/commands/start.js +0 -58
- package/lib/cli/commands/worktree.js +0 -145
- package/lib/cli/dx-cli.js +0 -1098
- package/lib/cli/flags.js +0 -123
- package/lib/cli/help-model.js +0 -234
- package/lib/cli/help-renderer.js +0 -137
- package/lib/cli/help-schema.js +0 -552
- package/lib/cli/help.js +0 -141
- package/lib/cli/index.js +0 -4
- package/lib/cli/nx-command.js +0 -13
- package/lib/confirm.js +0 -213
- package/lib/env-policy.js +0 -134
- package/lib/env-profile.js +0 -531
- package/lib/exec.js +0 -698
- package/lib/nx-ignore.js +0 -45
- package/lib/run-with-version-env.js +0 -163
- package/lib/sdk-build.js +0 -424
- package/lib/start-dev.js +0 -401
- package/lib/telegram-webhook.js +0 -431
- package/lib/validate-env.js +0 -317
- package/lib/vercel-deploy.js +0 -549
- package/lib/worktree.js +0 -1052
- package/skills/delegate_3th/scripts/__pycache__/delegate_3th.cpython-314.pyc +0 -0
- package/skills/delegate_3th/scripts/__pycache__/delegate_cc.cpython-314.pyc +0 -0
- package/skills/delegate_3th/scripts/__pycache__/test_delegate_cc.cpython-314.pyc +0 -0
package/bin/dx.js
CHANGED
|
@@ -1,196 +1,114 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function stripConfigDirArgs(argv) {
|
|
23
|
-
const args = Array.isArray(argv) ? [...argv] : []
|
|
24
|
-
const out = []
|
|
25
|
-
for (let i = 0; i < args.length; i++) {
|
|
26
|
-
const token = args[i]
|
|
27
|
-
if (token === '--') {
|
|
28
|
-
out.push(...args.slice(i))
|
|
29
|
-
break
|
|
30
|
-
}
|
|
31
|
-
if (token === '--config-dir') {
|
|
32
|
-
if (args[i + 1] === undefined || args[i + 1] === '--') {
|
|
33
|
-
throw new Error('--config-dir 需要在 -- 分隔符前提供配置目录')
|
|
34
|
-
}
|
|
35
|
-
i++
|
|
36
|
-
continue
|
|
37
|
-
}
|
|
38
|
-
if (token.startsWith('--config-dir=')) continue
|
|
39
|
-
out.push(token)
|
|
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)
|
|
40
22
|
}
|
|
41
|
-
return out
|
|
42
23
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const flags = []
|
|
49
|
-
const positionals = []
|
|
50
|
-
for (const token of filtered) {
|
|
51
|
-
if (token === '--') break
|
|
52
|
-
if (token.startsWith('-')) flags.push(token)
|
|
53
|
-
else positionals.push(token)
|
|
24
|
+
try {
|
|
25
|
+
if (command === '--version') {
|
|
26
|
+
process.stdout.write(`${getPackageVersion()}\n`)
|
|
27
|
+
process.exit(0)
|
|
54
28
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const isVersionCommand = positionals.length === 1 && positionals[0] === 'version'
|
|
66
|
-
if (isVersionCommand) return true
|
|
67
|
-
|
|
68
|
-
return false
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function isInitialInvocation(argv) {
|
|
72
|
-
const raw = Array.isArray(argv) ? argv : []
|
|
73
|
-
const filtered = stripConfigDirArgs(raw)
|
|
74
|
-
|
|
75
|
-
for (const token of filtered) {
|
|
76
|
-
if (token === '--') break
|
|
77
|
-
if (token.startsWith('-')) continue
|
|
78
|
-
return token === 'initial'
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return false
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function findProjectRootFrom(startDir) {
|
|
85
|
-
let current = resolve(startDir)
|
|
86
|
-
while (true) {
|
|
87
|
-
const marker = join(current, 'dx', 'config', 'commands.json')
|
|
88
|
-
if (existsSync(marker)) return current
|
|
89
|
-
|
|
90
|
-
const parent = dirname(current)
|
|
91
|
-
if (parent === current) return null
|
|
92
|
-
current = parent
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function findRepoRootFrom(startDir) {
|
|
97
|
-
let current = resolve(startDir)
|
|
98
|
-
while (true) {
|
|
99
|
-
// ai-monorepo style marker
|
|
100
|
-
if (existsSync(join(current, 'pnpm-workspace.yaml'))) return current
|
|
101
|
-
// fallback marker
|
|
102
|
-
if (existsSync(join(current, 'package.json'))) return current
|
|
103
|
-
|
|
104
|
-
const parent = dirname(current)
|
|
105
|
-
if (parent === current) return null
|
|
106
|
-
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)
|
|
107
38
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const normalized = String(configDir).replace(/\\/g, '/')
|
|
112
|
-
if (normalized.endsWith('/dx/config')) return resolve(configDir, '..', '..')
|
|
113
|
-
if (normalized.endsWith('/scripts/config')) return resolve(configDir, '..', '..')
|
|
114
|
-
|
|
115
|
-
return findRepoRootFrom(configDir) || findRepoRootFrom(startDir) || resolve(startDir)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function main() {
|
|
119
|
-
const rawArgs = process.argv.slice(2)
|
|
120
|
-
|
|
121
|
-
if (isVersionInvocation(rawArgs)) {
|
|
122
|
-
const { getPackageVersion } = await import('../lib/version.js')
|
|
123
|
-
console.log(getPackageVersion())
|
|
124
|
-
return
|
|
39
|
+
const help = command === undefined || command === '--help'
|
|
40
|
+
if (!help && !/^[a-z][a-z0-9-]*$/.test(command)) {
|
|
41
|
+
throw new Error('非法命令名:仅接受小写字母开头,后接小写字母、数字或连字符。')
|
|
125
42
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
import('../lib/logger.js'),
|
|
131
|
-
import('../lib/codex-initial.js'),
|
|
132
|
-
])
|
|
133
|
-
|
|
43
|
+
let root = process.cwd()
|
|
44
|
+
for (;;) {
|
|
45
|
+
const marker = join(root, 'dx/scripts')
|
|
46
|
+
let markerExists = false
|
|
134
47
|
try {
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
137
53
|
} catch (error) {
|
|
138
|
-
|
|
139
|
-
logger.error(error?.message || String(error))
|
|
140
|
-
process.exit(1)
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const overrideConfigDir = parseConfigDir(rawArgs)
|
|
145
|
-
const filteredArgs = stripConfigDirArgs(rawArgs)
|
|
146
|
-
|
|
147
|
-
const startDir = process.cwd()
|
|
148
|
-
let projectRoot
|
|
149
|
-
let configDir
|
|
150
|
-
|
|
151
|
-
if (overrideConfigDir) {
|
|
152
|
-
// When dx is installed globally, users may prefer providing DX_CONFIG_DIR/--config-dir.
|
|
153
|
-
// In that case, do not require dx/config marker discovery.
|
|
154
|
-
configDir = resolve(startDir, overrideConfigDir)
|
|
155
|
-
if (!existsSync(join(configDir, 'commands.json'))) {
|
|
156
|
-
console.error(`dx: 配置目录无效: ${configDir}`)
|
|
157
|
-
console.error('dx: 期望存在 commands.json')
|
|
158
|
-
process.exit(1)
|
|
54
|
+
if (markerExists || error.code !== 'ENOENT') throw new Error(`无法读取工程标记 ${marker}:${error.message};请检查目录类型、链接目标与读取、搜索权限。`)
|
|
159
55
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
console.error('dx: 未找到项目配置目录: dx/config/commands.json')
|
|
165
|
-
console.error('dx: 请在项目目录内执行 dx,或先创建 dx/config 并放置 commands.json')
|
|
166
|
-
console.error('dx: 也可通过 DX_CONFIG_DIR 或 --config-dir 指定配置目录')
|
|
167
|
-
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> 可执行脚本。')
|
|
168
60
|
}
|
|
169
|
-
|
|
61
|
+
root = parent
|
|
170
62
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
189
110
|
})
|
|
111
|
+
} catch (error) {
|
|
112
|
+
logger.error(error.message)
|
|
113
|
+
process.exitCode = 1
|
|
190
114
|
}
|
|
191
|
-
|
|
192
|
-
main().catch(error => {
|
|
193
|
-
console.error('dx: CLI启动失败')
|
|
194
|
-
console.error(error?.message || String(error))
|
|
195
|
-
process.exit(1)
|
|
196
|
-
})
|
|
@@ -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,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}`)
|
package/lib/env.js
CHANGED
|
@@ -1,261 +1,44 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
return JSON.parse(readFileSync(configPath, 'utf8'))
|
|
30
|
+
content = readFileSync(path, 'utf8')
|
|
37
31
|
} catch (error) {
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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()
|