@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,271 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative } from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
|
|
5
|
+
import { logger } from './logger.js'
|
|
6
|
+
|
|
7
|
+
const TEMP_DIR_PATTERN = /^\..+\.(tmp|backup)-\d+-\d+$/
|
|
8
|
+
|
|
9
|
+
// 历史上曾在 skills/ 目录托管、但后续已删除的 skill 名称。
|
|
10
|
+
// 来源:git log --all --diff-filter=A --name-only -- 'skills/*' | sed -n 's#^skills/\([^/]*\)/.*#\1#p' | sort -u
|
|
11
|
+
// 再减去当前 skills/ 现存名单。
|
|
12
|
+
// 这些名字需要在 ~/.agents、~/.claude、~/.codex 三处彻底清理(软链或真实目录都清)。
|
|
13
|
+
// 将来从 skills/ 删除新的 skill 时,把它追加到这里即可。
|
|
14
|
+
const DELETED_SKILLS = [
|
|
15
|
+
'autospec',
|
|
16
|
+
'backend-audit-fixer',
|
|
17
|
+
'backend-layering-audit-fixer',
|
|
18
|
+
'e2e-audit-fixer',
|
|
19
|
+
'env-accessor-audit-fixer',
|
|
20
|
+
'error-handling-audit-fixer',
|
|
21
|
+
'feature-decide-plan-execute',
|
|
22
|
+
'git-commit-and-pr',
|
|
23
|
+
'issues-batch-deliver',
|
|
24
|
+
'multi-pr-feature-delivery',
|
|
25
|
+
'naming-convention-audit',
|
|
26
|
+
'omc-reference',
|
|
27
|
+
'pagination-dto-audit-fixer',
|
|
28
|
+
'pr-ship',
|
|
29
|
+
'pr-train-ship',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
async function collectSkillNames(dir) {
|
|
33
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
34
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function collectAllFiles(dir) {
|
|
38
|
+
const out = []
|
|
39
|
+
|
|
40
|
+
async function walk(current) {
|
|
41
|
+
const entries = await fs.readdir(current, { withFileTypes: true })
|
|
42
|
+
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const full = join(current, entry.name)
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
if (entry.name === '__pycache__' || entry.name === '.pytest_cache') {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
await walk(full)
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (!entry.isFile()) continue
|
|
53
|
+
|
|
54
|
+
const lowerName = entry.name.toLowerCase()
|
|
55
|
+
if (lowerName.endsWith('.pyc') || lowerName.endsWith('.pyo') || lowerName.endsWith('.pyd')) continue
|
|
56
|
+
out.push(full)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await walk(dir)
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function ensureDir(path) {
|
|
65
|
+
await fs.mkdir(path, { recursive: true })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function pathExists(path) {
|
|
69
|
+
try {
|
|
70
|
+
await fs.access(path)
|
|
71
|
+
return true
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function lstatIfExists(path) {
|
|
78
|
+
try {
|
|
79
|
+
return await fs.lstat(path)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error?.code === 'ENOENT') return null
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function assertDirExists(path, label) {
|
|
87
|
+
try {
|
|
88
|
+
const st = await fs.stat(path)
|
|
89
|
+
if (!st.isDirectory()) {
|
|
90
|
+
throw new Error(`${label} 不是目录: ${path}`)
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error?.message?.startsWith(`${label} 不是目录:`)) {
|
|
94
|
+
throw error
|
|
95
|
+
}
|
|
96
|
+
const message = error?.message || String(error)
|
|
97
|
+
throw new Error(`${label} 不存在或不可访问: ${path}\n${message}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function copyDirMerge({ srcDir, dstDir }) {
|
|
102
|
+
const entries = await fs.readdir(srcDir, { withFileTypes: true })
|
|
103
|
+
let fileCount = 0
|
|
104
|
+
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (!entry.isDirectory()) continue
|
|
107
|
+
|
|
108
|
+
const srcSkillDir = join(srcDir, entry.name)
|
|
109
|
+
const dstSkillDir = join(dstDir, entry.name)
|
|
110
|
+
const token = `${process.pid}-${Date.now()}`
|
|
111
|
+
const tmpSkillDir = join(dstDir, `.${entry.name}.tmp-${token}`)
|
|
112
|
+
const backupSkillDir = join(dstDir, `.${entry.name}.backup-${token}`)
|
|
113
|
+
let hasBackup = false
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const files = await collectAllFiles(srcSkillDir)
|
|
117
|
+
fileCount += files.length
|
|
118
|
+
await ensureDir(tmpSkillDir)
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
const rel = relative(srcSkillDir, file)
|
|
121
|
+
const target = join(tmpSkillDir, rel)
|
|
122
|
+
await ensureDir(dirname(target))
|
|
123
|
+
await fs.copyFile(file, target)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (await pathExists(dstSkillDir)) {
|
|
127
|
+
await fs.rename(dstSkillDir, backupSkillDir)
|
|
128
|
+
hasBackup = true
|
|
129
|
+
}
|
|
130
|
+
await fs.rename(tmpSkillDir, dstSkillDir)
|
|
131
|
+
if (hasBackup) {
|
|
132
|
+
await fs.rm(backupSkillDir, { recursive: true, force: true })
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
await fs.rm(tmpSkillDir, { recursive: true, force: true })
|
|
136
|
+
if (hasBackup && !(await pathExists(dstSkillDir))) {
|
|
137
|
+
await fs.rename(backupSkillDir, dstSkillDir)
|
|
138
|
+
hasBackup = false
|
|
139
|
+
}
|
|
140
|
+
if (hasBackup) {
|
|
141
|
+
await fs.rm(backupSkillDir, { recursive: true, force: true })
|
|
142
|
+
}
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { fileCount }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function removeManagedNonSymlinkSkills(skillsDir, skillNames) {
|
|
151
|
+
for (const skillName of skillNames) {
|
|
152
|
+
const target = join(skillsDir, skillName)
|
|
153
|
+
const stat = await lstatIfExists(target)
|
|
154
|
+
if (!stat) continue
|
|
155
|
+
if (stat.isSymbolicLink()) continue
|
|
156
|
+
await fs.rm(target, { recursive: true, force: true })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 彻底清理指定名称的 skill:无论是软链还是真实目录/文件都删除。
|
|
161
|
+
// 用于历史上已从 skills/ 删除的 skill,需在各目标目录连根拔除。
|
|
162
|
+
// 这是尽力而为的清理:单个名字清理失败(如权限问题)只记录并继续,
|
|
163
|
+
// 不抛出,避免一个陈旧副本的异常阻断整个 skill 同步主流程。
|
|
164
|
+
async function purgeSkills(skillsDir, skillNames) {
|
|
165
|
+
let purged = 0
|
|
166
|
+
let failed = 0
|
|
167
|
+
for (const skillName of skillNames) {
|
|
168
|
+
const target = join(skillsDir, skillName)
|
|
169
|
+
let stat
|
|
170
|
+
try {
|
|
171
|
+
stat = await lstatIfExists(target)
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.warn(`清理历史 skill 失败(跳过): ${target} — ${error?.message || String(error)}`)
|
|
174
|
+
failed++
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
if (!stat) continue
|
|
178
|
+
try {
|
|
179
|
+
await fs.rm(target, { recursive: true, force: true })
|
|
180
|
+
purged++
|
|
181
|
+
} catch (error) {
|
|
182
|
+
logger.warn(`清理历史 skill 失败(跳过): ${target} — ${error?.message || String(error)}`)
|
|
183
|
+
failed++
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { purged, failed }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function removeStaleTempDirs(skillsDir) {
|
|
190
|
+
const entries = await fs.readdir(skillsDir, { withFileTypes: true })
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
if (!entry.isDirectory()) continue
|
|
193
|
+
if (!TEMP_DIR_PATTERN.test(entry.name)) continue
|
|
194
|
+
await fs.rm(join(skillsDir, entry.name), { recursive: true, force: true })
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function linkSkillsToClaude({ skillNames, agentsSkillsDir, claudeSkillsDir }) {
|
|
199
|
+
for (const skillName of skillNames) {
|
|
200
|
+
const srcSkillDir = join(agentsSkillsDir, skillName)
|
|
201
|
+
const claudeSkillPath = join(claudeSkillsDir, skillName)
|
|
202
|
+
|
|
203
|
+
let stat
|
|
204
|
+
try {
|
|
205
|
+
stat = await fs.lstat(claudeSkillPath)
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (error?.code !== 'ENOENT') throw error
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (stat?.isSymbolicLink()) {
|
|
211
|
+
const currentTarget = await fs.readlink(claudeSkillPath)
|
|
212
|
+
if (currentTarget === srcSkillDir) continue
|
|
213
|
+
await fs.rm(claudeSkillPath, { recursive: true, force: true })
|
|
214
|
+
} else if (stat) {
|
|
215
|
+
await fs.rm(claudeSkillPath, { recursive: true, force: true })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await fs.symlink(srcSkillDir, claudeSkillPath, 'dir')
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function runCodexInitial(options = {}) {
|
|
223
|
+
const packageRoot = options.packageRoot
|
|
224
|
+
if (!packageRoot) throw new Error('runCodexInitial: 缺少 packageRoot')
|
|
225
|
+
|
|
226
|
+
const homeDir = options.homeDir || os.homedir()
|
|
227
|
+
const srcSkillsDir = join(packageRoot, 'skills')
|
|
228
|
+
const agentsSkillsDir = join(homeDir, '.agents', 'skills')
|
|
229
|
+
const claudeSkillsDir = join(homeDir, '.claude', 'skills')
|
|
230
|
+
const codexSkillsDir = join(homeDir, '.codex', 'skills')
|
|
231
|
+
|
|
232
|
+
await assertDirExists(srcSkillsDir, '模板目录 skills')
|
|
233
|
+
const skillNames = await collectSkillNames(srcSkillsDir)
|
|
234
|
+
|
|
235
|
+
// 护栏:已删除名单不得与现存 skill 重叠,否则会把刚同步的 skill 又清掉(自相矛盾)。
|
|
236
|
+
const overlap = DELETED_SKILLS.filter((name) => skillNames.includes(name))
|
|
237
|
+
if (overlap.length > 0) {
|
|
238
|
+
throw new Error(`DELETED_SKILLS 与现存 skills/ 重叠,需修正名单: ${overlap.join(', ')}`)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
await ensureDir(codexSkillsDir)
|
|
242
|
+
await ensureDir(claudeSkillsDir)
|
|
243
|
+
await ensureDir(agentsSkillsDir)
|
|
244
|
+
|
|
245
|
+
await removeManagedNonSymlinkSkills(codexSkillsDir, skillNames)
|
|
246
|
+
await removeManagedNonSymlinkSkills(claudeSkillsDir, skillNames)
|
|
247
|
+
|
|
248
|
+
// 清理历史上已删除的 skill:在 agents/claude/codex 三处连根拔除(含 agents 真实副本与软链)。
|
|
249
|
+
// 先清 claude(指向 agents 的软链宿主),再清 agents/codex(真实副本),
|
|
250
|
+
// 避免中途失败时留下指向已删除 agents target 的悬空软链。
|
|
251
|
+
const purgeClaude = await purgeSkills(claudeSkillsDir, DELETED_SKILLS)
|
|
252
|
+
const purgeAgents = await purgeSkills(agentsSkillsDir, DELETED_SKILLS)
|
|
253
|
+
const purgeCodex = await purgeSkills(codexSkillsDir, DELETED_SKILLS)
|
|
254
|
+
|
|
255
|
+
await removeStaleTempDirs(agentsSkillsDir)
|
|
256
|
+
const copyStats = await copyDirMerge({ srcDir: srcSkillsDir, dstDir: agentsSkillsDir })
|
|
257
|
+
await removeStaleTempDirs(agentsSkillsDir)
|
|
258
|
+
await linkSkillsToClaude({ skillNames, agentsSkillsDir, claudeSkillsDir })
|
|
259
|
+
|
|
260
|
+
logger.success('已初始化 skills 模板')
|
|
261
|
+
logger.info(`agents skills: 覆盖复制 ${copyStats.fileCount} 个文件 -> ${agentsSkillsDir}`)
|
|
262
|
+
logger.info(`claude skills: 已创建 ${skillNames.length} 个软链接 -> ${claudeSkillsDir}`)
|
|
263
|
+
logger.info(`codex skills: 已清理 ${skillNames.length} 个包内托管 skill 的旧副本 -> ${codexSkillsDir}`)
|
|
264
|
+
logger.info(
|
|
265
|
+
`已删除 skill 清理: agents ${purgeAgents.purged} / claude ${purgeClaude.purged} / codex ${purgeCodex.purged}`,
|
|
266
|
+
)
|
|
267
|
+
const purgeFailed = purgeClaude.failed + purgeAgents.failed + purgeCodex.failed
|
|
268
|
+
if (purgeFailed > 0) {
|
|
269
|
+
logger.warn(`已删除 skill 清理: ${purgeFailed} 个目标清理失败(已跳过,详见上方告警)`)
|
|
270
|
+
}
|
|
271
|
+
}
|
package/lib/confirm.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline'
|
|
2
|
+
import { logger } from './logger.js'
|
|
3
|
+
|
|
4
|
+
export class ConfirmManager {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.rl = null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// 判断是否启用自动确认(CI 或显式环境变量)
|
|
10
|
+
isAutoYes(skipFlag = false) {
|
|
11
|
+
if (skipFlag) return true
|
|
12
|
+
const ci = String(process.env.CI || '').toLowerCase()
|
|
13
|
+
const autoYes = String(process.env.AI_CLI_YES || process.env.YES || '').toLowerCase()
|
|
14
|
+
// 常见 CI 环境会设置 CI=true;也支持自定义 AI_CLI_YES/YES 变量
|
|
15
|
+
return ci === 'true' || ci === '1' || autoYes === 'true' || autoYes === '1'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 基础确认
|
|
19
|
+
async confirm(message, defaultValue = false, skipFlag = false) {
|
|
20
|
+
if (this.isAutoYes(skipFlag)) {
|
|
21
|
+
logger.info(`跳过确认: ${message}`)
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const prompt = defaultValue ? `${message} [Y/n]: ` : `${message} [y/N]: `
|
|
26
|
+
|
|
27
|
+
return new Promise(resolve => {
|
|
28
|
+
this.rl = createInterface({
|
|
29
|
+
input: process.stdin,
|
|
30
|
+
output: process.stdout,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
this.rl.question(prompt, answer => {
|
|
34
|
+
this.rl.close()
|
|
35
|
+
|
|
36
|
+
const normalized = answer.toLowerCase().trim()
|
|
37
|
+
if (normalized === '') {
|
|
38
|
+
resolve(defaultValue)
|
|
39
|
+
} else {
|
|
40
|
+
resolve(['y', 'yes', '是', 'true', '1'].includes(normalized))
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 危险操作确认
|
|
47
|
+
async confirmDangerous(operation, environment = 'unknown', skipFlag = false) {
|
|
48
|
+
if (this.isAutoYes(skipFlag)) {
|
|
49
|
+
logger.warn(`跳过危险操作确认: ${operation}`)
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
logger.separator()
|
|
54
|
+
logger.warn('警告: 即将执行危险操作')
|
|
55
|
+
console.log(`操作: ${operation}`)
|
|
56
|
+
console.log(`环境: ${environment}`)
|
|
57
|
+
|
|
58
|
+
if (environment === 'production' || environment === '生产环境') {
|
|
59
|
+
console.log(`🔥 此操作将在生产环境执行,可能导致数据丢失或服务中断`)
|
|
60
|
+
console.log(`🔥 请确保您已经:`)
|
|
61
|
+
console.log(` 1. 备份了重要数据`)
|
|
62
|
+
console.log(` 2. 通知了相关团队成员`)
|
|
63
|
+
console.log(` 3. 确认了操作的必要性`)
|
|
64
|
+
} else {
|
|
65
|
+
console.log(`此操作可能导致数据丢失,请谨慎操作`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
logger.separator()
|
|
69
|
+
|
|
70
|
+
// 对于生产环境,需要两次确认
|
|
71
|
+
if (environment === 'production' || environment === '生产环境') {
|
|
72
|
+
const firstConfirm = await this.confirm('您确定要在生产环境执行此危险操作吗?', false, false)
|
|
73
|
+
if (!firstConfirm) {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log(`\n请再次确认,输入操作名称以继续: ${operation}`)
|
|
78
|
+
return new Promise(resolve => {
|
|
79
|
+
this.rl = createInterface({
|
|
80
|
+
input: process.stdin,
|
|
81
|
+
output: process.stdout,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
this.rl.question('请输入操作名称: ', answer => {
|
|
85
|
+
this.rl.close()
|
|
86
|
+
resolve(answer.trim() === operation)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
} else {
|
|
90
|
+
return this.confirm('确定要继续吗?', false, false)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 批量操作确认
|
|
95
|
+
async confirmBatch(operations, skipFlag = false) {
|
|
96
|
+
if (this.isAutoYes(skipFlag)) {
|
|
97
|
+
logger.info('跳过批量操作确认')
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
logger.separator()
|
|
102
|
+
console.log('即将执行以下操作:')
|
|
103
|
+
operations.forEach((op, index) => {
|
|
104
|
+
console.log(`${index + 1}. ${op}`)
|
|
105
|
+
})
|
|
106
|
+
logger.separator()
|
|
107
|
+
|
|
108
|
+
return this.confirm('确认执行所有操作?', false, false)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 端口冲突确认
|
|
112
|
+
async confirmPortCleanup(port, processes, skipFlag = false) {
|
|
113
|
+
if (this.isAutoYes(skipFlag)) {
|
|
114
|
+
logger.info(`跳过端口清理确认: 端口 ${port}`)
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
logger.warn(`端口 ${port} 被以下进程占用:`)
|
|
119
|
+
processes.forEach(pid => {
|
|
120
|
+
console.log(` 进程 ID: ${pid}`)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
return this.confirm(`是否杀死这些进程以释放端口 ${port}?`, true, false)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 环境切换确认
|
|
127
|
+
async confirmEnvironmentSwitch(from, to, skipFlag = false) {
|
|
128
|
+
if (this.isAutoYes(skipFlag)) {
|
|
129
|
+
logger.info(`跳过环境切换确认: ${from} -> ${to}`)
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (to === 'production' || to === '生产环境') {
|
|
134
|
+
logger.warn(`即将从 ${from} 切换到 ${to}`)
|
|
135
|
+
logger.warn('生产环境操作需要额外谨慎')
|
|
136
|
+
return this.confirm('确定要切换到生产环境吗?', false, false)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return this.confirm(`确定要从 ${from} 切换到 ${to} 吗?`, true, false)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 文件覆盖确认
|
|
143
|
+
async confirmOverwrite(filePath, skipFlag = false) {
|
|
144
|
+
if (this.isAutoYes(skipFlag)) {
|
|
145
|
+
logger.info(`跳过文件覆盖确认: ${filePath}`)
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
logger.warn(`文件已存在: ${filePath}`)
|
|
150
|
+
return this.confirm('是否覆盖现有文件?', false, false)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 清理操作确认
|
|
154
|
+
async confirmCleanup(targets, skipFlag = false) {
|
|
155
|
+
if (this.isAutoYes(skipFlag)) {
|
|
156
|
+
logger.info('跳过清理操作确认')
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
logger.warn('即将清理以下内容:')
|
|
161
|
+
targets.forEach(target => {
|
|
162
|
+
console.log(` - ${target}`)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
return this.confirm('确定要执行清理操作吗?', false, false)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 服务启动确认(用于可能有端口冲突的情况)
|
|
169
|
+
async confirmServiceStart(service, port, skipFlag = false) {
|
|
170
|
+
if (this.isAutoYes(skipFlag)) {
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return this.confirm(`确定要启动 ${service} 服务 (端口 ${port}) 吗?`, true, false)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 版本发布确认
|
|
178
|
+
async confirmRelease(version, isProduction = false, skipFlag = false) {
|
|
179
|
+
if (this.isAutoYes(skipFlag)) {
|
|
180
|
+
logger.info(`跳过版本发布确认: ${version}`)
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (isProduction) {
|
|
185
|
+
logger.warn(`即将发布生产版本: ${version}`)
|
|
186
|
+
logger.warn('此操作将创建正式发布标签')
|
|
187
|
+
return this.confirmDangerous(`发布版本 ${version}`, '生产环境', false)
|
|
188
|
+
} else {
|
|
189
|
+
return this.confirm(`确定要发布开发版本 ${version} 吗?`, true, false)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 数据库操作确认
|
|
194
|
+
async confirmDatabaseOperation(operation, environment, skipFlag = false) {
|
|
195
|
+
const dangerousOps = ['reset', 'drop', 'migrate:reset', 'db:reset']
|
|
196
|
+
const isDangerous = dangerousOps.some(op => operation.includes(op))
|
|
197
|
+
|
|
198
|
+
if (isDangerous) {
|
|
199
|
+
return this.confirmDangerous(
|
|
200
|
+
`数据库操作: ${operation}`,
|
|
201
|
+
environment,
|
|
202
|
+
this.isAutoYes(skipFlag),
|
|
203
|
+
)
|
|
204
|
+
} else {
|
|
205
|
+
if (this.isAutoYes(skipFlag)) {
|
|
206
|
+
return true
|
|
207
|
+
}
|
|
208
|
+
return this.confirm(`确定要执行数据库操作: ${operation} (${environment}) 吗?`, true, false)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const confirmManager = new ConfirmManager()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import stripJsonComments from 'strip-json-comments'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_POLICY_PATH = 'env-policy.jsonc'
|
|
6
|
+
|
|
7
|
+
let cachedPolicy = null
|
|
8
|
+
let cachedPolicyDir = null
|
|
9
|
+
|
|
10
|
+
function assertStringArray(value, fieldPath) {
|
|
11
|
+
if (!Array.isArray(value)) {
|
|
12
|
+
throw new Error(`${fieldPath} 必须是数组`)
|
|
13
|
+
}
|
|
14
|
+
const invalid = value.filter(v => typeof v !== 'string' || v.trim().length === 0)
|
|
15
|
+
if (invalid.length > 0) {
|
|
16
|
+
throw new Error(`${fieldPath} 中存在非法值,请使用非空字符串`)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertRecord(value, fieldPath) {
|
|
21
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
22
|
+
throw new Error(`${fieldPath} 必须是对象`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadEnvPolicy(configDir) {
|
|
27
|
+
if (cachedPolicy && cachedPolicyDir === configDir) return cachedPolicy
|
|
28
|
+
|
|
29
|
+
const policyPath = join(configDir, DEFAULT_POLICY_PATH)
|
|
30
|
+
if (!existsSync(policyPath)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`缺少配置文件 ${DEFAULT_POLICY_PATH}。
|
|
33
|
+
请在目标工程根目录创建 dx/config/${DEFAULT_POLICY_PATH}(或用 DX_CONFIG_DIR / --config-dir 指定配置目录)。
|
|
34
|
+
当前配置目录: ${configDir}`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let parsed
|
|
39
|
+
try {
|
|
40
|
+
const raw = readFileSync(policyPath, 'utf8')
|
|
41
|
+
parsed = JSON.parse(stripJsonComments(raw) || '{}')
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(`无法解析 ${DEFAULT_POLICY_PATH}: ${error.message}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
validateEnvPolicy(parsed)
|
|
47
|
+
cachedPolicy = parsed
|
|
48
|
+
cachedPolicyDir = configDir
|
|
49
|
+
return cachedPolicy
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function validateEnvPolicy(policy) {
|
|
53
|
+
assertRecord(policy, 'env-policy')
|
|
54
|
+
|
|
55
|
+
if (policy.version !== 1) {
|
|
56
|
+
throw new Error('env-policy.jsonc.version 必须为 1')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
assertStringArray(policy.environments, 'env-policy.jsonc.environments')
|
|
60
|
+
|
|
61
|
+
if (typeof policy.secretPlaceholder !== 'string' || policy.secretPlaceholder.trim().length === 0) {
|
|
62
|
+
throw new Error('env-policy.jsonc.secretPlaceholder 必须为非空字符串')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
assertRecord(policy.keys, 'env-policy.jsonc.keys')
|
|
66
|
+
assertStringArray(policy.keys.secret || [], 'env-policy.jsonc.keys.secret')
|
|
67
|
+
assertStringArray(policy.keys.localOnly || [], 'env-policy.jsonc.keys.localOnly')
|
|
68
|
+
assertStringArray(policy.keys.localOverride || [], 'env-policy.jsonc.keys.localOverride')
|
|
69
|
+
|
|
70
|
+
assertRecord(policy.layout, 'env-policy.jsonc.layout')
|
|
71
|
+
assertStringArray(policy.layout.forbidExact || [], 'env-policy.jsonc.layout.forbidExact')
|
|
72
|
+
assertStringArray(policy.layout.allowRoot || [], 'env-policy.jsonc.layout.allowRoot')
|
|
73
|
+
assertStringArray(policy.layout.allowSubdirGlobs || [], 'env-policy.jsonc.layout.allowSubdirGlobs')
|
|
74
|
+
|
|
75
|
+
assertRecord(policy.appToTarget || {}, 'env-policy.jsonc.appToTarget')
|
|
76
|
+
for (const [app, target] of Object.entries(policy.appToTarget || {})) {
|
|
77
|
+
if (typeof app !== 'string' || app.trim().length === 0) {
|
|
78
|
+
throw new Error('env-policy.jsonc.appToTarget 中存在非法 app key')
|
|
79
|
+
}
|
|
80
|
+
if (typeof target !== 'string' || target.trim().length === 0) {
|
|
81
|
+
throw new Error('env-policy.jsonc.appToTarget 中存在非法 target value')
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
assertRecord(policy.targets, 'env-policy.jsonc.targets')
|
|
86
|
+
const targetIds = Object.keys(policy.targets)
|
|
87
|
+
if (targetIds.length === 0) {
|
|
88
|
+
throw new Error('env-policy.jsonc.targets 不能为空')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [targetId, target] of Object.entries(policy.targets)) {
|
|
92
|
+
if (typeof targetId !== 'string' || targetId.trim().length === 0) {
|
|
93
|
+
throw new Error('env-policy.jsonc.targets 中存在非法 targetId')
|
|
94
|
+
}
|
|
95
|
+
assertRecord(target, `env-policy.jsonc.targets.${targetId}`)
|
|
96
|
+
assertRecord(target.files, `env-policy.jsonc.targets.${targetId}.files`)
|
|
97
|
+
if (typeof target.files.committed !== 'string' || target.files.committed.trim().length === 0) {
|
|
98
|
+
throw new Error(`env-policy.jsonc.targets.${targetId}.files.committed 必须为非空字符串`)
|
|
99
|
+
}
|
|
100
|
+
if (typeof target.files.local !== 'string' || target.files.local.trim().length === 0) {
|
|
101
|
+
throw new Error(`env-policy.jsonc.targets.${targetId}.files.local 必须为非空字符串`)
|
|
102
|
+
}
|
|
103
|
+
assertRecord(target.required || {}, `env-policy.jsonc.targets.${targetId}.required`)
|
|
104
|
+
|
|
105
|
+
for (const [group, keys] of Object.entries(target.required || {})) {
|
|
106
|
+
assertStringArray(keys, `env-policy.jsonc.targets.${targetId}.required.${group}`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Ensure appToTarget does not point to missing targets.
|
|
111
|
+
for (const [app, target] of Object.entries(policy.appToTarget || {})) {
|
|
112
|
+
if (!policy.targets[target]) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`env-policy.jsonc.appToTarget.${app} 指向不存在的 target: ${target}(请在 env-policy.jsonc.targets 中定义)`,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolvePolicyTargetId(policy, app) {
|
|
121
|
+
if (!policy || !app) return null
|
|
122
|
+
const mapped = policy.appToTarget?.[app]
|
|
123
|
+
return mapped || null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function resolveTargetRequiredVars(policy, targetId, environment) {
|
|
127
|
+
if (!policy || !targetId) return []
|
|
128
|
+
const target = policy.targets?.[targetId]
|
|
129
|
+
if (!target) return []
|
|
130
|
+
const required = target.required || {}
|
|
131
|
+
const common = Array.isArray(required._common) ? required._common : []
|
|
132
|
+
const envSpecific = Array.isArray(required[environment]) ? required[environment] : []
|
|
133
|
+
return Array.from(new Set([...common, ...envSpecific]))
|
|
134
|
+
}
|