@bruc3van/dsh-doctor 0.1.4 → 0.1.5

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 CHANGED
@@ -23,7 +23,7 @@ npx @bruc3van/dsh-doctor
23
23
 
24
24
  By default, Doctor checks `$DSH_HOME/profiles/web`. If `DSH_HOME` is unset, it uses `~/.dsh`.
25
25
 
26
- Doctor does not require `dsh` to be installed as a global command. It searches PATH, the current project, shared profile installations, links left by the npx cache, and built Harness source checkouts, in that order. For a bundled DSH Desktop runtime or another custom installation, pass `--dsh-command /path/to/dsh`; the official package's `lib/bin.js` is also accepted. If no CLI can be found, Doctor still completes its read-only checks but does not offer or run command-based repairs that it cannot verify.
26
+ Doctor does not require `dsh` to be installed as a global command. It searches, in order, an explicit `--dsh-command` or `DSH_DOCTOR_DSH_COMMAND`, the CLI under an explicit `--harness-root`, the shared profile installation or links left by the npx cache, the current project, PATH, and finally an automatically detected Harness source checkout. For a bundled DSH Desktop runtime or another custom installation, pass `--dsh-command /path/to/dsh`; the official package's `lib/bin.js` is also accepted. If no CLI can be found, Doctor still completes its read-only checks but does not offer or run command-based repairs that it cannot verify.
27
27
 
28
28
  ## How it works
29
29
 
@@ -80,7 +80,7 @@ After DSH is updated, Doctor assigns every direct profile plugin one explicit st
80
80
 
81
81
  - `incompatible`: Doctor found an error that can prevent the plugin or Harness from loading, such as a missing plugin or an injection targeting a removed client runtime.
82
82
  - `risk`: Doctor found a current-version risk, such as a Harness peer range that rejects the new version, a dependency on a removed DSH package, an unsupported Node.js version, or installation drift.
83
- - `unknown`: The plugin does not declare a Harness compatibility range through `peerDependencies`. Doctor cannot prove it supports the upgraded DSH, but does not report uncertainty as a failure.
83
+ - `unknown`: The plugin does not declare a Harness compatibility range through `peerDependencies`, or the active package version for a declared peer cannot be resolved. Doctor cannot prove it supports the upgraded DSH, but does not report uncertainty as a failure.
84
84
  - `compatible`: The declared compatibility ranges accept the active Harness and no plugin-related errors or warnings were found.
85
85
 
86
86
  Compatibility checks cover every direct profile plugin, not only frontend plugins with `dsh.client`. References to removed Harness APIs in bundle-only or server-side plugins are reported as well. After upgrading DSH, run `dsh-doctor` first, review the exact update recommendations, and then decide whether to continue with `dsh-doctor --fix`.
@@ -110,6 +110,7 @@ Every executable repair has a stable ID, risk level, description, and exact targ
110
110
  - External commands use fixed argument arrays and never construct shell commands.
111
111
  - `--json --fix --yes` captures subprocess output in the repair result so stdout remains exactly one valid JSON document.
112
112
  - Command repairs bind the diagnosed `DSH_HOME` and show the resolved CLI path instead of assuming `dsh` exists on PATH.
113
+ - Each command repair has a 10-minute limit; a timeout terminates that action and marks subsequent actions as skipped.
113
114
  - A failed repair stops later actions and preserves backups already created.
114
115
  - Doctor runs every diagnostic again after repairs and uses the final state for its exit code.
115
116
 
@@ -125,7 +126,7 @@ The initial release automatically performs only deterministic operations, such a
125
126
 
126
127
  - Static scanning recognizes only literal `require("package")` calls. Dynamic dependencies require a future bundle metadata contract.
127
128
  - Configuration checks cover syntax and structures that Doctor can align deterministically. Patch composition follows the current Harness algorithm, but Doctor does not evaluate `!!js` or load third-party plugins.
128
- - Version compatibility is based on plugin `peerDependencies` and resolvable active Harness package versions. A plugin without a declared range can receive only structural checks and an `unknown` compatibility state.
129
+ - Version compatibility is based on plugin `peerDependencies` and resolvable active Harness package versions. A plugin without a declared range, or whose corresponding active version cannot be resolved, can receive only structural checks and an `unknown` compatibility state.
129
130
  - Lockfile checks deterministically cross-check the direct profile importer only; they do not recursively scan the complete npm dependency graph.
