@bruc3van/dsh-doctor 0.1.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +80 -84
- package/README.md +130 -82
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/behavior.md +12 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/config-rules.json +36 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/manifest.json +19 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/packages.json +62 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/services.json +28 -0
- package/migrations/dsh-v0.1.1-rc.2__dsh-v0.1.2-alpha.2/symbols.json +126 -0
- package/package.json +6 -3
- package/skills/dsh-plugin-upgrade/SKILL.md +98 -0
- package/skills/dsh-plugin-upgrade/evals/evals.json +36 -0
- package/skills/dsh-plugin-upgrade/references/migration-map.md +32 -0
- package/skills/dsh-plugin-upgrade/references/verification.md +27 -0
- package/src/baseline.mjs +69 -0
- package/src/cli.mjs +262 -88
- package/src/config-model.mjs +167 -0
- package/src/doctor.mjs +201 -23
- package/src/i18n.mjs +18 -0
- package/src/migrate-verify.mjs +195 -0
- package/src/migrate.mjs +418 -0
- package/src/migration-catalog.mjs +60 -0
- package/src/recovery.mjs +358 -0
- package/src/redact.mjs +46 -0
- package/src/registry.mjs +61 -0
- package/src/safe-write.mjs +50 -0
package/src/cli.mjs
CHANGED
|
@@ -1,47 +1,95 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { createInterface } from 'node:readline/promises'
|
|
3
6
|
import { formatReport, diagnose, resolveDshHome } from './doctor.mjs'
|
|
4
7
|
import { applyRepairs, formatRepairOutcome, formatRepairPlan, repairsFromReport } from './repair.mjs'
|
|
5
8
|
import { resolveLanguage } from './i18n.mjs'
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
9
|
+
import { checkCompatibleVersion } from './registry.mjs'
|
|
10
|
+
import { applyPersistentQuarantine, attachUpdateResult, persistentQuarantinePlan, prepareRemovalArtifacts, quarantineDocument, removalPlan, restoreBackup, verifyQuarantine, verifyUpdate, writeQuarantineOverlay } from './recovery.mjs'
|
|
11
|
+
import { compareBaseline, createBaseline } from './baseline.mjs'
|
|
12
|
+
import { redactSecrets } from './redact.mjs'
|
|
10
13
|
|
|
11
|
-
const HELP_EN = `Usage:
|
|
14
|
+
const HELP_EN = `Usage:
|
|
15
|
+
dsh-doctor diagnose [options]
|
|
16
|
+
dsh-doctor recover <package> [--action <action>] [options]
|
|
17
|
+
dsh-doctor baseline <create|compare> [options]
|
|
18
|
+
dsh-doctor migrations list
|
|
19
|
+
dsh-doctor migrate <analyze|apply|verify> [plugin-root] [options]
|
|
12
20
|
|
|
13
|
-
|
|
21
|
+
Commands:
|
|
22
|
+
diagnose inspect the selected profile (default command)
|
|
23
|
+
recover <package> build and optionally execute one explicit recovery decision
|
|
24
|
+
baseline create|compare save or compare a pre-upgrade diagnostic snapshot
|
|
25
|
+
migrations list list built-in versioned migration catalogs
|
|
26
|
+
migrate analyze find source, manifest, artifact, and semantic migration work
|
|
27
|
+
migrate apply preview or apply only exact catalog codemods with backups
|
|
28
|
+
migrate verify run static, build, or isolated runtime verification
|
|
14
29
|
|
|
15
30
|
Options:
|
|
16
31
|
--profile <name> profile to inspect (default: web)
|
|
17
32
|
--home <path> Harness home (default: $DSH_HOME or ~/.dsh)
|
|
18
33
|
--harness-root <path> active DeepSeek Harness source checkout
|
|
19
|
-
--
|
|
20
|
-
--
|
|
34
|
+
--patch <path> include one CLI overlay in the effective tree (repeatable)
|
|
35
|
+
--dsh-command <path> DSH executable or lib/bin.js used for commands
|
|
36
|
+
--check-updates query registry for manifest-declared compatible versions
|
|
37
|
+
--action <action> check-update, update, quarantine, persist-quarantine, rollback-quarantine, or remove
|
|
38
|
+
--output <path> quarantine overlay or baseline path
|
|
39
|
+
--backup <path> backup used by rollback-quarantine
|
|
40
|
+
--verified assert that a temporary quarantine overlay was tested
|
|
41
|
+
--lang <auto|zh|en> output language
|
|
21
42
|
--json print machine-readable JSON
|
|
22
|
-
--fix, --repair
|
|
23
|
-
--yes confirm
|
|
43
|
+
--fix, --repair legacy confirmed repairs (never removes a plugin)
|
|
44
|
+
--yes confirm an explicit write or command
|
|
45
|
+
--from <ref> source DSH ref (default: dsh-v0.1.1-rc.2)
|
|
46
|
+
--to <ref> target DSH ref (default: dsh-v0.1.2-alpha.2)
|
|
47
|
+
--safe restrict migrate apply to catalog-confirmed exact rewrites
|
|
48
|
+
--level <level> static, build, or runtime verification
|
|
49
|
+
--keep-temp retain a successful runtime verification directory
|
|
24
50
|
-h, --help show help
|
|
25
51
|
-v, --version show version
|
|
26
52
|
`
|
|
27
53
|
|
|
28
|
-
const HELP_ZH = `用法:
|
|
54
|
+
const HELP_ZH = `用法:
|
|
55
|
+
dsh-doctor diagnose [选项]
|
|
56
|
+
dsh-doctor recover <包名> [--action <动作>] [选项]
|
|
57
|
+
dsh-doctor baseline <create|compare> [选项]
|
|
58
|
+
dsh-doctor migrations list
|
|
59
|
+
dsh-doctor migrate <analyze|apply|verify> [插件目录] [选项]
|
|
29
60
|
|
|
30
|
-
|
|
61
|
+
命令:
|
|
62
|
+
diagnose 检查选定的 profile(默认命令)
|
|
63
|
+
recover <包名> 生成并按确认执行一个明确的恢复动作
|
|
64
|
+
baseline create|compare 保存或比较升级前诊断快照
|
|
65
|
+
migrations list 列出内置的版本化迁移目录
|
|
66
|
+
migrate analyze 识别源码、manifest、产物和语义迁移任务
|
|
67
|
+
migrate apply 预览或应用 catalog 确认的精确改写并创建备份
|
|
68
|
+
migrate verify 执行静态、构建或隔离运行时验证
|
|
31
69
|
|
|
32
70
|
选项:
|
|
33
71
|
--profile <名称> 要检查的 profile(默认:web)
|
|
34
|
-
--home <路径> Harness
|
|
72
|
+
--home <路径> Harness home(默认:$DSH_HOME 或 ~/.dsh)
|
|
35
73
|
--harness-root <路径> 当前 DeepSeek Harness 源码工作区
|
|
36
|
-
--
|
|
37
|
-
--
|
|
38
|
-
--
|
|
39
|
-
--
|
|
40
|
-
--
|
|
74
|
+
--patch <路径> 在生效配置树中加入一个 CLI overlay(可重复)
|
|
75
|
+
--dsh-command <路径> 执行动作使用的 DSH 可执行文件或 lib/bin.js
|
|
76
|
+
--check-updates 查询 registry 中由 manifest 声明兼容的版本
|
|
77
|
+
--action <动作> check-update、update、quarantine、persist-quarantine、rollback-quarantine 或 remove
|
|
78
|
+
--output <路径> quarantine overlay 或 baseline 路径
|
|
79
|
+
--backup <路径> rollback-quarantine 使用的备份
|
|
80
|
+
--verified 确认临时 quarantine overlay 已经过测试
|
|
81
|
+
--lang <auto|zh|en> 输出语言
|
|
82
|
+
--json 输出机器可读 JSON
|
|
83
|
+
--fix, --repair 旧式确认修复(绝不移除插件)
|
|
84
|
+
--yes 确认一个明确的写入或命令动作
|
|
85
|
+
--from <ref> 源 DSH ref(默认:dsh-v0.1.1-rc.2)
|
|
86
|
+
--to <ref> 目标 DSH ref(默认:dsh-v0.1.2-alpha.2)
|
|
87
|
+
--safe migrate apply 仅执行 catalog 确认的精确改写
|
|
88
|
+
--level <级别> static、build 或 runtime
|
|
89
|
+
--keep-temp 成功后仍保留运行时验证目录
|
|
41
90
|
-h, --help 显示帮助
|
|
42
91
|
-v, --version 显示版本
|
|
43
92
|
`
|
|
44
|
-
|
|
45
93
|
const help = language => language === 'zh' ? HELP_ZH : HELP_EN
|
|
46
94
|
|
|
47
95
|
function valueAfter(args, index, name) {
|
|
@@ -51,16 +99,21 @@ function valueAfter(args, index, name) {
|
|
|
51
99
|
}
|
|
52
100
|
|
|
53
101
|
function optionValue(arg, name) {
|
|
54
|
-
const
|
|
55
|
-
if (!arg.startsWith(prefix)) return undefined
|
|
56
|
-
const value = arg.slice(prefix.length)
|
|
102
|
+
const value = arg.slice(`${name}=`.length)
|
|
57
103
|
if (value === '') throw new Error(`${name} needs a value`)
|
|
58
104
|
return value
|
|
59
105
|
}
|
|
60
106
|
|
|
61
107
|
function parse(args) {
|
|
62
|
-
const options = {}
|
|
63
|
-
|
|
108
|
+
const options = { command: 'diagnose' }
|
|
109
|
+
let index = 0
|
|
110
|
+
if (['diagnose', 'recover', 'baseline', 'migrations', 'migrate'].includes(args[0])) options.command = args[index++]
|
|
111
|
+
if (options.command === 'recover' && args[index] !== undefined && !args[index].startsWith('-')) options.package = args[index++]
|
|
112
|
+
if (options.command === 'baseline' && args[index] !== undefined && !args[index].startsWith('-')) options.baselineAction = args[index++]
|
|
113
|
+
if (options.command === 'migrations' && args[index] !== undefined && !args[index].startsWith('-')) options.migrationsAction = args[index++]
|
|
114
|
+
if (options.command === 'migrate' && args[index] !== undefined && !args[index].startsWith('-')) options.migrateAction = args[index++]
|
|
115
|
+
if (options.command === 'migrate' && args[index] !== undefined && !args[index].startsWith('-')) options.pluginRoot = args[index++]
|
|
116
|
+
for (; index < args.length; index += 1) {
|
|
64
117
|
const arg = args[index]
|
|
65
118
|
if (arg === '--profile') options.profile = valueAfter(args, index++, arg)
|
|
66
119
|
else if (arg.startsWith('--profile=')) options.profile = optionValue(arg, '--profile')
|
|
@@ -68,13 +121,31 @@ function parse(args) {
|
|
|
68
121
|
else if (arg.startsWith('--home=')) options.home = optionValue(arg, '--home')
|
|
69
122
|
else if (arg === '--harness-root') options.harnessRoot = valueAfter(args, index++, arg)
|
|
70
123
|
else if (arg.startsWith('--harness-root=')) options.harnessRoot = optionValue(arg, '--harness-root')
|
|
124
|
+
else if (arg === '--patch') (options.patchFiles ??= []).push(valueAfter(args, index++, arg))
|
|
125
|
+
else if (arg.startsWith('--patch=')) (options.patchFiles ??= []).push(optionValue(arg, '--patch'))
|
|
71
126
|
else if (arg === '--dsh-command') options.dshCommand = valueAfter(args, index++, arg)
|
|
72
127
|
else if (arg.startsWith('--dsh-command=')) options.dshCommand = optionValue(arg, '--dsh-command')
|
|
73
128
|
else if (arg === '--lang') options.lang = valueAfter(args, index++, arg)
|
|
74
129
|
else if (arg.startsWith('--lang=')) options.lang = optionValue(arg, '--lang')
|
|
130
|
+
else if (arg === '--action') options.action = valueAfter(args, index++, arg)
|
|
131
|
+
else if (arg.startsWith('--action=')) options.action = optionValue(arg, '--action')
|
|
132
|
+
else if (arg === '--output') options.output = valueAfter(args, index++, arg)
|
|
133
|
+
else if (arg.startsWith('--output=')) options.output = optionValue(arg, '--output')
|
|
134
|
+
else if (arg === '--backup') options.backup = valueAfter(args, index++, arg)
|
|
135
|
+
else if (arg.startsWith('--backup=')) options.backup = optionValue(arg, '--backup')
|
|
136
|
+
else if (arg === '--check-updates') options.checkUpdates = true
|
|
137
|
+
else if (arg === '--verified') options.verified = true
|
|
75
138
|
else if (arg === '--json') options.json = true
|
|
76
139
|
else if (arg === '--fix' || arg === '--repair') options.fix = true
|
|
77
140
|
else if (arg === '--yes') options.yes = true
|
|
141
|
+
else if (arg === '--from') options.from = valueAfter(args, index++, arg)
|
|
142
|
+
else if (arg.startsWith('--from=')) options.from = optionValue(arg, '--from')
|
|
143
|
+
else if (arg === '--to') options.to = valueAfter(args, index++, arg)
|
|
144
|
+
else if (arg.startsWith('--to=')) options.to = optionValue(arg, '--to')
|
|
145
|
+
else if (arg === '--safe') options.safe = true
|
|
146
|
+
else if (arg === '--level') options.level = valueAfter(args, index++, arg)
|
|
147
|
+
else if (arg.startsWith('--level=')) options.level = optionValue(arg, '--level')
|
|
148
|
+
else if (arg === '--keep-temp') options.keepTemp = true
|
|
78
149
|
else if (arg === '--help' || arg === '-h') options.help = true
|
|
79
150
|
else if (arg === '--version' || arg === '-v') options.version = true
|
|
80
151
|
else throw new Error(`unknown option ${arg}`)
|
|
@@ -82,84 +153,187 @@ function parse(args) {
|
|
|
82
153
|
return options
|
|
83
154
|
}
|
|
84
155
|
|
|
156
|
+
function diagnosisOptions(options) {
|
|
157
|
+
return { profile: options.profile, home: options.home, harnessRoot: options.harnessRoot, dshCommand: options.dshCommand, patchFiles: options.patchFiles }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function checkUpdates(report, selected) {
|
|
161
|
+
const names = selected === undefined ? report.context.pluginDiagnoses.map(item => item.name) : [selected]
|
|
162
|
+
for (const name of names) {
|
|
163
|
+
const diagnosis = report.context.pluginDiagnoses.find(item => item.name === name)
|
|
164
|
+
if (diagnosis === undefined) throw new Error(`package is not a direct profile plugin: ${name}`)
|
|
165
|
+
const result = await checkCompatibleVersion(name, report.context.harness.packages ?? {})
|
|
166
|
+
attachUpdateResult(diagnosis, result, { ...report.context.dshCli, profile: report.context.profile })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function print(value, options) {
|
|
171
|
+
const safeValue = typeof value === 'string' ? value : redactSecrets(value)
|
|
172
|
+
process.stdout.write(options.json ? `${JSON.stringify(safeValue, null, 2)}\n` : `${typeof safeValue === 'string' ? safeValue : JSON.stringify(safeValue, null, 2)}\n`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function legacyFix(options, language) {
|
|
176
|
+
let report = diagnose(diagnosisOptions(options))
|
|
177
|
+
const actions = repairsFromReport(report)
|
|
178
|
+
let repairs = []
|
|
179
|
+
let declined = false
|
|
180
|
+
if (actions.length === 0 && report.context.dshCli?.commandRepairNeeded && !report.context.dshCli.available) {
|
|
181
|
+
throw new Error(language === 'zh' ? '未找到可用的 DSH CLI,无法执行命令型修复;请使用 --dsh-command 指定当前安装的 dsh 或 lib/bin.js' : 'no working DSH CLI was found; pass the active dsh executable or lib/bin.js with --dsh-command')
|
|
182
|
+
}
|
|
183
|
+
if (actions.length > 0) {
|
|
184
|
+
let confirmed = options.yes === true
|
|
185
|
+
const plan = formatRepairPlan(actions, { language, prompt: !confirmed })
|
|
186
|
+
if (confirmed) process.stderr.write(`${plan}\n`)
|
|
187
|
+
else {
|
|
188
|
+
if (!process.stdin.isTTY) throw new Error('--fix needs an interactive terminal or explicit --yes')
|
|
189
|
+
const reader = createInterface({ input: process.stdin, output: process.stderr })
|
|
190
|
+
const answer = await reader.question(plan)
|
|
191
|
+
reader.close()
|
|
192
|
+
confirmed = /^(?:y(?:es)?|是|确认)$/i.test(answer.trim())
|
|
193
|
+
}
|
|
194
|
+
if (confirmed) {
|
|
195
|
+
repairs = applyRepairs(actions, { captureOutput: options.json })
|
|
196
|
+
if (repairs.every(item => item.status === 'applied')) report = diagnose(diagnosisOptions(options))
|
|
197
|
+
} else declined = true
|
|
198
|
+
}
|
|
199
|
+
const output = { ...report, repairs }
|
|
200
|
+
if (options.json) print(output, options)
|
|
201
|
+
else process.stdout.write(`${formatReport(report, { color: process.stdout.isTTY, language })}${repairs.length > 0 ? `${language === 'zh' ? '修复结果' : 'Repairs'}: ${JSON.stringify(repairs, null, 2)}\n` : formatRepairOutcome(actions, repairs, { declined, findingCount: report.findings.length, language })}`)
|
|
202
|
+
process.exitCode = repairs.some(item => item.status === 'failed') ? 2 : report.summary.errors > 0 ? 1 : 0
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function runRecover(options) {
|
|
206
|
+
if (options.package === undefined) throw new Error('recover needs a package name')
|
|
207
|
+
const allowed = ['check-update', 'update', 'quarantine', 'persist-quarantine', 'rollback-quarantine', 'remove']
|
|
208
|
+
if (options.action !== undefined && !allowed.includes(options.action)) throw new Error(`unsupported recovery action ${options.action}`)
|
|
209
|
+
let report = diagnose(diagnosisOptions(options))
|
|
210
|
+
const diagnosis = report.context.pluginDiagnoses?.find(item => item.name === options.package)
|
|
211
|
+
if (diagnosis === undefined) throw new Error(`package is not a direct profile plugin: ${options.package}`)
|
|
212
|
+
await checkUpdates(report, options.package)
|
|
213
|
+
const action = options.action
|
|
214
|
+
if (action === undefined || action === 'check-update') return print({ diagnosis }, options)
|
|
215
|
+
if (action === 'quarantine') {
|
|
216
|
+
const patches = quarantineDocument(diagnosis)
|
|
217
|
+
if (options.output === undefined) return print({ action, mode: 'preview', patches, command: ['dsh', '--profile', report.context.profile, '--patch', '<output.yml>'] }, options)
|
|
218
|
+
const result = writeQuarantineOverlay(diagnosis, options.output)
|
|
219
|
+
const verificationReport = diagnose({ ...diagnosisOptions(options), patchFiles: [...(options.patchFiles ?? []), result.file] })
|
|
220
|
+
const verification = verifyQuarantine(verificationReport, patches)
|
|
221
|
+
process.exitCode = verification.allTargetEntriesDisabled ? 0 : 1
|
|
222
|
+
return print({ diagnosis, result, verification: { ...verification, report: verificationReport }, command: ['dsh', '--profile', report.context.profile, '--patch', result.file] }, options)
|
|
223
|
+
}
|
|
224
|
+
if (action === 'persist-quarantine') {
|
|
225
|
+
if (!options.verified) throw new Error('persist-quarantine requires --verified after testing a temporary overlay')
|
|
226
|
+
const plan = persistentQuarantinePlan(diagnosis, report.context.profileDir)
|
|
227
|
+
if (!options.yes) return print({ action, mode: 'preview', file: plan.file, diff: plan.diff }, options)
|
|
228
|
+
const result = applyPersistentQuarantine(plan)
|
|
229
|
+
report = diagnose(diagnosisOptions(options))
|
|
230
|
+
const verification = verifyQuarantine(report, plan.patches)
|
|
231
|
+
process.exitCode = verification.allTargetEntriesDisabled && report.summary.errors === 0 ? 0 : 1
|
|
232
|
+
return print({ action, result, verification: { ...verification, report } }, options)
|
|
233
|
+
}
|
|
234
|
+
if (action === 'rollback-quarantine') {
|
|
235
|
+
if (options.backup === undefined) throw new Error('rollback-quarantine requires --backup')
|
|
236
|
+
const target = join(report.context.profileDir, 'cordis.patch.yml')
|
|
237
|
+
if (!options.yes) return print({ action, mode: 'preview', backup: options.backup, target }, options)
|
|
238
|
+
const result = restoreBackup(options.backup, target)
|
|
239
|
+
report = diagnose(diagnosisOptions(options))
|
|
240
|
+
process.exitCode = report.summary.errors > 0 ? 1 : 0
|
|
241
|
+
return print({ action, result, verification: report }, options)
|
|
242
|
+
}
|
|
243
|
+
if (action === 'remove') {
|
|
244
|
+
const plan = removalPlan(diagnosis, report.context.dshCli, report.context.home, report.context.profile)
|
|
245
|
+
if (!options.yes) return print({ action, mode: 'preview', plan }, options)
|
|
246
|
+
const artifacts = prepareRemovalArtifacts(diagnosis, report)
|
|
247
|
+
const [result] = applyRepairs([plan], { captureOutput: options.json })
|
|
248
|
+
report = diagnose(diagnosisOptions(options))
|
|
249
|
+
const absent = !report.context.packages.some(item => item.name === options.package)
|
|
250
|
+
const entryAbsent = !report.context.configuration?.entries?.some(entry => entry.name === options.package || entry.origin?.package === options.package)
|
|
251
|
+
const bundleAbsent = !report.context.configuration?.layers?.some(layer => layer.package === options.package)
|
|
252
|
+
const targetIds = new Set(plan.impact.hostEntries)
|
|
253
|
+
const referencesAbsent = !report.context.configuration?.patchReferences?.some(reference => targetIds.has(reference.id))
|
|
254
|
+
process.exitCode = result.status === 'failed' ? 2 : absent && entryAbsent && bundleAbsent && referencesAbsent ? (report.summary.errors > 0 ? 1 : 0) : 1
|
|
255
|
+
return print({ action, result, artifacts, verification: { packageAbsent: absent, bundleAbsent, entryAbsent, patchReferencesAbsent: referencesAbsent, report }, rollback: plan.impact.rollback }, options)
|
|
256
|
+
}
|
|
257
|
+
const update = diagnosis.recovery.options.find(item => item.kind === 'update')
|
|
258
|
+
if (update?.availability !== 'available' || update.command.length === 0) throw new Error('no manifest-declared compatible update is available through the active DSH CLI')
|
|
259
|
+
const plan = { id: `update-package:${diagnosis.name}`, kind: 'command', risk: 'medium', description: update.reason, command: update.command, env: { DSH_HOME: report.context.home } }
|
|
260
|
+
if (!options.yes) return print({ action, mode: 'preview', plan }, options)
|
|
261
|
+
const [result] = applyRepairs([plan], { captureOutput: options.json })
|
|
262
|
+
report = diagnose(diagnosisOptions(options))
|
|
263
|
+
const verification = verifyUpdate(report, diagnosis.name, update.impact.candidateVersion)
|
|
264
|
+
process.exitCode = result.status === 'failed' ? 2 : verification.verified ? 0 : 1
|
|
265
|
+
return print({ action, result, verification: { ...verification, report } }, options)
|
|
266
|
+
}
|
|
267
|
+
|
|
85
268
|
async function main() {
|
|
86
269
|
const options = parse(process.argv.slice(2))
|
|
87
270
|
if (options.version) {
|
|
88
|
-
|
|
89
|
-
const manifest = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8'))
|
|
90
|
-
process.stdout.write(`${manifest.version}\n`)
|
|
271
|
+
process.stdout.write(`${JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version}\n`)
|
|
91
272
|
return
|
|
92
273
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
274
|
+
const requestedLanguage = options.help
|
|
275
|
+
? (['auto', 'zh', 'en'].includes(options.lang) ? options.lang : undefined)
|
|
276
|
+
: options.lang
|
|
277
|
+
const language = resolveLanguage({ requested: requestedLanguage, home: resolveDshHome(options.home), systemLocale: Intl.DateTimeFormat().resolvedOptions().locale })
|
|
278
|
+
if (options.help) return process.stdout.write(help(language))
|
|
279
|
+
if (options.fix && options.command !== 'diagnose') throw new Error('--fix/--repair can only be used with diagnose')
|
|
280
|
+
if (options.yes && !options.fix && options.command === 'diagnose') throw new Error('--yes requires --fix or an explicit recover/migrate action')
|
|
281
|
+
if (options.json && options.fix && !options.yes) throw new Error('--json --fix requires --yes because a prompt would corrupt JSON output')
|
|
282
|
+
if (options.fix) return legacyFix(options, language)
|
|
283
|
+
if (options.command === 'recover') return runRecover(options)
|
|
284
|
+
if (options.command === 'migrations') {
|
|
285
|
+
if (options.migrationsAction !== 'list') throw new Error('migrations needs list')
|
|
286
|
+
const { listMigrations } = await import('./migration-catalog.mjs')
|
|
287
|
+
const migrations = listMigrations()
|
|
288
|
+
if (options.json) print({ schemaVersion: 1, migrations }, options)
|
|
289
|
+
else process.stdout.write(`${migrations.map(item => `${item.from.ref} -> ${item.to.ref} (${item.id})`).join('\n')}\n`)
|
|
101
290
|
return
|
|
102
291
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
let repairDeclined = false
|
|
115
|
-
if (options.fix) {
|
|
116
|
-
actions = repairsFromReport(report)
|
|
117
|
-
if (actions.length === 0 && report.context.dshCli?.commandRepairNeeded && !report.context.dshCli.available) {
|
|
118
|
-
throw new Error(language === 'zh'
|
|
119
|
-
? '未找到可用的 DSH CLI,无法执行命令型修复;请使用 --dsh-command 指定当前安装的 dsh 或 lib/bin.js'
|
|
120
|
-
: 'no working DSH CLI was found; pass the active dsh executable or lib/bin.js with --dsh-command')
|
|
121
|
-
}
|
|
122
|
-
if (actions.length > 0) {
|
|
123
|
-
let confirmed = options.yes === true
|
|
124
|
-
const plan = formatRepairPlan(actions, { language, prompt: !confirmed })
|
|
125
|
-
if (confirmed) {
|
|
126
|
-
process.stderr.write(`${plan}\n`)
|
|
127
|
-
} else {
|
|
128
|
-
if (!process.stdin.isTTY) throw new Error('--fix needs an interactive terminal or explicit --yes')
|
|
129
|
-
const reader = createInterface({ input: process.stdin, output: process.stderr })
|
|
130
|
-
const answer = await reader.question(plan)
|
|
131
|
-
reader.close()
|
|
132
|
-
confirmed = /^(?:y(?:es)?|是|确认)$/i.test(answer.trim())
|
|
133
|
-
}
|
|
134
|
-
if (confirmed) {
|
|
135
|
-
repairs = applyRepairs(actions, { captureOutput: options.json })
|
|
136
|
-
if (repairs.every(item => item.status === 'applied')) report = diagnose(options)
|
|
137
|
-
} else repairDeclined = true
|
|
138
|
-
}
|
|
292
|
+
if (options.command === 'migrate') {
|
|
293
|
+
if (!['analyze', 'apply', 'verify'].includes(options.migrateAction)) throw new Error('migrate needs analyze, apply, or verify')
|
|
294
|
+
if (options.migrateAction === 'apply' && options.safe !== true) throw new Error('migrate apply requires --safe')
|
|
295
|
+
if (options.migrateAction === 'apply' && options.pluginRoot === undefined) throw new Error('migrate apply requires an explicit plugin root')
|
|
296
|
+
const migrationOptions = { from: options.from, to: options.to, harnessRoot: options.harnessRoot, dshCommand: options.dshCommand, level: options.level, yes: options.yes, safe: options.safe, keepTemp: options.keepTemp }
|
|
297
|
+
if (options.migrateAction === 'verify') {
|
|
298
|
+
const { formatVerification, verifyMigration } = await import('./migrate-verify.mjs')
|
|
299
|
+
const result = verifyMigration(options.pluginRoot, migrationOptions)
|
|
300
|
+
print(options.json ? result : formatVerification(result, language), options)
|
|
301
|
+
process.exitCode = result.passed ? 0 : 1
|
|
302
|
+
return
|
|
139
303
|
}
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
304
|
+
const { analyzeMigration, applyMigration, formatMigrationReport, publicMigrationReport } = await import('./migrate.mjs')
|
|
305
|
+
const report = analyzeMigration(options.pluginRoot, migrationOptions)
|
|
306
|
+
if (options.migrateAction === 'apply') {
|
|
307
|
+
const result = applyMigration(report, migrationOptions)
|
|
308
|
+
if (options.json) print(result, options)
|
|
309
|
+
else if (result.mode === 'preview') process.stdout.write(`${formatMigrationReport(result, language)}${language === 'zh' ? '这是预览;加入 --yes 后才会写入。' : 'This is a preview; add --yes to write.'}\n`)
|
|
310
|
+
else process.stdout.write(`${language === 'zh' ? '已应用安全改写并创建备份。' : 'Safe edits applied with backups.'}\n${formatMigrationReport(result.report, language)}`)
|
|
311
|
+
const remaining = result.mode === 'applied' ? result.report : result
|
|
312
|
+
process.exitCode = remaining.summary.errors > 0 ? 1 : 0
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
print(options.json ? publicMigrationReport(report) : formatMigrationReport(report, language), options)
|
|
316
|
+
process.exitCode = report.summary.errors > 0 ? 1 : 0
|
|
317
|
+
return
|
|
152
318
|
}
|
|
319
|
+
const report = diagnose(diagnosisOptions(options))
|
|
320
|
+
if (options.command === 'baseline') {
|
|
321
|
+
if (!['create', 'compare'].includes(options.baselineAction)) throw new Error('baseline needs create or compare')
|
|
322
|
+
const result = options.baselineAction === 'create' ? createBaseline(report, options.output) : compareBaseline(report, options.output)
|
|
323
|
+
print(result, options)
|
|
324
|
+
process.exitCode = report.summary.errors > 0 ? 1 : 0
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
if (options.checkUpdates) await checkUpdates(report)
|
|
328
|
+
print(options.json ? report : formatReport(report, { color: process.stdout.isTTY, language }), options)
|
|
329
|
+
process.exitCode = report.summary.errors > 0 ? 1 : 0
|
|
153
330
|
}
|
|
154
331
|
|
|
155
332
|
try {
|
|
156
333
|
await main()
|
|
157
334
|
} catch (error) {
|
|
158
335
|
const message = error instanceof Error ? error.message : String(error)
|
|
159
|
-
if (process.argv.includes('--json')) {
|
|
160
|
-
|
|
161
|
-
} else {
|
|
162
|
-
process.stderr.write(`dsh-doctor: ${message}\n\n${help('en')}`)
|
|
163
|
-
}
|
|
336
|
+
if (process.argv.includes('--json')) process.stdout.write(`${JSON.stringify({ version: 2, operationalError: message }, null, 2)}\n`)
|
|
337
|
+
else process.stderr.write(`dsh-doctor: ${message}\n\n${help('en')}`)
|
|
164
338
|
process.exitCode = 2
|
|
165
339
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
function record(value) {
|
|
4
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function source(layer, patchIndex) {
|
|
8
|
+
return {
|
|
9
|
+
kind: layer.kind,
|
|
10
|
+
file: layer.file,
|
|
11
|
+
patchIndex,
|
|
12
|
+
...(layer.package === undefined ? {} : { package: layer.package }),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function leafPaths(value, prefix = '') {
|
|
17
|
+
const object = record(value)
|
|
18
|
+
if (object === undefined) return prefix === '' ? [] : [prefix]
|
|
19
|
+
const paths = Object.entries(object).flatMap(([key, child]) => leafPaths(child, prefix === '' ? key : `${prefix}.${key}`))
|
|
20
|
+
return paths.length === 0 && prefix !== '' ? [prefix] : paths
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeEntry(value, origin) {
|
|
24
|
+
const entry = structuredClone(value)
|
|
25
|
+
const fields = {}
|
|
26
|
+
for (const [key, fieldValue] of Object.entries(entry)) {
|
|
27
|
+
fields[key] = { value: structuredClone(fieldValue), source: origin }
|
|
28
|
+
}
|
|
29
|
+
return { value: entry, origin, fields }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function flatten(nodes, result = []) {
|
|
33
|
+
for (const node of nodes) {
|
|
34
|
+
result.push({
|
|
35
|
+
...structuredClone(node.value),
|
|
36
|
+
origin: node.origin,
|
|
37
|
+
fields: node.fields,
|
|
38
|
+
})
|
|
39
|
+
if (node.value.group && Array.isArray(node.children)) flatten(node.children, result)
|
|
40
|
+
}
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function publicTree(nodes) {
|
|
45
|
+
return nodes.map(node => ({
|
|
46
|
+
...structuredClone(node.value),
|
|
47
|
+
...(node.children === undefined ? {} : { config: publicTree(node.children) }),
|
|
48
|
+
origin: node.origin,
|
|
49
|
+
fields: node.fields,
|
|
50
|
+
}))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function compose(layers, onIssue) {
|
|
54
|
+
const roots = []
|
|
55
|
+
const idMap = new Map()
|
|
56
|
+
const register = (node, layer, patchIndex) => {
|
|
57
|
+
const id = node.value.id
|
|
58
|
+
if (typeof id === 'string' && id.length > 0) {
|
|
59
|
+
if (idMap.has(id)) onIssue('DUPLICATE_ENTRY_ID', layer, patchIndex, node.value, { id })
|
|
60
|
+
idMap.set(id, node)
|
|
61
|
+
}
|
|
62
|
+
if (node.value.group && Array.isArray(node.value.config)) {
|
|
63
|
+
node.children = node.value.config.map(value => makeEntry(value, node.origin))
|
|
64
|
+
delete node.value.config
|
|
65
|
+
for (const child of node.children) register(child, layer, patchIndex)
|
|
66
|
+
} else if (node.value.group && Object.hasOwn(node.value, 'config')) {
|
|
67
|
+
onIssue('INVALID_GROUP_CONFIG', layer, patchIndex, node.value, { actualType: node.value.config === null ? 'null' : typeof node.value.config })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const layer of layers) {
|
|
71
|
+
layer.patches.forEach((patch, patchIndex) => {
|
|
72
|
+
const at = source(layer, patchIndex)
|
|
73
|
+
if (Array.isArray(patch.insert)) {
|
|
74
|
+
let destination = roots
|
|
75
|
+
if (typeof patch.id === 'string' && patch.id.length > 0) {
|
|
76
|
+
const target = idMap.get(patch.id)
|
|
77
|
+
if (target === undefined || !target.value.group) return
|
|
78
|
+
if (target.children === undefined) target.children = []
|
|
79
|
+
destination = target.children
|
|
80
|
+
}
|
|
81
|
+
for (const value of patch.insert) {
|
|
82
|
+
const node = makeEntry(value, at)
|
|
83
|
+
destination.push(node)
|
|
84
|
+
register(node, layer, patchIndex)
|
|
85
|
+
}
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (typeof patch.id !== 'string' || patch.id.length === 0) return
|
|
89
|
+
const target = idMap.get(patch.id)
|
|
90
|
+
if (target === undefined || (patch.name !== undefined && patch.name !== target.value.name)) return
|
|
91
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
92
|
+
if (key === 'id' || key === 'insert' || key === 'name') continue
|
|
93
|
+
const previous = target.fields[key]
|
|
94
|
+
const removedPaths = key === 'config' && previous !== undefined
|
|
95
|
+
? leafPaths(previous.value).filter(path => !leafPaths(value).includes(path))
|
|
96
|
+
: []
|
|
97
|
+
target.value[key] = structuredClone(value)
|
|
98
|
+
target.fields[key] = {
|
|
99
|
+
value: structuredClone(value),
|
|
100
|
+
source: at,
|
|
101
|
+
...(previous === undefined ? {} : { replacedSource: previous.source }),
|
|
102
|
+
...(removedPaths.length === 0 ? {} : { removedPaths }),
|
|
103
|
+
}
|
|
104
|
+
if (key === 'config' && target.value.group) {
|
|
105
|
+
// A complete override always discards the old public children. Keep
|
|
106
|
+
// the application index unchanged (applyEntryPatches does not index
|
|
107
|
+
// config overrides), but only rebuild children for a valid array.
|
|
108
|
+
delete target.children
|
|
109
|
+
if (Array.isArray(value)) {
|
|
110
|
+
target.children = value.map(child => makeEntry(child, at))
|
|
111
|
+
delete target.value.config
|
|
112
|
+
} else {
|
|
113
|
+
onIssue('INVALID_GROUP_CONFIG', layer, patchIndex, target.value, { actualType: value === null ? 'null' : typeof value })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (key === 'disabled' && value === true && layer.kind !== 'bundle') {
|
|
117
|
+
onIssue('CORE_ENTRY_DISABLED_BY_HIGHER_LAYER', layer, patchIndex, target.value, { origin: target.origin })
|
|
118
|
+
}
|
|
119
|
+
if (key === 'config' && previous !== undefined && layer.kind !== 'bundle' && previous.source.file !== at.file) {
|
|
120
|
+
onIssue(target.value.group ? 'GROUP_CONTENT_REPLACED' : 'CONFIG_REPLACED_BY_HIGHER_LAYER', layer, patchIndex, target.value, { removedPaths })
|
|
121
|
+
}
|
|
122
|
+
if (key === 'group' && layer.kind !== 'bundle') onIssue('ENTRY_STRUCTURE_OVERRIDDEN', layer, patchIndex, target.value, {})
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
const entries = flatten(roots)
|
|
127
|
+
const names = new Map()
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (entry.disabled === true || typeof entry.name !== 'string' || entry.name.length === 0) continue
|
|
130
|
+
const mounted = names.get(entry.name) ?? []
|
|
131
|
+
mounted.push(entry.id)
|
|
132
|
+
names.set(entry.name, mounted)
|
|
133
|
+
}
|
|
134
|
+
for (const [name, ids] of names) {
|
|
135
|
+
if (ids.length > 1) onIssue('DUPLICATE_PLUGIN_MOUNT', undefined, undefined, { name }, { ids })
|
|
136
|
+
}
|
|
137
|
+
return { tree: publicTree(roots), entries }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function buildConfigurationModel(layers, addFinding) {
|
|
141
|
+
const issues = []
|
|
142
|
+
const onIssue = (code, layer, patchIndex, entry, details) => {
|
|
143
|
+
const issue = {
|
|
144
|
+
code,
|
|
145
|
+
layer,
|
|
146
|
+
patchIndex,
|
|
147
|
+
entry,
|
|
148
|
+
details,
|
|
149
|
+
}
|
|
150
|
+
issues.push(issue)
|
|
151
|
+
if (addFinding !== undefined) addFinding(issue)
|
|
152
|
+
}
|
|
153
|
+
const bundleLayers = layers.filter(layer => layer.kind === 'bundle')
|
|
154
|
+
const currentDefault = compose(bundleLayers, () => {})
|
|
155
|
+
const currentEffective = compose(layers, onIssue)
|
|
156
|
+
return {
|
|
157
|
+
layers: layers.map(layer => ({ kind: layer.kind, file: layer.file, label: layer.label, package: layer.package })),
|
|
158
|
+
currentDefaultTree: currentDefault.tree,
|
|
159
|
+
currentEffectiveTree: currentEffective.tree,
|
|
160
|
+
entries: currentEffective.entries,
|
|
161
|
+
issues,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function readPatchText(file) {
|
|
166
|
+
return readFileSync(file, 'utf8')
|
|
167
|
+
}
|