@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/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 { readFileSync } from 'node:fs'
7
- import { dirname, resolve } from 'node:path'
8
- import { fileURLToPath } from 'node:url'
9
- import { createInterface } from 'node:readline/promises'
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: dsh-doctor [options]
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
- Diagnostics and confirmed recovery for a DeepSeek Harness profile.
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
- --dsh-command <path> DSH executable or lib/bin.js used for command repairs
20
- --lang <auto|zh|en> output language (default: DSH setting, then system)
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 preview and apply available repairs after confirmation
23
- --yes confirm the displayed repair plan non-interactively
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 = `用法:dsh-doctor [选项]
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
- 诊断 DeepSeek Harness profile,并在用户确认后实施修复。
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 主目录(默认:$DSH_HOME 或 ~/.dsh)
72
+ --home <路径> Harness home(默认:$DSH_HOME 或 ~/.dsh)
35
73
  --harness-root <路径> 当前 DeepSeek Harness 源码工作区
36
- --dsh-command <路径> 命令修复使用的 DSH 可执行文件或 lib/bin.js
37
- --lang <auto|zh|en> 输出语言(默认:DSH 设置,其次系统语言)
38
- --json 输出稳定的机器可读 JSON
39
- --fix, --repair 展示修复计划,确认后实施
40
- --yes 在非交互环境中确认当前修复计划
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 prefix = `${name}=`
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
- for (let index = 0; index < args.length; index += 1) {
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
- const here = dirname(fileURLToPath(import.meta.url))
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
- if (options.help) {
94
- const requested = ['auto', 'zh', 'en'].includes(options.lang) ? options.lang : undefined
95
- const language = resolveLanguage({
96
- requested,
97
- home: resolveDshHome(options.home),
98
- systemLocale: Intl.DateTimeFormat().resolvedOptions().locale,
99
- })
100
- process.stdout.write(help(language))
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
- const language = resolveLanguage({
104
- requested: options.lang,
105
- home: resolveDshHome(options.home),
106
- systemLocale: Intl.DateTimeFormat().resolvedOptions().locale,
107
- })
108
- if (options.yes && !options.fix) throw new Error('--yes requires --fix')
109
- if (options.json && options.fix && !options.yes) throw new Error('--json --fix requires --yes because a prompt would corrupt JSON output')
110
- {
111
- let report = diagnose(options)
112
- let repairs = []
113
- let actions = []
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 output = options.fix ? { ...report, repairs } : report
141
- const noRepairMessage = options.fix && repairs.length === 0 && !options.json
142
- ? formatRepairOutcome(actions, repairs, {
143
- declined: repairDeclined,
144
- findingCount: report.findings.length,
145
- language,
146
- })
147
- : ''
148
- process.stdout.write(options.json
149
- ? `${JSON.stringify(output, null, 2)}\n`
150
- : `${formatReport(report, { color: process.stdout.isTTY, language })}${repairs.length > 0 ? `${language === 'zh' ? '修复结果' : 'Repairs'}: ${JSON.stringify(repairs, null, 2)}\n` : noRepairMessage}`)
151
- process.exitCode = repairs.some(item => item.status === 'failed') ? 2 : report.summary.errors > 0 ? 1 : 0
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
- process.stdout.write(`${JSON.stringify({ version: 1, operationalError: message }, null, 2)}\n`)
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
+ }