@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,317 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { loadEnvPolicy } from './env-policy.js'
|
|
4
|
+
|
|
5
|
+
const ROOT_DIR = process.env.DX_PROJECT_ROOT || process.cwd()
|
|
6
|
+
const CONFIG_DIR = process.env.DX_CONFIG_DIR || join(ROOT_DIR, 'dx', 'config')
|
|
7
|
+
const ROOT_ENV_FILE = join(ROOT_DIR, '.env')
|
|
8
|
+
const EXTRA_ENV_IGNORED_DIRS = new Set([
|
|
9
|
+
'.cache',
|
|
10
|
+
'.claude',
|
|
11
|
+
'.codex',
|
|
12
|
+
'.git',
|
|
13
|
+
'.idea',
|
|
14
|
+
'.vercel',
|
|
15
|
+
'node_modules',
|
|
16
|
+
'.nx',
|
|
17
|
+
'.omc',
|
|
18
|
+
'.omx',
|
|
19
|
+
'.opencode',
|
|
20
|
+
'.pytest_cache',
|
|
21
|
+
'dist',
|
|
22
|
+
'logs',
|
|
23
|
+
'tmp',
|
|
24
|
+
'.schaltwerk',
|
|
25
|
+
])
|
|
26
|
+
const EXTRA_ENV_ALLOWED_PATHS = new Set(['docker/.env', 'docker/.env.example'])
|
|
27
|
+
const ENV_EXAMPLE_FILE = join(ROOT_DIR, '.env.example')
|
|
28
|
+
|
|
29
|
+
export function validateEnvironment() {
|
|
30
|
+
if (!process.env.NODE_ENV) {
|
|
31
|
+
console.warn('⚠️ NODE_ENV 未设置,默认使用 development')
|
|
32
|
+
process.env.NODE_ENV = 'development'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (typeof process.env.APP_ENV === 'string' && process.env.APP_ENV.trim() === '') {
|
|
36
|
+
delete process.env.APP_ENV
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const policy = loadEnvPolicy(CONFIG_DIR)
|
|
40
|
+
|
|
41
|
+
enforceRootOnlyEnvFiles(policy)
|
|
42
|
+
enforceGlobalLocalFileProhibited()
|
|
43
|
+
|
|
44
|
+
enforceSecretPolicy(policy)
|
|
45
|
+
enforceEnvExamplePolicy(policy)
|
|
46
|
+
|
|
47
|
+
return { nodeEnv: process.env.NODE_ENV, appEnv: process.env.APP_ENV }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function enforceRootOnlyEnvFiles(policy) {
|
|
51
|
+
if (existsSync(ROOT_ENV_FILE)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'检测到根目录存在 .env 文件,请迁移到 .env.<env> / .env.<env>.local 并删除 .env',
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 保留现有规则:禁止子目录出现任意 .env* 文件(除特例路径)
|
|
58
|
+
// 注:该规则对是否启用 env-policy 都有效。
|
|
59
|
+
|
|
60
|
+
const violations = []
|
|
61
|
+
const queue = ['.']
|
|
62
|
+
|
|
63
|
+
while (queue.length > 0) {
|
|
64
|
+
const current = queue.pop()
|
|
65
|
+
const dirPath = join(ROOT_DIR, current)
|
|
66
|
+
const entries = readdirSync(dirPath, { withFileTypes: true })
|
|
67
|
+
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
if (EXTRA_ENV_IGNORED_DIRS.has(entry.name)) continue
|
|
71
|
+
const next = current === '.' ? entry.name : `${current}/${entry.name}`
|
|
72
|
+
queue.push(next)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!entry.isFile()) continue
|
|
77
|
+
if (!entry.name.startsWith('.env')) continue
|
|
78
|
+
|
|
79
|
+
const relativePath = current === '.' ? entry.name : `${current}/${entry.name}`
|
|
80
|
+
|
|
81
|
+
if (!relativePath.includes('/')) {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (EXTRA_ENV_ALLOWED_PATHS.has(relativePath)) continue
|
|
86
|
+
|
|
87
|
+
if (policy) {
|
|
88
|
+
const globs = Array.isArray(policy.layout?.allowSubdirGlobs) ? policy.layout.allowSubdirGlobs : []
|
|
89
|
+
if (isAllowedBySimpleGlob(relativePath, globs)) continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
violations.push(relativePath)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (violations.length > 0) {
|
|
97
|
+
const list = violations.join(', ')
|
|
98
|
+
throw new Error(
|
|
99
|
+
`检测到非根目录下的 env 文件: ${list}\n请将这些文件迁移到根目录或删除,再重试命令。`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function enforceGlobalLocalFileProhibited() {
|
|
105
|
+
const legacyLocal = join(ROOT_DIR, '.env.local')
|
|
106
|
+
if (existsSync(legacyLocal)) {
|
|
107
|
+
throw new Error('项目已弃用 .env.local,请改用 .env.<env>.local 存放机密信息并删除 .env.local')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function enforceSecretPolicy(policy) {
|
|
112
|
+
const placeholder = String(policy.secretPlaceholder || '').trim()
|
|
113
|
+
const secretKeys = new Set(policy.keys?.secret || [])
|
|
114
|
+
const localOnlyKeys = new Set(policy.keys?.localOnly || [])
|
|
115
|
+
const localOverrideKeys = new Set(policy.keys?.localOverride || [])
|
|
116
|
+
|
|
117
|
+
const invalidOverlap = findOverlaps([
|
|
118
|
+
{ name: 'keys.secret', set: secretKeys },
|
|
119
|
+
{ name: 'keys.localOnly', set: localOnlyKeys },
|
|
120
|
+
{ name: 'keys.localOverride', set: localOverrideKeys },
|
|
121
|
+
])
|
|
122
|
+
if (invalidOverlap.length > 0) {
|
|
123
|
+
throw new Error(`env-policy.jsonc keys 分类存在重复: ${invalidOverlap.join(', ')}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const errors = []
|
|
127
|
+
const filePairs = listTargetEnvFilePairs(policy)
|
|
128
|
+
|
|
129
|
+
for (const envName of policy.environments || []) {
|
|
130
|
+
for (const pair of filePairs) {
|
|
131
|
+
const committed = replaceEnvToken(pair.committed, envName)
|
|
132
|
+
const local = replaceEnvToken(pair.local, envName)
|
|
133
|
+
|
|
134
|
+
const committedPath = join(ROOT_DIR, committed)
|
|
135
|
+
const localPath = join(ROOT_DIR, local)
|
|
136
|
+
|
|
137
|
+
const committedExists = existsSync(committedPath)
|
|
138
|
+
const localExists = existsSync(localPath)
|
|
139
|
+
|
|
140
|
+
if (!committedExists && !localExists) continue
|
|
141
|
+
if (!committedExists && localExists) {
|
|
142
|
+
errors.push(`${committed} 缺失(但存在 ${local}),请补充 committed 模板文件`)
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const committedEntries = committedExists ? parseEnvFile(committedPath) : new Map()
|
|
147
|
+
const localEntries = localExists ? parseEnvFile(localPath) : new Map()
|
|
148
|
+
|
|
149
|
+
// Committed: secret keys must exist and be placeholder.
|
|
150
|
+
for (const key of secretKeys) {
|
|
151
|
+
if (!committedEntries.has(key)) {
|
|
152
|
+
errors.push(`${committed}: 缺少机密键模板 ${key}`)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
const rawValue = String(committedEntries.get(key) ?? '')
|
|
156
|
+
if (rawValue.trim() !== placeholder) {
|
|
157
|
+
errors.push(`${committed}: 机密键 ${key} 必须使用占位符 ${placeholder}`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Committed: localOnly must not appear.
|
|
162
|
+
for (const key of localOnlyKeys) {
|
|
163
|
+
if (committedEntries.has(key)) {
|
|
164
|
+
errors.push(`${committed}: localOnly 键 ${key} 不允许出现在非 local 文件中`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (localExists) {
|
|
169
|
+
const allowedInLocal = new Set([...secretKeys, ...localOnlyKeys, ...localOverrideKeys])
|
|
170
|
+
|
|
171
|
+
for (const [key, value] of localEntries.entries()) {
|
|
172
|
+
if (!allowedInLocal.has(key)) {
|
|
173
|
+
errors.push(`${local}: 包含未声明的键 ${key}(请加入 env-policy.jsonc.keys.* 或迁移到 committed 文件)`)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (secretKeys.has(key)) {
|
|
178
|
+
if (String(value ?? '').trim() === placeholder) {
|
|
179
|
+
errors.push(`${local}: 机密键 ${key} 不允许使用占位符,请设置真实值`)
|
|
180
|
+
}
|
|
181
|
+
if (!committedEntries.has(key)) {
|
|
182
|
+
errors.push(`${committed}: 缺少机密键模板 ${key}(因为 ${local} 中存在该键)`)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (localOnlyKeys.has(key) && committedEntries.has(key)) {
|
|
187
|
+
errors.push(`${committed}: localOnly 键 ${key} 不允许出现在 committed 文件中(已在 ${local} 中存在)`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (errors.length > 0) {
|
|
195
|
+
throw new Error(`环境变量机密策略校验未通过:\n${errors.join('\n')}`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function enforceEnvExamplePolicy(policy) {
|
|
200
|
+
if (!existsSync(ENV_EXAMPLE_FILE)) return
|
|
201
|
+
|
|
202
|
+
const placeholder = String(policy.secretPlaceholder || '').trim()
|
|
203
|
+
const secretKeys = new Set(policy.keys?.secret || [])
|
|
204
|
+
const localOnlyKeys = new Set(policy.keys?.localOnly || [])
|
|
205
|
+
const entries = parseEnvFile(ENV_EXAMPLE_FILE)
|
|
206
|
+
const errors = []
|
|
207
|
+
|
|
208
|
+
for (const [key, value] of entries.entries()) {
|
|
209
|
+
if (localOnlyKeys.has(key)) {
|
|
210
|
+
errors.push(`.env.example 不允许包含 localOnly 键: ${key}`)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (secretKeys.has(key)) {
|
|
214
|
+
if (String(value ?? '').trim() !== placeholder) {
|
|
215
|
+
errors.push(`.env.example 中机密键 ${key} 必须使用占位符 ${placeholder}`)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (errors.length > 0) {
|
|
221
|
+
throw new Error(errors.join('\n'))
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function listTargetEnvFilePairs(policy) {
|
|
226
|
+
const pairs = []
|
|
227
|
+
const targets = policy.targets || {}
|
|
228
|
+
for (const target of Object.values(targets)) {
|
|
229
|
+
const committed = target?.files?.committed
|
|
230
|
+
const local = target?.files?.local
|
|
231
|
+
if (typeof committed !== 'string' || typeof local !== 'string') continue
|
|
232
|
+
pairs.push({ committed, local })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// De-dup
|
|
236
|
+
const seen = new Set()
|
|
237
|
+
return pairs.filter(p => {
|
|
238
|
+
const key = `${p.committed}@@${p.local}`
|
|
239
|
+
if (seen.has(key)) return false
|
|
240
|
+
seen.add(key)
|
|
241
|
+
return true
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function replaceEnvToken(template, envName) {
|
|
246
|
+
return String(template).replace(/\{env\}/g, envName)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isAllowedBySimpleGlob(path, globs) {
|
|
250
|
+
for (const raw of globs) {
|
|
251
|
+
const glob = String(raw)
|
|
252
|
+
if (!glob.includes('*')) {
|
|
253
|
+
if (glob === path) return true
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
if (globToRegex(glob).test(path)) return true
|
|
257
|
+
}
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function globToRegex(glob) {
|
|
262
|
+
let pattern = ''
|
|
263
|
+
for (let i = 0; i < glob.length; i++) {
|
|
264
|
+
const ch = glob[i]
|
|
265
|
+
if (ch === '*') {
|
|
266
|
+
if (glob[i + 1] === '*') {
|
|
267
|
+
pattern += '.*'
|
|
268
|
+
i += 1
|
|
269
|
+
} else {
|
|
270
|
+
pattern += '[^/]*'
|
|
271
|
+
}
|
|
272
|
+
} else if ('\\^$+?.()|[]{}'.includes(ch)) {
|
|
273
|
+
pattern += `\\${ch}`
|
|
274
|
+
} else {
|
|
275
|
+
pattern += ch
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return new RegExp(`^${pattern}$`)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function findOverlaps(namedSets) {
|
|
282
|
+
const seen = new Map()
|
|
283
|
+
const overlaps = []
|
|
284
|
+
for (const { name, set } of namedSets) {
|
|
285
|
+
for (const key of set) {
|
|
286
|
+
if (seen.has(key)) {
|
|
287
|
+
overlaps.push(`${key} (${seen.get(key)} & ${name})`)
|
|
288
|
+
} else {
|
|
289
|
+
seen.set(key, name)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return overlaps
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function parseEnvFile(filePath) {
|
|
297
|
+
const content = readFileSync(filePath, 'utf8')
|
|
298
|
+
const map = new Map()
|
|
299
|
+
const lines = content.split(/\r?\n/)
|
|
300
|
+
|
|
301
|
+
for (const raw of lines) {
|
|
302
|
+
const trimmed = raw.trim()
|
|
303
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
304
|
+
|
|
305
|
+
const eqIdx = trimmed.indexOf('=')
|
|
306
|
+
if (eqIdx <= 0) continue
|
|
307
|
+
|
|
308
|
+
const key = trimmed.slice(0, eqIdx).trim()
|
|
309
|
+
if (!key) continue
|
|
310
|
+
|
|
311
|
+
map.set(key, trimmed.slice(eqIdx + 1))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return map
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export default { validateEnvironment }
|