130
131
  - A runtime startup probe is not enabled. Even a copied `DSH_HOME` would not make arbitrary third-party plugin code side-effect-free because it could access the network, absolute paths, or external processes.
131
132
 
package/README.md CHANGED
@@ -23,7 +23,7 @@ npx @bruc3van/dsh-doctor
23
23
 
24
24
  默认检查 `$DSH_HOME/profiles/web`;未设置 `DSH_HOME` 时使用 `~/.dsh`。
25
25
 
26
- Doctor 不要求 `dsh` 必须是全局命令。它会按顺序查找 PATH、当前项目安装、profile 共享安装或 npx 缓存留下的链接、已构建的 Harness 源码工作区。DSH Desktop 内置运行时或其他特殊安装可以通过 `--dsh-command /path/to/dsh`(也接受官方包的 `lib/bin.js`)明确指定。找不到 CLI 时仍会完成只读诊断,但不会提供或执行无法验证的命令型修复。
26
+ Doctor 不要求 `dsh` 必须是全局命令。它会按顺序查找 `--dsh-command` 或 `DSH_DOCTOR_DSH_COMMAND` 指定的命令、显式 `--harness-root` 中的 CLI、profile 共享安装或 npx 缓存留下的链接、当前项目安装、PATH,最后是自动识别出的 Harness 源码工作区。DSH Desktop 内置运行时或其他特殊安装可以通过 `--dsh-command /path/to/dsh`(也接受官方包的 `lib/bin.js`)明确指定。找不到 CLI 时仍会完成只读诊断,但不会提供或执行无法验证的命令型修复。
27
27
 
28
28
  ## 工作方式
29
29
 
@@ -59,7 +59,7 @@ DSH 更新后,Doctor 会把每个 profile 插件归入一个明确状态,并
59
59
 
60
60
  - `incompatible`:已经发现会阻断插件加载或 Harness 启动的错误,例如插件未安装,或注入了已删除的 client runtime。
61
61
  - `risk`:发现当前版本风险,例如 Harness peer range 不接受新版本、仍依赖已删除的 DSH 包、Node.js 不兼容,或安装版本发生漂移。
62
- - `unknown`:插件没有通过 `peerDependencies` 声明 Harness 兼容范围;Doctor 无法证明它支持升级后的 DSH,但不会把未知误报成故障。
62
+ - `unknown`:插件没有通过 `peerDependencies` 声明 Harness 兼容范围,或声明对应的当前 package 版本无法解析;Doctor 无法证明它支持升级后的 DSH,但不会把未知误报成故障。
63
63
  - `compatible`:插件声明的兼容范围接受当前 Harness,且没有发现插件相关错误或警告。
64
64
 
65
65
  兼容性检查覆盖所有 profile 直接插件,不再只检查带 `dsh.client` 的前端插件;纯 bundle 或服务端插件引用旧 Harness API 也会被报告。建议 DSH 升级后先运行一次 `dsh-doctor`,再根据精确的 update 建议决定是否执行 `dsh-doctor --fix`。
@@ -110,6 +110,7 @@ dsh-doctor --fix --yes --json
110
110
  - 外部命令使用固定 argv 调用,不拼接 shell 命令。
111
111
  - `--json --fix --yes` 会捕获子命令输出并放入修复结果,保证 stdout 始终只有一个合法 JSON 文档。
112
112
  - 命令修复绑定当前诊断的 `DSH_HOME`,并展示解析出的真实 CLI 路径;不会假定 PATH 中存在 `dsh`。
113
+ - 单个命令修复最长运行 10 分钟,超时会终止该动作并把后续动作标记为跳过。
113
114
  - 任一步失败即停止后续修复,并保留已经创建的备份。
114
115
  - 完成后重新运行全部诊断,以最终状态决定退出码。
115
116
 
@@ -125,7 +126,7 @@ dsh-doctor --fix --yes --json
125
126
 
126
127
  - 静态扫描只识别代码中的字面量 `require("package")`;动态依赖需要未来的 bundle 元数据协议。
127
128
  - 配置检查覆盖语法和 Doctor 能稳定对齐的结构,并按当前 Harness patch 算法做无执行组合检查;不会求值 `!!js`,也不会加载第三方插件。
