@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.
- package/LICENSE +21 -0
- package/README.md +755 -0
- package/bin/dx-with-version-env.js +8 -0
- package/bin/dx.js +187 -0
- package/lib/artifact-deploy/artifact-builder.js +144 -0
- package/lib/artifact-deploy/config.js +180 -0
- package/lib/artifact-deploy/remote-script.js +301 -0
- package/lib/artifact-deploy/remote-transport.js +86 -0
- package/lib/artifact-deploy.js +70 -0
- package/lib/backend-artifact-deploy/artifact-builder.js +267 -0
- package/lib/backend-artifact-deploy/config.js +218 -0
- package/lib/backend-artifact-deploy/path-utils.js +18 -0
- package/lib/backend-artifact-deploy/remote-phases.js +14 -0
- package/lib/backend-artifact-deploy/remote-result.js +44 -0
- package/lib/backend-artifact-deploy/remote-script.js +507 -0
- package/lib/backend-artifact-deploy/remote-transport.js +123 -0
- package/lib/backend-artifact-deploy/rollback.js +5 -0
- package/lib/backend-artifact-deploy/runtime-package.js +46 -0
- package/lib/backend-artifact-deploy.js +91 -0
- package/lib/backend-package.js +674 -0
- package/lib/cli/args.js +38 -0
- package/lib/cli/command-result.js +1 -0
- package/lib/cli/commands/contracts.js +60 -0
- package/lib/cli/commands/core.js +533 -0
- package/lib/cli/commands/db.js +231 -0
- package/lib/cli/commands/deploy.js +175 -0
- package/lib/cli/commands/env.js +120 -0
- package/lib/cli/commands/export.js +39 -0
- package/lib/cli/commands/package.js +22 -0
- package/lib/cli/commands/release.js +55 -0
- package/lib/cli/commands/stack.js +427 -0
- package/lib/cli/commands/start.js +58 -0
- package/lib/cli/commands/worktree.js +145 -0
- package/lib/cli/dx-cli.js +1072 -0
- package/lib/cli/flags.js +123 -0
- package/lib/cli/help-model.js +222 -0
- package/lib/cli/help-renderer.js +137 -0
- package/lib/cli/help-schema.js +552 -0
- package/lib/cli/help.js +141 -0
- package/lib/cli/index.js +4 -0
- package/lib/cli/nx-command.js +13 -0
- package/lib/codex-initial.js +271 -0
- package/lib/confirm.js +213 -0
- package/lib/env-policy.js +134 -0
- package/lib/env-profile.js +435 -0
- package/lib/env.js +261 -0
- package/lib/exec.js +692 -0
- package/lib/logger.js +239 -0
- package/lib/nx-ignore.js +45 -0
- package/lib/run-with-version-env.js +163 -0
- package/lib/sdk-build.js +424 -0
- package/lib/start-dev.js +401 -0
- package/lib/telegram-webhook.js +431 -0
- package/lib/validate-env.js +317 -0
- package/lib/vercel-deploy.js +549 -0
- package/lib/version.js +14 -0
- package/lib/worktree.js +1052 -0
- package/package.json +45 -0
- package/skills/create-issue/SKILL.md +90 -0
- package/skills/delivering-design-handoff/SKILL.md +290 -0
- package/skills/doctor/SKILL.md +76 -0
- package/skills/gh-dependabot-cleanup/SKILL.md +54 -0
- package/skills/gh-dependabot-cleanup/agents/openai.yaml +7 -0
- package/skills/git-release/SKILL.md +194 -0
- package/skills/git-release/agents/openai.yaml +7 -0
- package/skills/online-debug-guard/SKILL.md +111 -0
- package/skills/ship-issue-pr/SKILL.md +676 -0
- 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()
|