@weotro/dx 0.1.21 → 0.2.1
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 +78 -766
- 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/skills/dx-adopt/SKILL.md +88 -0
- package/skills/dx-adopt/agents/openai.yaml +2 -0
- package/skills/dx-adopt/references/dx-0.2-contract.md +95 -0
- package/skills/dx-adopt/references/migrate-0.1.md +48 -0
- 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/lib/env-profile.js
DELETED
|
@@ -1,531 +0,0 @@
|
|
|
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
|
-
realpathSync,
|
|
12
|
-
renameSync,
|
|
13
|
-
rmSync,
|
|
14
|
-
statSync,
|
|
15
|
-
writeFileSync,
|
|
16
|
-
} from 'node:fs'
|
|
17
|
-
import { tmpdir } from 'node:os'
|
|
18
|
-
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
19
|
-
import { loadEnvPolicy, resolveTargetRequiredVars } from './env-policy.js'
|
|
20
|
-
|
|
21
|
-
const PROFILE_CONFIG_FILE = 'env-profiles.json'
|
|
22
|
-
const PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/
|
|
23
|
-
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
24
|
-
const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }
|
|
25
|
-
|
|
26
|
-
function assert(condition, message) {
|
|
27
|
-
if (!condition) throw new Error(message)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function parseJsonFile(filePath) {
|
|
31
|
-
try {
|
|
32
|
-
return JSON.parse(readFileSync(filePath, 'utf8'))
|
|
33
|
-
} catch (error) {
|
|
34
|
-
throw new Error(`无法解析 ${filePath}: ${error.message}`)
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function loadEnvProfileConfig(configDir) {
|
|
39
|
-
const filePath = join(configDir, PROFILE_CONFIG_FILE)
|
|
40
|
-
assert(existsSync(filePath), `缺少配置文件 ${filePath}`)
|
|
41
|
-
const config = parseJsonFile(filePath)
|
|
42
|
-
|
|
43
|
-
assert(config.version === 1, `${PROFILE_CONFIG_FILE}.version 必须为 1`)
|
|
44
|
-
assert(
|
|
45
|
-
config.profiles && typeof config.profiles === 'object' && !Array.isArray(config.profiles),
|
|
46
|
-
`${PROFILE_CONFIG_FILE}.profiles 必须为对象`,
|
|
47
|
-
)
|
|
48
|
-
const profileNames = Object.keys(config.profiles)
|
|
49
|
-
assert(profileNames.length > 0, `${PROFILE_CONFIG_FILE}.profiles 不能为空`)
|
|
50
|
-
for (const name of profileNames) {
|
|
51
|
-
assert(PROFILE_NAME_PATTERN.test(name), `非法 profile 名称: ${name}`)
|
|
52
|
-
const profile = config.profiles[name]
|
|
53
|
-
assert(profile && typeof profile === 'object' && !Array.isArray(profile), `profile ${name} 必须为对象`)
|
|
54
|
-
if (profile.label !== undefined) {
|
|
55
|
-
assert(typeof profile.label === 'string' && profile.label.trim(), `profile ${name}.label 必须为非空字符串`)
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
assert(Array.isArray(config.environments), `${PROFILE_CONFIG_FILE}.environments 必须为数组`)
|
|
60
|
-
assert(config.environments.length > 0, `${PROFILE_CONFIG_FILE}.environments 不能为空`)
|
|
61
|
-
for (const environment of config.environments) {
|
|
62
|
-
assert(typeof environment === 'string' && environment.trim(), 'environment 必须为非空字符串')
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const requiredLocalKeys = config.requiredLocalKeys || {}
|
|
66
|
-
assert(
|
|
67
|
-
requiredLocalKeys && typeof requiredLocalKeys === 'object' && !Array.isArray(requiredLocalKeys),
|
|
68
|
-
`${PROFILE_CONFIG_FILE}.requiredLocalKeys 必须为对象`,
|
|
69
|
-
)
|
|
70
|
-
for (const [environment, keys] of Object.entries(requiredLocalKeys)) {
|
|
71
|
-
assert(config.environments.includes(environment), `requiredLocalKeys 包含未声明环境: ${environment}`)
|
|
72
|
-
assert(Array.isArray(keys), `requiredLocalKeys.${environment} 必须为数组`)
|
|
73
|
-
for (const key of keys) {
|
|
74
|
-
assert(ENV_KEY_PATTERN.test(key), `requiredLocalKeys.${environment} 包含非法键: ${key}`)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (config.committedRuntime !== undefined) {
|
|
79
|
-
const committedRuntime = config.committedRuntime
|
|
80
|
-
assert(
|
|
81
|
-
committedRuntime && typeof committedRuntime === 'object' && !Array.isArray(committedRuntime),
|
|
82
|
-
`${PROFILE_CONFIG_FILE}.committedRuntime 必须为对象`,
|
|
83
|
-
)
|
|
84
|
-
assert(
|
|
85
|
-
typeof committedRuntime.target === 'string' && committedRuntime.target.trim(),
|
|
86
|
-
`${PROFILE_CONFIG_FILE}.committedRuntime.target 必须为非空字符串`,
|
|
87
|
-
)
|
|
88
|
-
assert(Array.isArray(committedRuntime.keys), `${PROFILE_CONFIG_FILE}.committedRuntime.keys 必须为数组`)
|
|
89
|
-
const seenKeys = new Set()
|
|
90
|
-
for (const key of committedRuntime.keys) {
|
|
91
|
-
assert(
|
|
92
|
-
typeof key === 'string' && ENV_KEY_PATTERN.test(key),
|
|
93
|
-
`${PROFILE_CONFIG_FILE}.committedRuntime.keys 包含非法键: ${String(key)}`,
|
|
94
|
-
)
|
|
95
|
-
assert(!seenKeys.has(key), `${PROFILE_CONFIG_FILE}.committedRuntime.keys 包含重复键: ${key}`)
|
|
96
|
-
seenKeys.add(key)
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return config
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function resolveEnvProfilePaths({ projectRoot, profile, environment }) {
|
|
104
|
-
assert(PROFILE_NAME_PATTERN.test(profile || ''), `非法 profile 名称: ${profile || '<empty>'}`)
|
|
105
|
-
assert(typeof environment === 'string' && environment.trim(), 'environment 不能为空')
|
|
106
|
-
const profileDirectory = join(projectRoot, 'dx', 'env', 'templates', profile)
|
|
107
|
-
return {
|
|
108
|
-
source: join(profileDirectory, `${environment}.local`),
|
|
109
|
-
target: join(projectRoot, `.env.${environment}.local`),
|
|
110
|
-
template: join(profileDirectory, `${environment}.local.example`),
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function parseEnvContent(content, filePath) {
|
|
115
|
-
const entries = new Map()
|
|
116
|
-
const errors = []
|
|
117
|
-
const lines = String(content).split(/\r?\n/)
|
|
118
|
-
|
|
119
|
-
lines.forEach((rawLine, index) => {
|
|
120
|
-
let line = rawLine.trim()
|
|
121
|
-
if (!line || line.startsWith('#')) return
|
|
122
|
-
if (line.startsWith('export ')) line = line.slice('export '.length).trim()
|
|
123
|
-
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)
|
|
124
|
-
if (!match) {
|
|
125
|
-
errors.push(`${filePath}:${index + 1}: 不是合法的 KEY=VALUE`)
|
|
126
|
-
return
|
|
127
|
-
}
|
|
128
|
-
if (entries.has(match[1])) {
|
|
129
|
-
errors.push(`${filePath}:${index + 1}: 重复键 ${match[1]}`)
|
|
130
|
-
return
|
|
131
|
-
}
|
|
132
|
-
entries.set(match[1], match[2])
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
if (errors.length > 0) throw new Error(errors.join('\n'))
|
|
136
|
-
return entries
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function normalizedValue(value) {
|
|
140
|
-
const text = String(value ?? '').trim()
|
|
141
|
-
if (
|
|
142
|
-
text.length >= 2 &&
|
|
143
|
-
((text.startsWith('"') && text.endsWith('"')) ||
|
|
144
|
-
(text.startsWith("'") && text.endsWith("'")))
|
|
145
|
-
) {
|
|
146
|
-
return text.slice(1, -1).trim()
|
|
147
|
-
}
|
|
148
|
-
return text
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function assertPrivatePermissions(filePath) {
|
|
152
|
-
const linkStat = lstatSync(filePath)
|
|
153
|
-
assert(!linkStat.isSymbolicLink(), `profile 不允许使用符号链接: ${filePath}`)
|
|
154
|
-
const stat = statSync(filePath)
|
|
155
|
-
assert(stat.isFile(), `profile 必须是普通文件: ${filePath}`)
|
|
156
|
-
if (process.platform === 'win32') return
|
|
157
|
-
const publicBits = stat.mode & 0o077
|
|
158
|
-
assert(
|
|
159
|
-
publicBits === 0,
|
|
160
|
-
`${filePath} 权限过宽(当前 ${(stat.mode & 0o777).toString(8).padStart(3, '0')}),请执行 chmod 600 ${basename(filePath)}`,
|
|
161
|
-
)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function assertGitIgnored(projectRoot, filePath) {
|
|
165
|
-
const inside = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
166
|
-
cwd: projectRoot,
|
|
167
|
-
encoding: 'utf8',
|
|
168
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
169
|
-
})
|
|
170
|
-
if (inside.status !== 0) return
|
|
171
|
-
|
|
172
|
-
const ignored = spawnSync('git', ['check-ignore', '--quiet', '--', relative(projectRoot, filePath)], {
|
|
173
|
-
cwd: projectRoot,
|
|
174
|
-
stdio: 'ignore',
|
|
175
|
-
})
|
|
176
|
-
assert(
|
|
177
|
-
ignored.status === 0,
|
|
178
|
-
`${basename(filePath)} 未被 Git 忽略;为防止泄密,拒绝读取该 profile`,
|
|
179
|
-
)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function mergeEntries(...maps) {
|
|
183
|
-
const merged = new Map()
|
|
184
|
-
for (const map of maps) {
|
|
185
|
-
for (const [key, value] of map) merged.set(key, value)
|
|
186
|
-
}
|
|
187
|
-
return merged
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function resolveCommittedRuntimeEnv({ projectRoot, config, policy, environment }) {
|
|
191
|
-
const committedRuntime = config.committedRuntime
|
|
192
|
-
if (!committedRuntime || committedRuntime.keys.length === 0) return {}
|
|
193
|
-
|
|
194
|
-
const targetId = committedRuntime.target
|
|
195
|
-
const target = policy.targets?.[targetId]
|
|
196
|
-
assert(target, `${PROFILE_CONFIG_FILE}.committedRuntime.target 指向不存在的 policy target: ${targetId}`)
|
|
197
|
-
assert(
|
|
198
|
-
policy.environments.includes(environment),
|
|
199
|
-
`${PROFILE_CONFIG_FILE}.committedRuntime 不支持 policy 未声明的环境: ${environment}`,
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
const committedTemplate = target.files.committed
|
|
203
|
-
assert(
|
|
204
|
-
committedTemplate.includes('{env}'),
|
|
205
|
-
`env-policy.jsonc.targets.${targetId}.files.committed 必须包含 {env} 才能用于 committed runtime`,
|
|
206
|
-
)
|
|
207
|
-
const projectBoundary = resolve(projectRoot)
|
|
208
|
-
const committedPath = resolve(projectBoundary, committedTemplate.replace(/\{env\}/g, environment))
|
|
209
|
-
const pathWithinProject = relative(projectBoundary, committedPath)
|
|
210
|
-
assert(
|
|
211
|
-
pathWithinProject &&
|
|
212
|
-
pathWithinProject !== '..' &&
|
|
213
|
-
!pathWithinProject.startsWith(`..${sep}`) &&
|
|
214
|
-
!isAbsolute(pathWithinProject),
|
|
215
|
-
`committed runtime 文件必须位于项目根目录内: ${committedTemplate}`,
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
const secretKeys = new Set(policy.keys?.secret || [])
|
|
219
|
-
const localOnlyKeys = new Set(policy.keys?.localOnly || [])
|
|
220
|
-
for (const key of committedRuntime.keys) {
|
|
221
|
-
assert(!secretKeys.has(key), `committed runtime 不允许导出机密键: ${key}`)
|
|
222
|
-
assert(!localOnlyKeys.has(key), `committed runtime 不允许导出 localOnly 键: ${key}`)
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
assert(existsSync(committedPath), `committed runtime 文件不存在: ${pathWithinProject}`)
|
|
226
|
-
const linkStat = lstatSync(committedPath)
|
|
227
|
-
assert(!linkStat.isSymbolicLink(), `committed runtime 文件不允许使用符号链接: ${pathWithinProject}`)
|
|
228
|
-
assert(statSync(committedPath).isFile(), `committed runtime 路径必须是普通文件: ${pathWithinProject}`)
|
|
229
|
-
const realProjectBoundary = realpathSync(projectBoundary)
|
|
230
|
-
const realCommittedPath = realpathSync(committedPath)
|
|
231
|
-
const realPathWithinProject = relative(realProjectBoundary, realCommittedPath)
|
|
232
|
-
assert(
|
|
233
|
-
realPathWithinProject &&
|
|
234
|
-
realPathWithinProject !== '..' &&
|
|
235
|
-
!realPathWithinProject.startsWith(`..${sep}`) &&
|
|
236
|
-
!isAbsolute(realPathWithinProject),
|
|
237
|
-
`committed runtime 文件解析后必须位于项目根目录内: ${pathWithinProject}`,
|
|
238
|
-
)
|
|
239
|
-
|
|
240
|
-
const placeholder = normalizedValue(policy.secretPlaceholder)
|
|
241
|
-
const committedEntries = parseEnvContent(readFileSync(committedPath, 'utf8'), committedPath)
|
|
242
|
-
const committedEnv = {}
|
|
243
|
-
|
|
244
|
-
for (const key of committedRuntime.keys) {
|
|
245
|
-
assert(committedEntries.has(key), `${pathWithinProject}: 缺少 committed runtime 键 ${key}`)
|
|
246
|
-
const value = normalizedValue(committedEntries.get(key))
|
|
247
|
-
assert(value && value !== placeholder, `${pathWithinProject}: committed runtime 键 ${key} 不能是空值或占位符`)
|
|
248
|
-
committedEnv[key] = value
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
return committedEnv
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export function validateEnvProfile({ projectRoot, configDir, profile, environment }) {
|
|
255
|
-
const config = loadEnvProfileConfig(configDir)
|
|
256
|
-
assert(config.profiles[profile], `未声明 env profile: ${profile}`)
|
|
257
|
-
assert(config.environments.includes(environment), `profile 不支持环境: ${environment}`)
|
|
258
|
-
|
|
259
|
-
const paths = resolveEnvProfilePaths({ projectRoot, profile, environment })
|
|
260
|
-
assert(
|
|
261
|
-
existsSync(paths.source),
|
|
262
|
-
`缺少私有配置 ${basename(paths.source)};请从 ${relative(projectRoot, paths.template)} 复制并填写`,
|
|
263
|
-
)
|
|
264
|
-
assertPrivatePermissions(paths.source)
|
|
265
|
-
assertGitIgnored(projectRoot, paths.source)
|
|
266
|
-
|
|
267
|
-
const policy = loadEnvPolicy(configDir)
|
|
268
|
-
const committedRuntimeEnv = resolveCommittedRuntimeEnv({
|
|
269
|
-
projectRoot,
|
|
270
|
-
config,
|
|
271
|
-
policy,
|
|
272
|
-
environment,
|
|
273
|
-
})
|
|
274
|
-
const placeholder = normalizedValue(policy.secretPlaceholder)
|
|
275
|
-
const secretKeys = new Set(policy.keys?.secret || [])
|
|
276
|
-
const allowedLocalKeys = new Set([
|
|
277
|
-
...(policy.keys?.secret || []),
|
|
278
|
-
...(policy.keys?.localOnly || []),
|
|
279
|
-
...(policy.keys?.localOverride || []),
|
|
280
|
-
])
|
|
281
|
-
const profileEntries = parseEnvContent(readFileSync(paths.source, 'utf8'), paths.source)
|
|
282
|
-
const errors = []
|
|
283
|
-
const invalidKeys = new Set()
|
|
284
|
-
|
|
285
|
-
for (const [key, value] of profileEntries) {
|
|
286
|
-
if (!allowedLocalKeys.has(key)) {
|
|
287
|
-
errors.push(`${basename(paths.source)}: 未在 env-policy keys.* 声明的键 ${key}`)
|
|
288
|
-
invalidKeys.add(key)
|
|
289
|
-
}
|
|
290
|
-
const normalized = normalizedValue(value)
|
|
291
|
-
if (secretKeys.has(key) && (!normalized || normalized === placeholder)) {
|
|
292
|
-
errors.push(`${basename(paths.source)}: 机密键 ${key} 必须设置真实值`)
|
|
293
|
-
invalidKeys.add(key)
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
for (const key of config.requiredLocalKeys?.[environment] || []) {
|
|
298
|
-
if (!profileEntries.has(key) || !normalizedValue(profileEntries.get(key))) {
|
|
299
|
-
if (invalidKeys.has(key)) continue
|
|
300
|
-
errors.push(`${basename(paths.source)}: 缺少 profile 必填键 ${key}`)
|
|
301
|
-
invalidKeys.add(key)
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const checkedRequired = new Set()
|
|
306
|
-
for (const [targetId, target] of Object.entries(policy.targets || {})) {
|
|
307
|
-
const committedTemplate = target?.files?.committed
|
|
308
|
-
if (typeof committedTemplate !== 'string') continue
|
|
309
|
-
const committedFile = committedTemplate.replace(/\{env\}/g, environment)
|
|
310
|
-
const committedPath = join(projectRoot, committedFile)
|
|
311
|
-
const committedEntries = existsSync(committedPath)
|
|
312
|
-
? parseEnvContent(readFileSync(committedPath, 'utf8'), committedPath)
|
|
313
|
-
: new Map()
|
|
314
|
-
const effective = mergeEntries(committedEntries, profileEntries)
|
|
315
|
-
for (const key of resolveTargetRequiredVars(policy, targetId, environment)) {
|
|
316
|
-
const identity = `${targetId}:${key}`
|
|
317
|
-
if (checkedRequired.has(identity)) continue
|
|
318
|
-
checkedRequired.add(identity)
|
|
319
|
-
const value = normalizedValue(effective.get(key))
|
|
320
|
-
if (!value || value === placeholder) {
|
|
321
|
-
if (invalidKeys.has(key)) continue
|
|
322
|
-
errors.push(`${targetId}@${environment}: 必填键 ${key} 未由 committed env 或 profile 提供`)
|
|
323
|
-
invalidKeys.add(key)
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
if (errors.length > 0) throw new Error(`env profile 校验未通过:\n${errors.join('\n')}`)
|
|
329
|
-
|
|
330
|
-
return {
|
|
331
|
-
profile,
|
|
332
|
-
label: config.profiles[profile].label || profile,
|
|
333
|
-
environment,
|
|
334
|
-
source: paths.source,
|
|
335
|
-
target: paths.target,
|
|
336
|
-
keyCount: profileEntries.size,
|
|
337
|
-
committedRuntimeEnv,
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function sameFileContent(left, right) {
|
|
342
|
-
if (!existsSync(left) || !existsSync(right)) return false
|
|
343
|
-
const digest = file => createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
344
|
-
return digest(left) === digest(right)
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
export function describeEnvProfiles({ projectRoot, configDir }) {
|
|
348
|
-
const config = loadEnvProfileConfig(configDir)
|
|
349
|
-
const rows = []
|
|
350
|
-
for (const [profile, metadata] of Object.entries(config.profiles)) {
|
|
351
|
-
for (const environment of config.environments) {
|
|
352
|
-
const paths = resolveEnvProfilePaths({ projectRoot, profile, environment })
|
|
353
|
-
let mode = '-'
|
|
354
|
-
if (existsSync(paths.source) && process.platform !== 'win32') {
|
|
355
|
-
mode = (statSync(paths.source).mode & 0o777).toString(8).padStart(3, '0')
|
|
356
|
-
}
|
|
357
|
-
rows.push({
|
|
358
|
-
profile,
|
|
359
|
-
label: metadata.label || profile,
|
|
360
|
-
environment,
|
|
361
|
-
exists: existsSync(paths.source),
|
|
362
|
-
mode,
|
|
363
|
-
active: sameFileContent(paths.source, paths.target),
|
|
364
|
-
source: paths.source,
|
|
365
|
-
})
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
return rows
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function lockPathFor(projectRoot, environment) {
|
|
372
|
-
const key = createHash('sha256').update(`${projectRoot}\0${environment}`).digest('hex').slice(0, 20)
|
|
373
|
-
return join(tmpdir(), `dx-env-profile-${key}.lock`)
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function isProcessAlive(pid) {
|
|
377
|
-
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
378
|
-
try {
|
|
379
|
-
process.kill(pid, 0)
|
|
380
|
-
return true
|
|
381
|
-
} catch (error) {
|
|
382
|
-
return error?.code === 'EPERM'
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function acquireLock(projectRoot, environment, profile) {
|
|
387
|
-
const lockPath = lockPathFor(projectRoot, environment)
|
|
388
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
389
|
-
try {
|
|
390
|
-
const fd = openSync(lockPath, 'wx', 0o600)
|
|
391
|
-
writeFileSync(fd, JSON.stringify({ pid: process.pid, profile, environment }))
|
|
392
|
-
closeSync(fd)
|
|
393
|
-
return lockPath
|
|
394
|
-
} catch (error) {
|
|
395
|
-
if (error?.code !== 'EEXIST') throw error
|
|
396
|
-
let owner = null
|
|
397
|
-
try {
|
|
398
|
-
owner = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
399
|
-
} catch {}
|
|
400
|
-
if (owner && isProcessAlive(owner.pid)) {
|
|
401
|
-
throw new Error(
|
|
402
|
-
`已有 env profile 操作运行中: pid=${owner.pid}, profile=${owner.profile}, environment=${owner.environment}`,
|
|
403
|
-
)
|
|
404
|
-
}
|
|
405
|
-
recoverStaleTransaction(projectRoot, environment, owner?.transaction)
|
|
406
|
-
rmSync(lockPath, { force: true })
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
throw new Error(`无法取得 env profile 锁: ${lockPath}`)
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function writeLockState(lockPath, state) {
|
|
413
|
-
const temporary = `${lockPath}.${randomUUID()}.tmp`
|
|
414
|
-
writeFileSync(temporary, JSON.stringify(state), { flag: 'wx', mode: 0o600 })
|
|
415
|
-
renameSync(temporary, lockPath)
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function recoverStaleTransaction(projectRoot, environment, transaction) {
|
|
419
|
-
if (!transaction || typeof transaction !== 'object') return
|
|
420
|
-
const expectedTarget = join(projectRoot, `.env.${environment}.local`)
|
|
421
|
-
if (transaction.target !== expectedTarget) return
|
|
422
|
-
if (
|
|
423
|
-
typeof transaction.temporary !== 'string' ||
|
|
424
|
-
!transaction.temporary.startsWith(`${expectedTarget}.dx-profile-`)
|
|
425
|
-
) {
|
|
426
|
-
return
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
rmSync(expectedTarget, { force: true })
|
|
430
|
-
rmSync(transaction.temporary, { force: true })
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function materializeProfile(source, target, onPrepared) {
|
|
434
|
-
const suffix = randomUUID()
|
|
435
|
-
const temporary = `${target}.dx-profile-${suffix}`
|
|
436
|
-
assert(
|
|
437
|
-
!existsSync(target),
|
|
438
|
-
`不允许存在持久根配置 ${basename(target)};请迁移到品牌 profile 后删除该文件`,
|
|
439
|
-
)
|
|
440
|
-
const transaction = { target, temporary }
|
|
441
|
-
onPrepared?.(transaction)
|
|
442
|
-
|
|
443
|
-
writeFileSync(temporary, readFileSync(source), { flag: 'wx', mode: 0o600 })
|
|
444
|
-
chmodSync(temporary, 0o600)
|
|
445
|
-
|
|
446
|
-
try {
|
|
447
|
-
renameSync(temporary, target)
|
|
448
|
-
} catch (error) {
|
|
449
|
-
rmSync(temporary, { force: true })
|
|
450
|
-
throw error
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
return {
|
|
454
|
-
transaction,
|
|
455
|
-
restore() {
|
|
456
|
-
rmSync(target, { force: true })
|
|
457
|
-
rmSync(temporary, { force: true })
|
|
458
|
-
},
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
function runChild(command, args, options) {
|
|
463
|
-
return new Promise((resolve, reject) => {
|
|
464
|
-
const child = spawn(command, args, {
|
|
465
|
-
cwd: options.cwd,
|
|
466
|
-
env: options.env,
|
|
467
|
-
stdio: 'inherit',
|
|
468
|
-
shell: false,
|
|
469
|
-
})
|
|
470
|
-
const handlers = new Map()
|
|
471
|
-
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
472
|
-
const handler = () => {
|
|
473
|
-
if (child.exitCode === null && child.signalCode === null) child.kill(signal)
|
|
474
|
-
}
|
|
475
|
-
handlers.set(signal, handler)
|
|
476
|
-
process.on(signal, handler)
|
|
477
|
-
}
|
|
478
|
-
const removeHandlers = () => {
|
|
479
|
-
for (const [signal, handler] of handlers) process.off(signal, handler)
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
child.once('error', error => {
|
|
483
|
-
removeHandlers()
|
|
484
|
-
reject(error)
|
|
485
|
-
})
|
|
486
|
-
child.once('close', (code, signal) => {
|
|
487
|
-
removeHandlers()
|
|
488
|
-
resolve(code ?? SIGNAL_EXIT_CODES[signal] ?? 1)
|
|
489
|
-
})
|
|
490
|
-
})
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
export async function executeWithEnvProfile({
|
|
494
|
-
projectRoot,
|
|
495
|
-
configDir,
|
|
496
|
-
profile,
|
|
497
|
-
environment,
|
|
498
|
-
command,
|
|
499
|
-
args = [],
|
|
500
|
-
}) {
|
|
501
|
-
assert(typeof command === 'string' && command.trim(), 'env exec 必须在 -- 后提供要执行的命令')
|
|
502
|
-
const validated = validateEnvProfile({ projectRoot, configDir, profile, environment })
|
|
503
|
-
const lockPath = acquireLock(projectRoot, environment, profile)
|
|
504
|
-
let materialized = null
|
|
505
|
-
|
|
506
|
-
try {
|
|
507
|
-
materialized = materializeProfile(validated.source, validated.target, transaction => {
|
|
508
|
-
writeLockState(lockPath, { pid: process.pid, profile, environment, transaction })
|
|
509
|
-
})
|
|
510
|
-
const profileEntries = parseEnvContent(readFileSync(validated.source, 'utf8'), validated.source)
|
|
511
|
-
const profileEnv = Object.fromEntries(
|
|
512
|
-
[...profileEntries].map(([key, value]) => [key, normalizedValue(value)]),
|
|
513
|
-
)
|
|
514
|
-
return await runChild(command, args, {
|
|
515
|
-
cwd: projectRoot,
|
|
516
|
-
env: {
|
|
517
|
-
...process.env,
|
|
518
|
-
...profileEnv,
|
|
519
|
-
...validated.committedRuntimeEnv,
|
|
520
|
-
DX_ENV_PROFILE: profile,
|
|
521
|
-
DX_ENV_PROFILE_ENVIRONMENT: environment,
|
|
522
|
-
},
|
|
523
|
-
})
|
|
524
|
-
} finally {
|
|
525
|
-
try {
|
|
526
|
-
materialized?.restore()
|
|
527
|
-
} finally {
|
|
528
|
-
rmSync(lockPath, { force: true })
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|