128
- - 版本兼容以插件 `peerDependencies` 和当前可解析 Harness package 版本为依据;未声明兼容范围的插件只能做结构检查。
129
+ - 版本兼容以插件 `peerDependencies` 和当前可解析 Harness package 版本为依据;未声明兼容范围或无法解析对应当前版本的插件只能做结构检查。
129
130
  - lockfile 检查只对 profile 的直接依赖 importer 做确定性交叉验证,不递归扫描整个 npm 依赖树。
130
131
  - 真实启动探针尚未启用;即使复制 `DSH_HOME`,第三方插件仍可能访问网络、绝对路径或启动外部进程,不能宣称无副作用。
131
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bruc3van/dsh-doctor",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Diagnose DSH plugins broken by upgrades, startup blockers, and profile issues with explicit, confirmed repairs",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { defaultDshHome, formatReport, diagnose } from './doctor.mjs'
4
- import { applyRepairs, formatRepairPlan, repairsFromReport } from './repair.mjs'
3
+ import { formatReport, diagnose, resolveDshHome } from './doctor.mjs'
4
+ import { applyRepairs, formatRepairOutcome, formatRepairPlan, repairsFromReport } from './repair.mjs'
5
5
  import { resolveLanguage } from './i18n.mjs'
6
6
  import { readFileSync } from 'node:fs'
7
7
  import { dirname, resolve } from 'node:path'
