@bruc3van/dsh-doctor 0.1.3 → 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
@@ -2,7 +2,7 @@
2
2
 
3
3
  [中文](README.md) | English
4
4
 
5
- DSH Doctor diagnoses common DeepSeek Harness failures, including startup errors, damaged configuration, and third-party plugin incompatibility. A normal run is entirely read-only. Repairs run only after you explicitly use `--fix`, inspect the exact file or command plan, and confirm it.
5
+ DSH Doctor helps DSH and plugin users quickly identify plugins that break startup or stop working after a DSH upgrade. It groups each plugin's problems, impact, and recommended actions, while also checking common profile configuration and version-drift issues. Diagnosis is read-only by default; repairs run only after you explicitly use `--fix`, review the exact plan, and confirm it. File edits are backed up first.
6
6
 
7
7
  This is a community-maintained third-party tool and is not an official DeepSeek project. It does not load or execute code from the plugins it inspects.
8
8
 
@@ -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
 
@@ -137,4 +138,6 @@ npm run check
137
138
  node src/cli.mjs --help
138
139
  ```
139
140
 
140
- The first publication of a new package must be performed by the npm account that owns the `@bruc3van` scope with `npm publish --access public`. Then configure a GitHub Actions Trusted Publisher in the npm package settings with Organization or user `bruc3van`, Repository `dsh-doctor`, Workflow filename `release.yml`, no Environment, and only the `npm publish` allowed action. Later releases are triggered by pushing a `vX.Y.Z` tag matching `package.json`; the workflow publishes through OIDC with npm provenance and requires no long-lived npm token.
141
+ The first publication of a new package must be performed by the npm account that owns the `@bruc3van` scope with `npm publish --access public`. Then configure a GitHub Actions Trusted Publisher in the npm package settings with Organization or user `bruc3van`, Repository `dsh-doctor`, Workflow filename `release.yml`, no Environment, and only the `npm publish` allowed action.
142
+
143
+ Before each later release, add a Chinese `## vX.Y.Z` entry matching the version tag to `CHANGELOG.md`. Pushing a tag that matches `package.json` makes the workflow publish through OIDC with npm provenance and automatically create or update the GitHub Release from that Chinese entry. The release fails if the entry is missing or contains no Chinese text. No long-lived npm token is required.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  中文 | [English](README.en.md)
4
4
 
5
- DSH Doctor 用来定位 DeepSeek Harness 无法启动、配置损坏和第三方插件不兼容等常见问题。默认运行完全只读;只有显式使用 `--fix`,查看精确的文件或命令计划并确认后,它才会实施可回滚的修复。
5
+ DSH Doctor 面向 DSH 与插件使用者,帮助快速找出导致 DSH 启动异常或升级后不可用的插件,集中说明每个插件的问题、影响与处理方式,并检查常见的 profile 配置和版本漂移。诊断默认完全只读;只有显式使用 `--fix`、核对并确认精确的修复计划后才会执行,文件修改会先创建备份。
6
6
 
7
7
  这是社区维护的第三方工具,不属于 DeepSeek 官方项目。它不会加载或执行待检查插件的代码。
8
8
 
@@ -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
 
@@ -137,4 +138,6 @@ npm run check
137
138
  node src/cli.mjs --help
138
139
  ```
139
140
 
140
- 新包需要先由 `@bruc3van` 对应的 npm 账号完成一次 `npm publish --access public`,创建公开包页面。然后在 npm 包设置中添加 GitHub Actions Trusted Publisher:Organization or user 为 `bruc3van`,Repository 为 `dsh-doctor`,Workflow filename 为 `release.yml`,Environment 留空,Allowed actions 只启用 `npm publish`。后续推送与 `package.json` 版本一致的 `vX.Y.Z` tag,workflow 会通过 OIDC 发布并由 npm 自动生成 provenance,不需要保存长期 npm token。
141
+ 新包需要先由 `@bruc3van` 对应的 npm 账号完成一次 `npm publish --access public`,创建公开包页面。然后在 npm 包设置中添加 GitHub Actions Trusted Publisher:Organization or user 为 `bruc3van`,Repository 为 `dsh-doctor`,Workflow filename 为 `release.yml`,Environment 留空,Allowed actions 只启用 `npm publish`。
142
+
143
+ 后续发布前,需要在 `CHANGELOG.md` 中增加与版本 tag 同名的中文 `## vX.Y.Z` 条目。推送与 `package.json` 版本一致的 tag 后,workflow 会通过 OIDC 发布 npm 包、生成 provenance,并自动用该中文条目创建或更新 GitHub Release;缺少中文条目时发布流程会失败。不需要保存长期 npm token。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bruc3van/dsh-doctor",
3
- "version": "0.1.3",
4
- "description": "Diagnostics and confirmed recovery for DeepSeek Harness profiles and third-party plugins",
3
+ "version": "0.1.5",
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",
7
7
  "repository": {
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
@@ -39,6 +39,7 @@ function finding(severity, code, message, options = {}) {
39
39
  message,
40
40
  ...options.package === undefined ? {} : { package: options.package },
41
41
  ...options.evidence === undefined ? {} : { evidence: options.evidence },
42
+ ...options.details === undefined ? {} : { details: options.details },
42
43
  ...options.suggestion === undefined ? {} : { suggestion: options.suggestion },
43
44
  ...options.repair === undefined ? {} : { repair: options.repair },
44
45
  }
@@ -86,6 +87,12 @@ function packagePathParts(name) {
86
87
  return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) ? [name] : undefined
87
88
  }