@@ -44,11 +44,6 @@ const HELP_ZH = `用法:dsh-doctor [选项]
44
44
 
45
45
  const help = language => language === 'zh' ? HELP_ZH : HELP_EN
46
46
 
47
- function fail(message, language = 'en') {
48
- process.stderr.write(`dsh-doctor: ${message}\n\n${help(language)}`)
49
- process.exitCode = 2
50
- }
51
-
52
47
  function valueAfter(args, index, name) {
53
48
  const value = args[index + 1]
54
49
  if (value === undefined || value.startsWith('-')) throw new Error(`${name} needs a value`)
@@ -75,31 +70,37 @@ function parse(args) {
75
70
  }
76
71
 
77
72
  async function main() {
78
- let options
79
- try {
80
- options = parse(process.argv.slice(2))
81
- } catch (error) {
82
- fail(error instanceof Error ? error.message : String(error))
73
+ const options = parse(process.argv.slice(2))
74
+ if (options.version) {
75
+ const here = dirname(fileURLToPath(import.meta.url))
76
+ const manifest = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8'))
77
+ process.stdout.write(`${manifest.version}\n`)
78
+ return
79
+ }
80
+ if (options.help) {
81
+ const requested = ['auto', 'zh', 'en'].includes(options.lang) ? options.lang : undefined
82
+ const language = resolveLanguage({
83
+ requested,
84
+ home: resolveDshHome(options.home),
85
+ systemLocale: Intl.DateTimeFormat().resolvedOptions().locale,
86
+ })
87
+ process.stdout.write(help(language))
83
88
  return
84
89
  }
85
90
  const language = resolveLanguage({
86
91
  requested: options.lang,
87
- home: resolve(options.home ?? defaultDshHome()),
92
+ home: resolveDshHome(options.home),
88
93
  systemLocale: Intl.DateTimeFormat().resolvedOptions().locale,
89
94
  })
90
95
  if (options.yes && !options.fix) throw new Error('--yes requires --fix')
91
96
  if (options.json && options.fix && !options.yes) throw new Error('--json --fix requires --yes because a prompt would corrupt JSON output')
92
- if (options.help) {
93
- process.stdout.write(help(language))
94
- } else if (options.version) {
95
- const here = dirname(fileURLToPath(import.meta.url))
96
- const manifest = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8'))
97
- process.stdout.write(`${manifest.version}\n`)
98
- } else {
97
+ {
99
98
  let report = diagnose(options)
100
99
  let repairs = []
100
+ let actions = []
101
+ let repairDeclined = false
101
102
  if (options.fix) {
102
- const actions = repairsFromReport(report)
103
+ actions = repairsFromReport(report)
103
104
  if (actions.length === 0 && report.context.dshCli?.commandRepairNeeded && !report.context.dshCli.available) {
104
105
  throw new Error(language === 'zh'
105
106
  ? '未找到可用的 DSH CLI,无法执行命令型修复;请使用 --dsh-command 指定当前安装的 dsh 或 lib/bin.js'
@@ -120,13 +121,20 @@ async function main() {
120
121
  if (confirmed) {
121
122
  repairs = applyRepairs(actions, { captureOutput: options.json })
122
123
  if (repairs.every(item => item.status === 'applied')) report = diagnose(options)
123
- }
124
+ } else repairDeclined = true
124
125
  }
125
126
  }
126
127
  const output = options.fix ? { ...report, repairs } : report
128
+ const noRepairMessage = options.fix && repairs.length === 0 && !options.json
129
+ ? formatRepairOutcome(actions, repairs, {
130
+ declined: repairDeclined,
131
+ findingCount: report.findings.length,
132
+ language,
133
+ })
134
+ : ''
127
135
  process.stdout.write(options.json
128
136
  ? `${JSON.stringify(output, null, 2)}\n`
129
- : `${formatReport(report, { color: process.stdout.isTTY, language })}${repairs.length > 0 ? `${language === 'zh' ? '修复结果' : 'Repairs'}: ${JSON.stringify(repairs, null, 2)}\n` : ''}`)
137
+ : `${formatReport(report, { color: process.stdout.isTTY, language })}${repairs.length > 0 ? `${language === 'zh' ? '修复结果' : 'Repairs'}: ${JSON.stringify(repairs, null, 2)}\n` : noRepairMessage}`)
130
138
  process.exitCode = repairs.some(item => item.status === 'failed') ? 2 : report.summary.errors > 0 ? 1 : 0
131
139
  }
132
140
  }
@@ -138,7 +146,7 @@ try {
138
146
  if (process.argv.includes('--json')) {
139
147
  process.stdout.write(`${JSON.stringify({ version: 1, operationalError: message }, null, 2)}\n`)
140
148
  } else {
141
- process.stderr.write(`dsh-doctor: ${message}\n`)
149
+ process.stderr.write(`dsh-doctor: ${message}\n\n${help('en')}`)
142
150
  }
143
151
  process.exitCode = 2
144
152
  }
package/src/doctor.mjs CHANGED
@@ -87,6 +87,12 @@ function packagePathParts(name) {
87
87
  return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) ? [name] : undefined
88
88
  }
89
89
 
90
+ function expandUserPath(value) {
91
+ if (value === '~') return homedir()
92
+ if (value.startsWith('~/') || value.startsWith('~\\')) return resolve(homedir(), value.slice(2))
93
+ return value
94
+ }
95
+
90
96
  function packageManifestAt(directory, expectedName, findings, source) {
91
97
  const file = join(directory, 'package.json')
92
98
  if (!existsSync(file)) return undefined
@@ -174,11 +180,7 @@ function localDshPackage(start) {
174
180
  }
175
181
 
176
182
  function commandFromValue(value, env, cwd) {
177
- const expanded = value === '~'
178
- ? homedir()
179
- : value.startsWith('~/') || value.startsWith('~\\')
180
- ? resolve(homedir(), value.slice(2))
181
- : value
183
+ const expanded = expandUserPath(value)
182
184
  const looksLikePath = isAbsolute(expanded) || expanded.includes('/') || expanded.includes('\\') || expanded.startsWith('.')
183
185
  const file = looksLikePath ? resolve(cwd, expanded) : executableOnPath(expanded, env)
184
186
  if (file === undefined) return undefined
@@ -206,7 +208,13 @@ function resolveDshCli(options, harness, home) {
206
208
 
207
209
  const sharedPackage = join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh')
208
210
  if (existsSync(sharedPackage)) {
209
- const bin = manifestBin(realpathSync(sharedPackage))
211
+ let canonical
212
+ try {
213
+ canonical = realpathSync(sharedPackage)
214
+ } catch {
215
+ canonical = undefined
216
+ }
217
+ const bin = canonical === undefined ? undefined : manifestBin(canonical)
210
218
  if (bin !== undefined) return { command: [process.execPath, bin.bin], path: bin.bin, source: 'profile', version: bin.version }
211
219
  }
212
220
 
@@ -270,7 +278,7 @@ function indexWorkspace(root, findings) {
270
278
 
271
279
  function resolveHarnessContext(home, explicitRoot, findings) {
272
280
  if (explicitRoot !== undefined) {
273
- const root = resolve(explicitRoot)
281
+ const root = resolve(expandUserPath(explicitRoot))
274
282
  if (!existsSync(join(root, 'package.json')) || !existsSync(join(root, 'packages'))) {
275
283
  findings.push(finding('error', 'INVALID_HARNESS_ROOT', 'The configured Harness root is not a source checkout.', {
276
284
  evidence: root,
@@ -284,7 +292,16 @@ function resolveHarnessContext(home, explicitRoot, findings) {
284
292
 
285
293
  const sharedDsh = join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh')
286
294
  if (existsSync(sharedDsh)) {
287
- const canonical = realpathSync(sharedDsh)
295
+ let canonical
296
+ try {
297
+ canonical = realpathSync(sharedDsh)
298
+ } catch (error) {
299
+ findings.push(finding('warning', 'HARNESS_INSTALLATION_UNKNOWN', 'Could not resolve the DSH installation currently used by this home.', {
300
+ evidence: `${sharedDsh}: ${error instanceof Error ? error.message : String(error)}`,
301
+ suggestion: 'Repair the shared DSH installation or pass --harness-root for a source checkout.',
302
+ }))
303
+ return { root: undefined, packages: new Map(), version: undefined, authoritative: false }
304
+ }
288
305
  const root = findHarnessRoot(canonical)
289
306
  if (root !== undefined) {
290
307
  const manifest = readJson(join(root, 'package.json'), 'Harness root manifest', findings)
@@ -393,7 +410,10 @@ export function extractStaticRequires(source) {
393
410
  if (!code[match.index]) continue
394
411
  const specifier = match[2]
395
412
  if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || BUILTIN_MODULES.has(specifier)) continue
396
- if (packagePathParts(specifier) !== undefined || specifier.startsWith('@')) values.add(specifier)
413
+ const packageName = specifier.startsWith('@')
414
+ ? specifier.split('/').slice(0, 2).join('/')
415
+ : specifier.split('/')[0]
416
+ if (packagePathParts(packageName) !== undefined) values.add(specifier)
397
417
  }
398
418
  return [...values].sort()
399
419
  }
@@ -401,6 +421,7 @@ export function extractStaticRequires(source) {
401
421
  function codePositions(source) {
402
422
  const code = new Uint8Array(source.length)
403
423
  let state = 'code'
424
+ let regexClass = false
404
425
  for (let index = 0; index < source.length; index += 1) {
405
426
  const char = source[index]
406
427
  const next = source[index + 1]
@@ -413,6 +434,9 @@ function codePositions(source) {
413
434
  index += 1
414
435
  } else if (char === "'" || char === '"' || char === '`') {
415
436
  state = char
437
+ } else if (char === '/' && regexCanStart(source, index)) {
438
+ state = 'regex'
439
+ regexClass = false
416
440
  } else {
417
441
  code[index] = 1
418
442
  }
@@ -426,6 +450,12 @@ function codePositions(source) {
426
450
  state = 'code'
427
451
  index += 1
428
452
  }
453
+ } else if (state === 'regex') {
454
+ if (char === '\\') index += 1
455
+ else if (char === '[') regexClass = true
456
+ else if (char === ']') regexClass = false
457
+ else if (char === '/' && !regexClass) state = 'code'
458
+ else if (char === '\n' || char === '\r') state = 'code'
429
459
  } else if (char === '\\') {
430
460
  index += 1
431
461
  } else if (char === state) {
@@ -435,6 +465,17 @@ function codePositions(source) {
435
465
  return code
436
466
  }
437
467
 
468
+ function regexCanStart(source, slashIndex) {
469
+ let index = slashIndex - 1
470
+ while (index >= 0 && /\s/.test(source[index])) index -= 1
471
+ if (index < 0) return true
472
+ if ('([{=,:;!&|?~+-*%^<>'.includes(source[index])) return true
473
+ if (!/[a-zA-Z0-9_$]/.test(source[index])) return false
474
+ const end = index + 1
475
+ while (index >= 0 && /[a-zA-Z0-9_$]/.test(source[index])) index -= 1
476
+ return /^(?:await|case|delete|do|else|in|instanceof|new|of|return|throw|typeof|void|yield)$/.test(source.slice(index + 1, end))
477
+ }
478
+
438
479
  function stripClientSuffix(specifier) {
439
480
  return specifier.endsWith('/client') ? specifier.slice(0, -'/client'.length) : specifier
440
481
  }
@@ -505,6 +546,7 @@ function inspectClientPackage(record, context) {
505
546
  package: name,
506
547
  evidence: record.file,
507
548
  suggestion: disableSuggestion,
549
+ repair: updateRepair(profile, name, commandRepair),
508
550
  }))