88
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
+
89
96
  function packageManifestAt(directory, expectedName, findings, source) {
90
97
  const file = join(directory, 'package.json')
91
98
  if (!existsSync(file)) return undefined
@@ -173,11 +180,7 @@ function localDshPackage(start) {
173
180
  }
174
181
 
175
182
  function commandFromValue(value, env, cwd) {
176
- const expanded = value === '~'
177
- ? homedir()
178
- : value.startsWith('~/') || value.startsWith('~\\')
179
- ? resolve(homedir(), value.slice(2))
180
- : value
183
+ const expanded = expandUserPath(value)
181
184
  const looksLikePath = isAbsolute(expanded) || expanded.includes('/') || expanded.includes('\\') || expanded.startsWith('.')
182
185
  const file = looksLikePath ? resolve(cwd, expanded) : executableOnPath(expanded, env)
183
186
  if (file === undefined) return undefined
@@ -205,7 +208,13 @@ function resolveDshCli(options, harness, home) {
205
208
 
206
209
  const sharedPackage = join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh')
207
210
  if (existsSync(sharedPackage)) {
208
- 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)
209
218
  if (bin !== undefined) return { command: [process.execPath, bin.bin], path: bin.bin, source: 'profile', version: bin.version }
210
219
  }
211
220
 
@@ -269,7 +278,7 @@ function indexWorkspace(root, findings) {
269
278
 
270
279
  function resolveHarnessContext(home, explicitRoot, findings) {
271
280
  if (explicitRoot !== undefined) {
272
- const root = resolve(explicitRoot)
281
+ const root = resolve(expandUserPath(explicitRoot))
273
282
  if (!existsSync(join(root, 'package.json')) || !existsSync(join(root, 'packages'))) {
274
283
  findings.push(finding('error', 'INVALID_HARNESS_ROOT', 'The configured Harness root is not a source checkout.', {
275
284
  evidence: root,
@@ -283,7 +292,16 @@ function resolveHarnessContext(home, explicitRoot, findings) {
283
292
 
284
293
  const sharedDsh = join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh')
285
294
  if (existsSync(sharedDsh)) {
286
- 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
+ }
287
305
  const root = findHarnessRoot(canonical)
288
306
  if (root !== undefined) {
289
307
  const manifest = readJson(join(root, 'package.json'), 'Harness root manifest', findings)
@@ -293,7 +311,7 @@ function resolveHarnessContext(home, explicitRoot, findings) {
293
311
  return { root: canonical, packages: new Map(), version: manifest?.version, authoritative: false }
294
312
  }
295
313
 
296
- findings.push(finding('warning', 'HARNESS_INSTALLATION_UNKNOWN', 'Could not locate the Harness installation used by this home.', {
314
+ findings.push(finding('warning', 'HARNESS_INSTALLATION_UNKNOWN', 'Could not locate the DSH installation currently used by this home.', {
297
315
  evidence: sharedDsh,
298
316
  suggestion: 'Pass --harness-root when diagnosing a source checkout.',
299
317
  }))
@@ -392,7 +410,10 @@ export function extractStaticRequires(source) {
392
410
  if (!code[match.index]) continue
393
411
  const specifier = match[2]
394
412
  if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || BUILTIN_MODULES.has(specifier)) continue
395
- 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)
396
417
  }
397
418
  return [...values].sort()
398
419
  }
@@ -400,6 +421,7 @@ export function extractStaticRequires(source) {
400
421
  function codePositions(source) {
401
422
  const code = new Uint8Array(source.length)
402
423
  let state = 'code'
424
+ let regexClass = false
403
425
  for (let index = 0; index < source.length; index += 1) {
404
426
  const char = source[index]
405
427
  const next = source[index + 1]
@@ -412,6 +434,9 @@ function codePositions(source) {
412
434
  index += 1
413
435
  } else if (char === "'" || char === '"' || char === '`') {
414
436
  state = char
437
+ } else if (char === '/' && regexCanStart(source, index)) {
438
+ state = 'regex'
439
+ regexClass = false
415
440
  } else {
416
441
  code[index] = 1
417
442
  }
@@ -425,6 +450,12 @@ function codePositions(source) {
425
450
  state = 'code'
426
451
  index += 1
427
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'
428
459
  } else if (char === '\\') {
429
460
  index += 1
430
461
  } else if (char === state) {
@@ -434,6 +465,17 @@ function codePositions(source) {
434
465
  return code
435
466
  }
436
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
+
437
479
  function stripClientSuffix(specifier) {
438
480
  return specifier.endsWith('/client') ? specifier.slice(0, -'/client'.length) : specifier
439
481
  }
@@ -504,6 +546,7 @@ function inspectClientPackage(record, context) {
504
546
  package: name,
505
547
  evidence: record.file,
506
548
  suggestion: disableSuggestion,
549
+ repair: updateRepair(profile, name, commandRepair),
507
550
  }))
508
551
  return
509
552
  }
@@ -525,6 +568,8 @@ function inspectClientPackage(record, context) {
525
568
  findings.push(finding('error', 'CLIENT_BUNDLE_UNREADABLE', `${name} client bundle cannot be read.`, {
526
569
  package: name,
527
570
  evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
571
+ suggestion: disableSuggestion,
572
+ repair: updateRepair(profile, name, commandRepair),
528
573
  }))
529
574
  return
530
575
  }
@@ -556,7 +601,7 @@ function inspectClientPackage(record, context) {
556
601
  ? harnessPackages.get(supplier)
557
602
  : harnessPackages.get(supplier) ?? resolvePackage(supplier)
558
603
  if (supplied === undefined || supplied.manifest?.dsh?.client === undefined) {
559
- findings.push(finding('error', 'CLIENT_EXTERNAL_WITHOUT_SUPPLIER', `${name} requests ${specifier}, but the active Harness has no client module supplier.`, {
604
+ findings.push(finding('error', 'CLIENT_EXTERNAL_WITHOUT_SUPPLIER', `${name} requests ${specifier}, but the active DSH has no client module supplier.`, {
560
605
  package: name,
561
606
  evidence: record.file,
562
607
  suggestion: disableSuggestion,
@@ -569,7 +614,7 @@ function inspectClientPackage(record, context) {
569
614
  for (const dependency of inject ?? []) {
570
615
  if (!dependency.startsWith('@deepseek-ai/dsh-')) continue
571
616
  if (!harnessPackages.has(stripClientSuffix(dependency))) {
572
- findings.push(finding('error', 'REMOVED_CLIENT_INJECT', `${name} injects ${dependency}, which is absent from the active Harness source tree.`, {
617
+ findings.push(finding('error', 'REMOVED_CLIENT_INJECT', `${name} injects ${dependency}, which is absent from the active DSH.`, {
573
618
  package: name,
574
619
  evidence: record.file,
575
620
  suggestion: disableSuggestion,
@@ -983,7 +1028,7 @@ function inspectProfileHarnessPackages(profileDir, home, harness, findings) {
983
1028
  }
984
1029
  }
985
1030
  if (harness.authoritative && !harness.packages.has(name)) {
986
- findings.push(finding('warning', 'STALE_PROFILE_HARNESS_PACKAGE', `${name} remains in the profile but is absent from the active Harness source tree.`, {
1031
+ findings.push(finding('warning', 'STALE_PROFILE_HARNESS_PACKAGE', `${name} remains in the profile but is absent from the active DSH.`, {
987
1032
  package: name,
988
1033
  evidence: profileManifestFile,
989
1034
  suggestion: 'Reinstall the profile with the active DSH CLI and review plugins that still require this package.',
@@ -1005,7 +1050,7 @@ function inspectCompatibility(record, context) {
1005
1050
  .filter(name => name.startsWith('@deepseek-ai/dsh-') && !harnessPackages.has(name))
1006
1051
  .sort()
1007
1052
  if (removedPeers.length > 0) {
1008
- findings.push(finding('warning', 'LEGACY_HARNESS_PEERS', `${packageName} still declares Harness packages that no longer exist in the active source tree.`, {
1053
+ findings.push(finding('warning', 'LEGACY_HARNESS_PEERS', `${packageName} declares old interface packages that the active DSH has removed.`, {
1009
1054
  package: packageName,
1010
1055
  evidence: removedPeers.join(', '),
1011
1056
  suggestion: 'Update this plugin before relying on it with the current DSH release.',
@@ -1017,7 +1062,7 @@ function inspectCompatibility(record, context) {
1017
1062
  .filter(name => name.startsWith('@deepseek-ai/dsh-') && !harnessPackages.has(name))
1018
1063
  .sort()
1019
1064
  if (removedDependencies.length > 0) {
1020
- findings.push(finding('warning', 'LEGACY_HARNESS_DEPENDENCIES', `${packageName} depends on Harness packages that no longer exist in the active source tree.`, {
1065
+ findings.push(finding('warning', 'LEGACY_HARNESS_DEPENDENCIES', `${packageName} depends on Harness packages that no longer exist in the active DSH.`, {
1021
1066
  package: packageName,
1022
1067
  evidence: removedDependencies.join(', '),
1023
1068
  suggestion: 'Update this plugin; its bundled DSH APIs may be incompatible with the current release.',
@@ -1026,8 +1071,11 @@ function inspectCompatibility(record, context) {
1026
1071
  }
1027
1072
  }
1028
1073
  const mismatches = []
1074
+ let declaredPeers = 0
1075
+ let resolvedPeers = 0
1029
1076
  for (const [name, range] of peers) {
1030
1077
  if (!name.startsWith('@deepseek-ai/') && name !== 'cordis') continue
1078
+ declaredPeers += 1
1031
1079
  if (semver.validRange(range) === null) {
1032
1080
  findings.push(finding('warning', 'INVALID_HARNESS_PEER_RANGE', `${packageName} declares an invalid Harness peer range for ${name}.`, {
1033
1081
  package: packageName,
@@ -1040,17 +1088,37 @@ function inspectCompatibility(record, context) {
1040
1088
  if (supplier === undefined) continue
1041
1089
  const version = supplier.manifest?.version
1042
1090
  if (typeof version !== 'string' || semver.valid(version) === null) continue
1091
+ resolvedPeers += 1
1043
1092
  if (semver.satisfies(version, range, { includePrerelease: true })) continue
1044
- mismatches.push(`${name} ${range} (active ${version})`)
1093
+ mismatches.push({ name, required: range, active: version })
1045
1094
  }
1046
1095
  if (mismatches.length > 0) {
1047
- findings.push(finding('warning', 'HARNESS_PEER_VERSION_MISMATCH', `${packageName} has Harness peer ranges that do not accept the active versions.`, {
1096
+ const grouped = new Map()
1097
+ for (const mismatch of mismatches) {
1098
+ const key = JSON.stringify([mismatch.required, mismatch.active])
1099
+ const group = grouped.get(key) ?? {
1100
+ required: mismatch.required, active: mismatch.active, packages: [],
1101
+ }
1102
+ group.packages.push(mismatch.name)
1103
+ grouped.set(key, group)
1104
+ }
1105
+ const groups = [...grouped.values()]
1106
+ .map(group => ({ ...group, packages: group.packages.sort() }))
1107
+ const evidence = groups.flatMap((group, index) => [
1108
+ ...(groups.length > 1 ? [`Group ${String(index + 1)}:`] : []),
1109
+ `${groups.length > 1 ? ' ' : ''}Plugin requires: ${group.required}`,
1110
+ `${groups.length > 1 ? ' ' : ''}Active DSH: ${group.active}`,
1111
+ `${groups.length > 1 ? ' ' : ''}Affected ${String(group.packages.length)} package(s): ${group.packages.join(', ')}`,
1112
+ ]).join('\n')
1113
+ findings.push(finding('warning', 'HARNESS_PEER_VERSION_MISMATCH', `${packageName} declares compatibility ranges that exclude the active DSH version.`, {
1048
1114
  package: packageName,
1049
- evidence: mismatches.join(', '),
1050
- suggestion: `Update ${packageName} to a release compatible with the active Harness.`,
1115
+ evidence,
1116
+ details: { peerVersionGroups: groups },
1117
+ suggestion: `Update ${packageName} to a release compatible with the active DSH.`,
1051
1118
  repair: updateRepair(profile, packageName, commandRepair),
1052
1119
  }))
1053
1120
  }
1121
+ return { declaredPeers, resolvedPeers }
1054
1122
  }
1055
1123
 
1056
1124
  function hasHarnessCompatibilityDeclaration(record) {
@@ -1059,28 +1127,31 @@ function hasHarnessCompatibilityDeclaration(record) {
1059
1127
  .some(name => name.startsWith('@deepseek-ai/') || name === 'cordis')
1060
1128
  }
1061
1129
 
1062
- function pluginCompatibility(record, findings) {
1130
+ function pluginCompatibility(record, findings, compatibilityCheck) {
1063
1131
  const name = record.requestedName ?? record.manifest.name
1064
1132
  const related = findings.filter(item => item.package === name)
1065
1133
  if (related.some(item => item.severity === 'error')) return 'incompatible'
1066
1134
  if (related.some(item => item.severity === 'warning')) return 'risk'
1067
1135
  if (!hasHarnessCompatibilityDeclaration(record)) return 'unknown'
1136
+ if (compatibilityCheck.resolvedPeers < compatibilityCheck.declaredPeers) return 'unknown'
1068
1137
  return 'compatible'
1069
1138
  }
1070
1139
 
1071
1140
  export function defaultDshHome(env = process.env) {
1072
1141
  const configured = env.DSH_HOME?.trim()
1073
1142
  if (configured !== undefined && configured.length > 0) {
1074
- if (configured === '~') return homedir()
1075
- if (configured.startsWith('~/') || configured.startsWith('~\\')) return resolve(homedir(), configured.slice(2))
1076
- return resolve(configured)
1143
+ return resolve(expandUserPath(configured))
1077
1144
  }
1078
1145
  return join(homedir(), '.dsh')
1079
1146
  }
1080
1147
 
1148
+ export function resolveDshHome(value, env = process.env) {
1149
+ return resolve(expandUserPath(value ?? defaultDshHome(env)))
1150
+ }
1151
+
1081
1152
  export function diagnose(options = {}) {
1082
1153
  const findings = []
1083
- const home = resolve(options.home ?? defaultDshHome(options.env))
1154
+ const home = resolveDshHome(options.home, options.env)
1084
1155
  const profile = options.profile ?? 'web'
1085
1156
  if (profile === '' || profile === '.' || profile === '..' || profile.includes('/') || profile.includes('\\')) {
1086
1157
  findings.push(finding('error', 'INVALID_PROFILE_NAME', `Invalid profile name ${JSON.stringify(profile)}.`))
@@ -1249,6 +1320,7 @@ export function diagnose(options = {}) {
1249
1320
  const thirdPartyRecords = dependencyNames
1250
1321
  .map(name => records.get(name))
1251
1322
  .filter(record => record !== undefined)
1323
+ const compatibilityChecks = new Map()
1252
1324
  for (const record of thirdPartyRecords) {
1253
1325
  inspectClientPackage(record, {
1254
1326
  findings,
@@ -1258,7 +1330,7 @@ export function diagnose(options = {}) {
1258
1330
  profile,
1259
1331
  resolvePackage,
1260
1332
  })
1261
- inspectCompatibility(record, {
1333
+ const check = inspectCompatibility(record, {
1262
1334
  findings,
1263
1335
  commandRepair,
1264
1336
  profile,
@@ -1266,6 +1338,7 @@ export function diagnose(options = {}) {
1266
1338
  harnessPackages: harness.packages,
1267
1339
  harnessPackagesAuthoritative: harness.authoritative,
1268
1340
  })
1341
+ compatibilityChecks.set(record.requestedName ?? record.manifest.name, check)
1269
1342
  inspectPluginNodeEngine(
1270
1343
  record,
1271
1344
  dshCli?.command?.[0] === process.execPath ? process.version : undefined,
@@ -1292,7 +1365,11 @@ export function diagnose(options = {}) {
1292
1365
  installed: true,
1293
1366
  client: record.manifest?.dsh?.client !== undefined,
1294
1367
  bundle: record.manifest?.dsh?.bundle !== undefined,
1295
- compatibility: pluginCompatibility(record, findings),
1368
+ compatibility: pluginCompatibility(
1369
+ record,
1370
+ findings,
1371
+ compatibilityChecks.get(record.requestedName ?? record.manifest.name),
1372
+ ),
1296
1373
  }
1297
1374
  }),
1298
1375
  }, findings)
@@ -1318,6 +1395,20 @@ function finish(context, findings) {
1318
1395
  return { version: 1, ok: summary.errors === 0, context, summary, findings }
1319
1396
  }
1320
1397
 
1398
+ function appendReportField(lines, label, value, indent = ' ') {
1399
+ const valueLines = String(value).split('\n')
1400
+ if (valueLines.length === 1) {
1401
+ lines.push(`${indent}${label}: ${valueLines[0]}`)
1402
+ return
1403
+ }
1404
+ lines.push(`${indent}${label}:`)
1405
+ lines.push(...valueLines.map(line => `${indent} ${line}`))
1406
+ }
1407
+
1408
+ function uniqueStrings(values) {
1409
+ return [...new Set(values.filter(value => typeof value === 'string' && value.length > 0))]
1410
+ }
1411
+
1321
1412
  export function formatReport(report, options = {}) {
1322
1413
  const color = options.color ?? false
1323
1414
  const language = options.language ?? 'en'
@@ -1336,7 +1427,7 @@ export function formatReport(report, options = {}) {
1336
1427
  'DSH Doctor',
1337
1428
  `${zh ? 'Profile' : 'Profile'}: ${report.context.profile}`,
1338
1429
  `${zh ? 'DSH 主目录' : 'Home'}: ${report.context.home}`,
1339
- `Harness: ${report.context.harness.version ?? 'unknown'}${report.context.harness.root ? ` (${report.context.harness.root})` : ''}`,
1430
+ `${zh ? '当前使用的 DSH' : 'Active DSH'}: ${report.context.harness.version ?? 'unknown'}${report.context.harness.root ? ` (${report.context.harness.root})` : ''}`,
1340
1431
  `${zh ? 'DSH CLI' : 'DSH CLI'}: ${cliText}`,
1341
1432
  `${zh ? 'Profile 插件' : 'Profile plugins'}: ${String(report.context.packages.length)}`,
1342
1433
  zh
@@ -1345,19 +1436,130 @@ export function formatReport(report, options = {}) {
1345
1436
  `${zh ? '输出语言' : 'Output language'}: ${languageName(language)}`,
1346
1437
  '',
1347
1438
  ]
1348
- if (report.findings.length === 0) {
1349
- lines.push(paint('info', zh ? '正常 当前检查范围内未发现问题。' : 'OK No problems found by the MVP checks.'))
1439
+ const packageNames = new Set(report.context.packages.map(item => item.name))
1440
+ const pluginFindings = new Map()
1441
+ const environmentFindings = []
1442
+ for (const item of report.findings) {
1443
+ if (item.package !== undefined && packageNames.has(item.package)) {
1444
+ const items = pluginFindings.get(item.package) ?? []
1445
+ items.push(item)
1446
+ pluginFindings.set(item.package, items)
1447
+ } else environmentFindings.push(item)
1448
+ }
1449
+ const problemPackages = report.context.packages
1450
+ .filter(item => (item.compatibility === 'incompatible' || item.compatibility === 'risk')
1451
+ && pluginFindings.has(item.name))
1452
+ .sort((left, right) => {
1453
+ const rank = { incompatible: 0, risk: 1 }
1454
+ return rank[left.compatibility] - rank[right.compatibility] || left.name.localeCompare(right.name)
1455
+ })
1456
+ const unknownPackages = report.context.packages
1457
+ .filter(item => item.compatibility === 'unknown')
1458
+ .sort((left, right) => left.name.localeCompare(right.name))
1459
+
1460
+ if (report.findings.length === 0 && unknownPackages.length === 0) {
1461
+ lines.push(paint('info', zh ? '正常 当前检查范围内未发现问题。' : 'OK No problems found by the current checks.'))
1350
1462
  } else {
1351
- for (const original of report.findings) {
1463
+ if (problemPackages.length > 0) {
1464
+ lines.push(zh
1465
+ ? `插件问题(${String(problemPackages.length)} 个)`
1466
+ : `Plugin problems (${String(problemPackages.length)})`)
1467
+ lines.push('')
1468
+ for (const plugin of problemPackages) {
1469
+ const originals = pluginFindings.get(plugin.name)
1470
+ const status = plugin.compatibility === 'incompatible'
1471
+ ? zh ? '不兼容' : 'INCOMPATIBLE'
1472
+ : zh ? '有风险' : 'RISK'
1473
+ const severity = plugin.compatibility === 'incompatible' ? 'error' : 'warning'
1474
+ lines.push(paint(severity, `[${status}] ${plugin.name}`))
1475
+ appendReportField(lines, zh ? '版本' : 'Version', plugin.installed === false
1476
+ ? zh ? '未安装' : 'not installed'
1477
+ : plugin.version ?? (zh ? '未知' : 'unknown'))
1478
+ lines.push(zh
1479
+ ? ` 问题(${String(originals.length)}):`
1480
+ : ` Problems (${String(originals.length)}):`)
1481
+ originals.forEach((original, index) => {
1482
+ const item = localizedFinding(original, language)
1483
+ const prefix = `${plugin.name} `
1484
+ const message = item.message.startsWith(prefix) ? item.message.slice(prefix.length) : item.message
1485
+ lines.push(` ${String(index + 1)}. [${item.code}] ${message}`)
1486
+ if (item.evidence !== undefined) appendReportField(
1487
+ lines, zh ? '证据' : 'Evidence', item.evidence, ' ',
1488
+ )
1489
+ })
1490
+ const suggestionGroups = new Map()
1491
+ for (const original of originals) {
1492
+ const suggestion = localizedFinding(original, language).suggestion
1493
+ if (suggestion === undefined) continue
1494
+ const key = original.repair?.id ?? `suggestion:${suggestion}`
1495
+ if (!suggestionGroups.has(key)) suggestionGroups.set(key, suggestion)
1496
+ }
1497
+ const suggestions = [...suggestionGroups.values()]
1498
+ if (suggestions.length > 0) {
1499
+ lines.push(` ${zh ? '处理建议' : 'Recommended actions'}:`)
1500
+ suggestions.forEach(item => lines.push(` - ${item}`))
1501
+ }
1502
+ const commands = uniqueStrings(originals
1503
+ .filter(item => item.repair?.kind === 'command')
1504
+ .map(item => item.repair.command.map(quote).join(' ')))
1505
+ if (commands.length > 0) {
1506
+ lines.push(` ${zh ? '可执行命令' : 'Available commands'}:`)
1507
+ commands.forEach(command => lines.push(` $ ${command}`))
1508
+ }
1509
+ lines.push('')
1510
+ }
1511
+ }
1512
+
1513
+ if (unknownPackages.length > 0) {
1514
+ lines.push(zh
1515
+ ? `兼容性未确认(${String(unknownPackages.length)} 个插件)`
1516
+ : `Compatibility unknown (${String(unknownPackages.length)} plugin(s))`)
1517
+ for (const plugin of unknownPackages) {
1518
+ lines.push(`[${zh ? '未知' : 'UNKNOWN'}] ${plugin.name}${plugin.version ? ` ${plugin.version}` : ''}`)
1519
+ appendReportField(lines, zh ? '原因' : 'Reason', zh
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.')
1522
+ appendReportField(lines, zh ? '建议' : 'Action', zh
1523
+ ? '升级 DSH 后请关注该插件的发布说明或向插件作者确认。'
1524
+ : 'After a DSH upgrade, review the plugin release notes or ask its author to confirm compatibility.')
1525
+ }
1526
+ lines.push('')
1527
+ }
1528
+
1529
+ const stale = environmentFindings.filter(item => item.code === 'STALE_PROFILE_HARNESS_PACKAGE')
1530
+ const otherEnvironment = environmentFindings.filter(item => item.code !== 'STALE_PROFILE_HARNESS_PACKAGE')
1531
+ if (stale.length > 0 || otherEnvironment.length > 0) {
1532
+ lines.push(zh ? 'DSH 环境问题' : 'DSH environment problems')
1533
+ lines.push('')
1534
+ }
1535
+ if (stale.length > 0) {
1536
+ lines.push(paint('warning', zh
1537
+ ? `[警告] [STALE_PROFILE_HARNESS_PACKAGE ×${String(stale.length)}] 检测到当前 DSH 已不再包含的 profile 残留包。`
1538
+ : `[WARN] [STALE_PROFILE_HARNESS_PACKAGE ×${String(stale.length)}] Profile packages remain that the active DSH no longer includes.`))
1539
+ lines.push(` ${zh ? '残留包' : 'Stale packages'}:`)
1540
+ for (const original of stale) {
1541
+ const item = localizedFinding(original, language)
1542
+ lines.push(` - ${item.package ?? (zh ? '未知包' : 'unknown package')}`)
1543
+ if (item.evidence !== undefined) appendReportField(
1544
+ lines, zh ? '位置' : 'Location', item.evidence, ' ',
1545
+ )
1546
+ }
1547
+ const suggestions = uniqueStrings(stale.map(item => localizedFinding(item, language).suggestion))
1548
+ suggestions.forEach(item => appendReportField(lines, zh ? '处理建议' : 'Recommended action', item))
1549
+ lines.push('')
1550
+ }
1551
+ for (const original of otherEnvironment) {
1352
1552
  const item = localizedFinding(original, language)
1353
1553
  const label = zh
1354
1554
  ? item.severity === 'error' ? '错误' : item.severity === 'warning' ? '警告' : '信息'
1355
- : item.severity === 'error' ? 'ERROR' : item.severity === 'warning' ? 'WARN ' : 'INFO '
1555
+ : item.severity === 'error' ? 'ERROR' : item.severity === 'warning' ? 'WARN' : 'INFO'
1356
1556
  lines.push(paint(item.severity, `${label} [${item.code}] ${item.message}`))
1357
- if (item.package !== undefined) lines.push(` ${zh ? '包' : 'Package'}: ${item.package}`)
1358
- if (item.evidence !== undefined) lines.push(` ${zh ? '证据' : 'Evidence'}: ${item.evidence}`)
1359
- if (item.suggestion !== undefined) lines.push(` ${zh ? '建议' : 'Action'}: ${item.suggestion}`)
1360
- if (item.repair?.kind === 'command') lines.push(` ${zh ? '更新命令' : 'Update command'}: ${item.repair.command.map(quote).join(' ')}`)
1557
+ if (item.package !== undefined) appendReportField(lines, zh ? '包' : 'Package', item.package)
1558
+ if (item.evidence !== undefined) appendReportField(lines, zh ? '证据' : 'Evidence', item.evidence)
1559
+ if (item.suggestion !== undefined) appendReportField(lines, zh ? '处理建议' : 'Recommended action', item.suggestion)
1560
+ if (item.repair?.kind === 'command') appendReportField(
1561
+ lines, zh ? '可执行命令' : 'Available command', `$ ${item.repair.command.map(quote).join(' ')}`,
1562
+ )
1361
1563
  lines.push('')
1362
1564
  }
1363
1565
  }
@@ -1365,7 +1567,11 @@ export function formatReport(report, options = {}) {
1365
1567
  ? `汇总:${String(report.summary.errors)} 个错误,${String(report.summary.warnings)} 个警告`
1366
1568
  : `Summary: ${String(report.summary.errors)} error(s), ${String(report.summary.warnings)} warning(s)`)
1367
1569
  if (report.summary.errors > 0) lines.push(zh
1368
- ? 'Harness 可能无法启动。请优先更新或停用产生错误的插件。'
1369
- : 'Harness may fail to start. Upgrade or disable the error-producing plugin first.')
1570
+ ? 'DSH 可能无法启动。请优先更新或停用产生错误的插件。'
1571
+ : 'DSH may fail to start. Upgrade or disable the error-producing plugin first.')
1572
+ lines.push('')
1573
+ lines.push(zh
1574
+ ? '本诊断结果由 @bruc3van/dsh-doctor 生成,仅供参考。欢迎在 GitHub Star 或反馈问题:https://github.com/bruc3van/dsh-doctor'
1575
+ : 'This diagnostic report was generated by @bruc3van/dsh-doctor for reference only. Star the project or share feedback on GitHub: https://github.com/bruc3van/dsh-doctor')
1370
1576
  return `${lines.join('\n')}\n`
1371
1577
  }
package/src/i18n.mjs CHANGED
@@ -59,7 +59,7 @@ const ZH_MESSAGES = {
59
59
  PACKAGE_NAME_MISMATCH: item => `${item.package ?? '依赖'}解析到了名称不匹配的包。`,
60
60
  INVALID_HARNESS_ROOT: () => '指定的 Harness 根目录不是有效的源码工作区。',
61
61
  INVALID_WORKSPACE_MANIFEST: () => '已忽略一个无效的 Harness workspace 包清单。',
62
- HARNESS_INSTALLATION_UNKNOWN: () => '无法定位这个 DSH Home 实际使用的 Harness。',
62
+ HARNESS_INSTALLATION_UNKNOWN: () => '无法定位这个 DSH Home 当前使用的 DSH 安装。',
63
63
  INVALID_DEPENDENCY_MAP: item => `${captured(item.message, /^(\S+) must be/) ?? '依赖字段'}必须是“包名到版本范围”的对象。`,
64
64
  INVALID_CLIENT_DECLARATION: item => `${item.package} 的 dsh.client 声明无效。`,
65
65
  INVALID_CLIENT_PLATFORM: item => `${item.package} 的 dsh.client.platform 必须是字符串。`,
@@ -71,10 +71,10 @@ const ZH_MESSAGES = {
71
71
  CLIENT_BUNDLE_UNREADABLE: item => `无法读取 ${item.package} 的客户端 bundle。`,
72
72
  UNDECLARED_CLIENT_REQUIRE: item => `${item.package} 引用了 ${captured(item.message, / requires (.+) but does not/) ?? '未声明模块'},但没有在 dsh.client.external 中声明。`,
73
73
  REDUNDANT_CLIENT_EXTERNAL: item => `${item.package} 把平台模块 ${captured(item.message, / module (.+) as an external/) ?? ''} 重复声明为 external。`,
74
- CLIENT_EXTERNAL_WITHOUT_SUPPLIER: item => `${item.package} 请求了 ${captured(item.message, / requests (.+), but/) ?? '客户端模块'},但当前 Harness 没有对应的模块提供方。`,
75
- REMOVED_CLIENT_INJECT: item => `${item.package} 注入了 ${captured(item.message, / injects (.+), which/) ?? '已移除的模块'},但当前 Harness 源码中已不存在该模块。`,
76
- LEGACY_HARNESS_PEERS: item => `${item.package} 仍声明了当前 Harness 源码中已不存在的旧包。`,
77
- LEGACY_HARNESS_DEPENDENCIES: item => `${item.package} 仍依赖当前 Harness 源码中已不存在的旧包。`,
74
+ CLIENT_EXTERNAL_WITHOUT_SUPPLIER: item => `${item.package} 请求了 ${captured(item.message, / requests (.+), but/) ?? '客户端模块'},但当前使用的 DSH 没有对应的模块提供方。`,
75
+ REMOVED_CLIENT_INJECT: item => `${item.package} 注入了 ${captured(item.message, / injects (.+), which/) ?? '已移除的模块'},但当前使用的 DSH 中已不存在该模块。`,
76
+ LEGACY_HARNESS_PEERS: item => `${item.package} 声明依赖当前使用的 DSH 已移除的旧接口包。`,
77
+ LEGACY_HARNESS_DEPENDENCIES: item => `${item.package} 仍依赖当前使用的 DSH 中已不存在的旧包。`,
78
78
  BUNDLE_NOT_INSTALLED: item => `配置中的 bundle ${item.package} 尚未安装。`,
79
79
  BUNDLE_DECLARATION_MISSING: item => `${item.package} 被列为 profile bundle,但没有声明 dsh.bundle.patch。`,
80
80
  BUNDLE_PATCH_MISSING: item => `${item.package} 的 bundle patch 文件缺失。`,
@@ -92,7 +92,7 @@ const ZH_MESSAGES = {
92
92
  INVALID_CREDENTIALS_DOCUMENT: () => 'Harness 凭据文件无法解析。',
93
93
  INVALID_CREDENTIALS_ROOT: () => 'Harness 凭据文件顶层必须是映射。',
94
94
  INVALID_CREDENTIALS_LAYOUT: () => 'Harness 凭据文件不是受支持的 version 1 结构。',
95
- HARNESS_PEER_VERSION_MISMATCH: item => `${item.package} 声明的 Harness peer 版本范围不接受当前已安装版本。`,
95
+ HARNESS_PEER_VERSION_MISMATCH: item => `${item.package} 声明的兼容范围不包含当前使用的 DSH 版本。`,
96
96
  INVALID_PROFILE_NAME: item => `Profile 名称无效:${captured(item.message, /^Invalid profile name (.+)\.$/) ?? ''}`,
97
97
  PROFILE_NOT_FOUND: item => `Profile ${captured(item.message, /^Profile (.+) does not exist\.$/) ?? ''} 不存在。`,
98
98
  INVALID_DSH_CONFIGURATION: () => 'dsh 字段存在时必须是对象。',
@@ -114,7 +114,7 @@ const ZH_MESSAGES = {
114
114
  LOCKFILE_DEPENDENCY_STALE: item => `pnpm lockfile 仍包含未声明的依赖 ${item.package}。`,
115
115
  DSH_CLI_HARNESS_VERSION_MISMATCH: () => '当前 DSH CLI 与诊断到的 Harness 版本不一致。',
116
116
  DUPLICATE_HARNESS_PACKAGE_VERSION: item => `${item.package} 在 profile 与共享 DSH 安装中存在不同版本。`,
117
- STALE_PROFILE_HARNESS_PACKAGE: item => `${item.package} 残留在 profile 中,但当前 Harness 源码已不再包含它。`,
117
+ STALE_PROFILE_HARNESS_PACKAGE: item => `${item.package} 残留在 profile 中,但当前使用的 DSH 已不再包含它。`,
118
118
  PROFILE_HARNESS_SCOPE_UNREADABLE: () => 'Profile 内的 @deepseek-ai 包作用域无法作为目录读取。',
119
119
  }
120
120
 
@@ -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,
@@ -155,7 +156,7 @@ const ZH_SUGGESTIONS = {
155
156
  INVALID_SETTINGS_ROOT: () => '把顶层标量或数组替换为映射。',
156
157
  INVALID_CREDENTIALS_DOCUMENT: () => '只修复报告的结构;Doctor 永远不会输出或重写秘密值。',
157
158
  INVALID_CREDENTIALS_LAYOUT: () => '迁移文档结构,不要暴露或修改秘密值。',
158
- HARNESS_PEER_VERSION_MISMATCH: item => `把 ${item.package} 更新到兼容当前 Harness 的版本。`,
159
+ HARNESS_PEER_VERSION_MISMATCH: item => `把 ${item.package} 更新到兼容当前使用的 DSH 的版本。`,
159
160
  PROFILE_NOT_FOUND: () => '先启动一次该 profile,或用当前 DSH 安装初始化它。',
160
161
  INVALID_DSH_CONFIGURATION: () => '启动 Harness 前,请先修复 dsh 配置对象。',
161
162
  INVALID_PROFILE_CONFIGURATION: () => '启动 Harness 前,请先修复 dsh.profile 配置对象。',
@@ -188,9 +189,21 @@ export function localizedFinding(item, language) {
188
189
  if (language !== 'zh') return item
189
190
  const message = ZH_MESSAGES[item.code]?.(item) ?? item.message
190
191
  const suggestion = item.suggestion === undefined ? undefined : (ZH_SUGGESTIONS[item.code]?.(item) ?? item.suggestion)
191
- const evidence = typeof item.evidence === 'string'
192
- ? item.evidence.replaceAll('(active ', '(当前 ').replace(/ at line (\d+), column (\d+)/g, ',第 $1 行第 $2 列')
193
- : item.evidence
192
+ const peerGroups = item.details?.peerVersionGroups
193
+ const evidence = Array.isArray(peerGroups)
194
+ ? peerGroups.flatMap((group, index) => [
195
+ ...(peerGroups.length > 1 ? [`第 ${String(index + 1)} 组:`] : []),
196
+ `${peerGroups.length > 1 ? ' ' : ''}插件要求:${group.required}`,
197
+ `${peerGroups.length > 1 ? ' ' : ''}当前 DSH:${group.active}`,
198
+ `${peerGroups.length > 1 ? ' ' : ''}涉及 ${String(group.packages.length)} 个包:${group.packages.join('、')}`,
199
+ ]).join('\n')
200
+ : typeof item.evidence === 'string'
201
+ ? (item.code === 'LEGACY_HARNESS_PEERS' || item.code === 'LEGACY_HARNESS_DEPENDENCIES'
202
+ ? item.evidence.replaceAll(', ', '、')
203
+ : item.evidence)
204
+ .replaceAll('(active ', '(当前 ')
205
+ .replace(/ at line (\d+), column (\d+)/g, ',第 $1 行第 $2 列')
206
+ : item.evidence
194
207
  return { ...item, message, suggestion, evidence }
195
208
  }
196
209
 
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
  }