509
551
  return
510
552
  }
@@ -526,6 +568,8 @@ function inspectClientPackage(record, context) {
526
568
  findings.push(finding('error', 'CLIENT_BUNDLE_UNREADABLE', `${name} client bundle cannot be read.`, {
527
569
  package: name,
528
570
  evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
571
+ suggestion: disableSuggestion,
572
+ repair: updateRepair(profile, name, commandRepair),
529
573
  }))
530
574
  return
531
575
  }
@@ -1027,8 +1071,11 @@ function inspectCompatibility(record, context) {
1027
1071
  }
1028
1072
  }
1029
1073
  const mismatches = []
1074
+ let declaredPeers = 0
1075
+ let resolvedPeers = 0
1030
1076
  for (const [name, range] of peers) {
1031
1077
  if (!name.startsWith('@deepseek-ai/') && name !== 'cordis') continue
1078
+ declaredPeers += 1
1032
1079
  if (semver.validRange(range) === null) {
1033
1080
  findings.push(finding('warning', 'INVALID_HARNESS_PEER_RANGE', `${packageName} declares an invalid Harness peer range for ${name}.`, {
1034
1081
  package: packageName,
@@ -1041,6 +1088,7 @@ function inspectCompatibility(record, context) {
1041
1088
  if (supplier === undefined) continue
1042
1089
  const version = supplier.manifest?.version
1043
1090
  if (typeof version !== 'string' || semver.valid(version) === null) continue
1091
+ resolvedPeers += 1
1044
1092
  if (semver.satisfies(version, range, { includePrerelease: true })) continue
1045
1093
  mismatches.push({ name, required: range, active: version })
1046
1094
  }
@@ -1070,6 +1118,7 @@ function inspectCompatibility(record, context) {
1070
1118
  repair: updateRepair(profile, packageName, commandRepair),
1071
1119
  }))
1072
1120
  }
1121
+ return { declaredPeers, resolvedPeers }
1073
1122
  }
1074
1123
 
1075
1124
  function hasHarnessCompatibilityDeclaration(record) {
@@ -1078,28 +1127,31 @@ function hasHarnessCompatibilityDeclaration(record) {
1078
1127
  .some(name => name.startsWith('@deepseek-ai/') || name === 'cordis')
1079
1128
  }
1080
1129
 
1081
- function pluginCompatibility(record, findings) {
1130
+ function pluginCompatibility(record, findings, compatibilityCheck) {
1082
1131
  const name = record.requestedName ?? record.manifest.name
1083
1132
  const related = findings.filter(item => item.package === name)
1084
1133
  if (related.some(item => item.severity === 'error')) return 'incompatible'
1085
1134
  if (related.some(item => item.severity === 'warning')) return 'risk'
1086
1135
  if (!hasHarnessCompatibilityDeclaration(record)) return 'unknown'
1136
+ if (compatibilityCheck.resolvedPeers < compatibilityCheck.declaredPeers) return 'unknown'
1087
1137
  return 'compatible'
1088
1138
  }
1089
1139
 
1090
1140
  export function defaultDshHome(env = process.env) {
1091
1141
  const configured = env.DSH_HOME?.trim()
1092
1142
  if (configured !== undefined && configured.length > 0) {
1093
- if (configured === '~') return homedir()
1094
- if (configured.startsWith('~/') || configured.startsWith('~\\')) return resolve(homedir(), configured.slice(2))
1095
- return resolve(configured)
1143
+ return resolve(expandUserPath(configured))
1096
1144
  }
1097
1145
  return join(homedir(), '.dsh')
1098
1146
  }
1099
1147
 
1148
+ export function resolveDshHome(value, env = process.env) {
1149
+ return resolve(expandUserPath(value ?? defaultDshHome(env)))
1150
+ }
1151
+
1100
1152
  export function diagnose(options = {}) {
1101
1153
  const findings = []
1102
- const home = resolve(options.home ?? defaultDshHome(options.env))
1154
+ const home = resolveDshHome(options.home, options.env)
1103
1155
  const profile = options.profile ?? 'web'
1104
1156
  if (profile === '' || profile === '.' || profile === '..' || profile.includes('/') || profile.includes('\\')) {
1105
1157
  findings.push(finding('error', 'INVALID_PROFILE_NAME', `Invalid profile name ${JSON.stringify(profile)}.`))
@@ -1268,6 +1320,7 @@ export function diagnose(options = {}) {
1268
1320
  const thirdPartyRecords = dependencyNames
1269
1321
  .map(name => records.get(name))
1270
1322
  .filter(record => record !== undefined)
1323
+ const compatibilityChecks = new Map()
1271
1324
  for (const record of thirdPartyRecords) {
1272
1325
  inspectClientPackage(record, {
1273
1326
  findings,
@@ -1277,7 +1330,7 @@ export function diagnose(options = {}) {
1277
1330
  profile,
1278
1331
  resolvePackage,
1279
1332
  })
1280
- inspectCompatibility(record, {
1333
+ const check = inspectCompatibility(record, {
1281
1334
  findings,
1282
1335
  commandRepair,
1283
1336
  profile,
@@ -1285,6 +1338,7 @@ export function diagnose(options = {}) {
1285
1338
  harnessPackages: harness.packages,
1286
1339
  harnessPackagesAuthoritative: harness.authoritative,
1287
1340
  })
1341
+ compatibilityChecks.set(record.requestedName ?? record.manifest.name, check)
1288
1342
  inspectPluginNodeEngine(
1289
1343
  record,
1290
1344
  dshCli?.command?.[0] === process.execPath ? process.version : undefined,
@@ -1311,7 +1365,11 @@ export function diagnose(options = {}) {
1311
1365
  installed: true,
1312
1366
  client: record.manifest?.dsh?.client !== undefined,
1313
1367
  bundle: record.manifest?.dsh?.bundle !== undefined,
1314
- compatibility: pluginCompatibility(record, findings),
1368
+ compatibility: pluginCompatibility(
1369
+ record,
1370
+ findings,
1371
+ compatibilityChecks.get(record.requestedName ?? record.manifest.name),
1372
+ ),
1315
1373
  }
1316
1374
  }),
1317
1375
  }, findings)
@@ -1400,7 +1458,7 @@ export function formatReport(report, options = {}) {
1400
1458
  .sort((left, right) => left.name.localeCompare(right.name))
1401
1459
 
1402
1460
  if (report.findings.length === 0 && unknownPackages.length === 0) {
1403
- lines.push(paint('info', zh ? '正常 当前检查范围内未发现问题。' : 'OK No problems found by the MVP checks.'))
1461
+ lines.push(paint('info', zh ? '正常 当前检查范围内未发现问题。' : 'OK No problems found by the current checks.'))
1404
1462
  } else {
1405
1463
  if (problemPackages.length > 0) {
1406
1464
  lines.push(zh
@@ -1459,8 +1517,8 @@ export function formatReport(report, options = {}) {
1459
1517
  for (const plugin of unknownPackages) {
1460
1518
  lines.push(`[${zh ? '未知' : 'UNKNOWN'}] ${plugin.name}${plugin.version ? ` ${plugin.version}` : ''}`)
1461
1519
  appendReportField(lines, zh ? '原因' : 'Reason', zh
1462
- ? '插件没有声明当前 DSH 的兼容范围。'
1463
- : 'The plugin does not declare a compatibility range for the active DSH.')
1520
+ ? '插件没有声明当前 DSH 的兼容范围,或 Doctor 无法解析声明所对应的当前版本。'
1521
+ : 'The plugin declares no range for the active DSH, or Doctor could not resolve the active version for a declared peer.')
1464
1522
  appendReportField(lines, zh ? '建议' : 'Action', zh
1465
1523
  ? '升级 DSH 后请关注该插件的发布说明或向插件作者确认。'
1466
1524
  : 'After a DSH upgrade, review the plugin release notes or ask its author to confirm compatibility.')
package/src/i18n.mjs CHANGED
@@ -133,6 +133,7 @@ const ZH_SUGGESTIONS = {
133
133
  INVALID_CLIENT_INJECT: update,
134
134
  CLIENT_EXPORT_MISSING: update,
135
135
  CLIENT_BUNDLE_MISSING: update,
136
+ CLIENT_BUNDLE_UNREADABLE: update,
136
137
  UNDECLARED_CLIENT_REQUIRE: update,
137
138
  CLIENT_EXTERNAL_WITHOUT_SUPPLIER: update,
138
139
  REMOVED_CLIENT_INJECT: update,
package/src/repair.mjs CHANGED
@@ -3,6 +3,9 @@ import { copyFileSync, readFileSync, renameSync, statSync, writeFileSync } from
3
3
  import { dirname, join } from 'node:path'
4
4
  import crossSpawn from 'cross-spawn'
5
5
 
6
+ export const COMMAND_TIMEOUT_MS = 10 * 60 * 1000
7
+ const COMMAND_MAX_BUFFER = 16 * 1024 * 1024
8
+
6
9
  function hash(value) {
7
10
  return createHash('sha256').update(value).digest('hex')
8
11
  }
@@ -40,6 +43,16 @@ export function formatRepairPlan(actions, options = {}) {
40
43
  : `${plan}${zh ? '执行这些修复吗?[y/N] ' : 'Apply these repairs? [y/N] '}`
41
44
  }
42
45
 
46
+ export function formatRepairOutcome(actions, results, options = {}) {
47
+ if (results.length > 0) return ''
48
+ const zh = options.language === 'zh'
49
+ if (options.declined === true) return zh ? '修复结果:用户已取消。\n' : 'Repairs: cancelled by the user.\n'
50
+ if (actions.length > 0) return ''
51
+ return options.findingCount === 0
52
+ ? zh ? '修复结果:当前无需修复。\n' : 'Repairs: no repairs are needed.\n'
53
+ : zh ? '修复结果:没有可安全自动执行的修复,请按诊断建议手动处理。\n' : 'Repairs: no safe automatic repairs are available; follow the diagnostic suggestions manually.\n'
54
+ }
55
+
43
56
  function localizedDescription(action, zh) {
44
57
  if (!zh) return action.description
45
58
  if (action.id.startsWith('update-package:')) return `更新 profile ${action.profile} 中的 ${action.package}。`
@@ -90,11 +103,15 @@ function limitedOutput(value, limit = 8192) {
90
103
  function applyCommand(action, options) {
91
104
  const [command, ...args] = action.command
92
105
  const captureOutput = options.captureOutput === true
106
+ const timeout = options.commandTimeoutMs ?? COMMAND_TIMEOUT_MS
107
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new Error('command repair timeout must be a positive number')
93
108
  const result = crossSpawn.sync(command, args, {
94
109
  stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit',
95
- ...captureOutput ? { encoding: 'utf8' } : {},
110
+ ...captureOutput ? { encoding: 'utf8', maxBuffer: COMMAND_MAX_BUFFER } : {},
96
111
  env: action.env === undefined ? process.env : { ...process.env, ...action.env },
112
+ timeout,
97
113
  })
114
+ if (result.error?.code === 'ETIMEDOUT') throw new Error(`${command} timed out after ${String(timeout)} ms`)
98
115
  if (result.error != null) throw result.error
99
116
  if (result.status !== 0) {
100
117
  const reason = result.signal === null
@@ -115,7 +132,8 @@ function applyCommand(action, options) {
115
132
 
116
133
  export function applyRepairs(actions, options = {}) {
117
134
  const results = []
118
- for (const action of actions) {
135
+ for (let index = 0; index < actions.length; index += 1) {
136
+ const action = actions[index]
119
137
  try {
120
138
  results.push(action.kind === 'json-edit' ? applyJsonEdit(action) : applyCommand(action, options))
121
139
  } catch (error) {
@@ -124,6 +142,9 @@ export function applyRepairs(actions, options = {}) {
124
142
  status: 'failed',
125
143
  error: error instanceof Error ? error.message : String(error),
126
144
  })
145
+ for (const skipped of actions.slice(index + 1)) {
146
+ results.push({ id: skipped.id, status: 'skipped' })
147
+ }
127
148
  break
128
149
  }
129
150
  }