@bruc3van/dsh-doctor 0.1.4 → 0.1.6

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.6",
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,26 +44,34 @@ 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`)
55
50
  return value
56
51
  }
57
52
 
53
+ function optionValue(arg, name) {
54
+ const prefix = `${name}=`
55
+ if (!arg.startsWith(prefix)) return undefined
56
+ const value = arg.slice(prefix.length)
57
+ if (value === '') throw new Error(`${name} needs a value`)
58
+ return value
59
+ }
60
+
58
61
  function parse(args) {
59
62
  const options = {}
60
63
  for (let index = 0; index < args.length; index += 1) {
61
64
  const arg = args[index]
62
65
  if (arg === '--profile') options.profile = valueAfter(args, index++, arg)
66
+ else if (arg.startsWith('--profile=')) options.profile = optionValue(arg, '--profile')
63
67
  else if (arg === '--home') options.home = valueAfter(args, index++, arg)
68
+ else if (arg.startsWith('--home=')) options.home = optionValue(arg, '--home')
64
69
  else if (arg === '--harness-root') options.harnessRoot = valueAfter(args, index++, arg)
70
+ else if (arg.startsWith('--harness-root=')) options.harnessRoot = optionValue(arg, '--harness-root')
65
71
  else if (arg === '--dsh-command') options.dshCommand = valueAfter(args, index++, arg)
72
+ else if (arg.startsWith('--dsh-command=')) options.dshCommand = optionValue(arg, '--dsh-command')
66
73
  else if (arg === '--lang') options.lang = valueAfter(args, index++, arg)
74
+ else if (arg.startsWith('--lang=')) options.lang = optionValue(arg, '--lang')
67
75
  else if (arg === '--json') options.json = true
68
76
  else if (arg === '--fix' || arg === '--repair') options.fix = true
69
77
  else if (arg === '--yes') options.yes = true
@@ -75,31 +83,37 @@ function parse(args) {
75
83
  }
76
84
 
77
85
  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))
86
+ const options = parse(process.argv.slice(2))
87
+ 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`)
91
+ return
92
+ }
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))
83
101
  return
84
102
  }
85
103
  const language = resolveLanguage({
86
104
  requested: options.lang,
87
- home: resolve(options.home ?? defaultDshHome()),
105
+ home: resolveDshHome(options.home),
88
106
  systemLocale: Intl.DateTimeFormat().resolvedOptions().locale,
89
107
  })
90
108
  if (options.yes && !options.fix) throw new Error('--yes requires --fix')
91
109
  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 {
110
+ {
99
111
  let report = diagnose(options)
100
112
  let repairs = []
113
+ let actions = []
114
+ let repairDeclined = false
101
115
  if (options.fix) {
102
- const actions = repairsFromReport(report)
116
+ actions = repairsFromReport(report)
103
117
  if (actions.length === 0 && report.context.dshCli?.commandRepairNeeded && !report.context.dshCli.available) {
104
118
  throw new Error(language === 'zh'
105
119
  ? '未找到可用的 DSH CLI,无法执行命令型修复;请使用 --dsh-command 指定当前安装的 dsh 或 lib/bin.js'
@@ -120,13 +134,20 @@ async function main() {
120
134
  if (confirmed) {
121
135
  repairs = applyRepairs(actions, { captureOutput: options.json })
122
136
  if (repairs.every(item => item.status === 'applied')) report = diagnose(options)
123
- }
137
+ } else repairDeclined = true
124
138
  }
125
139
  }
126
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
+ : ''
127
148
  process.stdout.write(options.json
128
149
  ? `${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` : ''}`)
150
+ : `${formatReport(report, { color: process.stdout.isTTY, language })}${repairs.length > 0 ? `${language === 'zh' ? '修复结果' : 'Repairs'}: ${JSON.stringify(repairs, null, 2)}\n` : noRepairMessage}`)
130
151
  process.exitCode = repairs.some(item => item.status === 'failed') ? 2 : report.summary.errors > 0 ? 1 : 0
131
152
  }
132
153
  }
@@ -138,7 +159,7 @@ try {
138
159
  if (process.argv.includes('--json')) {
139
160
  process.stdout.write(`${JSON.stringify({ version: 1, operationalError: message }, null, 2)}\n`)
140
161
  } else {
141
- process.stderr.write(`dsh-doctor: ${message}\n`)
162
+ process.stderr.write(`dsh-doctor: ${message}\n\n${help('en')}`)
142
163
  }
143
164
  process.exitCode = 2
144
165
  }
package/src/doctor.mjs CHANGED
@@ -7,6 +7,9 @@ import semver from 'semver'
7
7
  import { parseDocument } from 'yaml'
8
8
  import { languageName, localizedFinding } from './i18n.mjs'
9
9
 
10
+ // Mirrors packages/client/web/src/platform.ts in DeepSeek Harness. Installed
11
+ // DSH packages do not expose this source list, so update this baseline when
12
+ // Harness changes PLATFORM_MODULES.
10
13
  export const PLATFORM_MODULES = new Set([
11
14
  'react',
12
15
  'react/jsx-runtime',
@@ -87,6 +90,12 @@ function packagePathParts(name) {
87
90
  return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) ? [name] : undefined
88
91
  }
89
92
 
93
+ function expandUserPath(value) {
94
+ if (value === '~') return homedir()
95
+ if (value.startsWith('~/') || value.startsWith('~\\')) return resolve(homedir(), value.slice(2))
96
+ return value
97
+ }
98
+
90
99
  function packageManifestAt(directory, expectedName, findings, source) {
91
100
  const file = join(directory, 'package.json')
92
101
  if (!existsSync(file)) return undefined
@@ -162,11 +171,12 @@ function manifestBin(packageDir) {
162
171
  }
163
172
  }
164
173
 
165
- function localDshPackage(start) {
174
+ function localDshBin(start) {
166
175
  let current = resolve(start)
167
176
  while (true) {
168
177
  const packageDir = join(current, 'node_modules', '@deepseek-ai', 'dsh')
169
- if (manifestBin(packageDir) !== undefined) return packageDir
178
+ const bin = manifestBin(packageDir)
179
+ if (bin !== undefined) return bin
170
180
  const parent = dirname(current)
171
181
  if (parent === current) return undefined
172
182
  current = parent
@@ -174,11 +184,7 @@ function localDshPackage(start) {
174
184
  }
175
185
 
176
186
  function commandFromValue(value, env, cwd) {
177
- const expanded = value === '~'
178
- ? homedir()
179
- : value.startsWith('~/') || value.startsWith('~\\')
180
- ? resolve(homedir(), value.slice(2))
181
- : value
187
+ const expanded = expandUserPath(value)
182
188
  const looksLikePath = isAbsolute(expanded) || expanded.includes('/') || expanded.includes('\\') || expanded.startsWith('.')
183
189
  const file = looksLikePath ? resolve(cwd, expanded) : executableOnPath(expanded, env)
184
190
  if (file === undefined) return undefined
@@ -206,14 +212,19 @@ function resolveDshCli(options, harness, home) {
206
212
 
207
213
  const sharedPackage = join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh')
208
214
  if (existsSync(sharedPackage)) {
209
- const bin = manifestBin(realpathSync(sharedPackage))
215
+ let canonical
216
+ try {
217
+ canonical = realpathSync(sharedPackage)
218
+ } catch {
219
+ canonical = undefined
220
+ }
221
+ const bin = canonical === undefined ? undefined : manifestBin(canonical)
210
222
  if (bin !== undefined) return { command: [process.execPath, bin.bin], path: bin.bin, source: 'profile', version: bin.version }
211
223
  }
212
224
 
213
- const localPackage = localDshPackage(cwd)
214
- if (localPackage !== undefined) {
215
- const bin = manifestBin(localPackage)
216
- return { command: [process.execPath, bin.bin], path: bin.bin, source: 'project', version: bin.version }
225
+ const localBin = localDshBin(cwd)
226
+ if (localBin !== undefined) {
227
+ return { command: [process.execPath, localBin.bin], path: localBin.bin, source: 'project', version: localBin.version }
217
228
  }
218
229
 
219
230
  const pathCommand = executableOnPath('dsh', env)
@@ -270,7 +281,7 @@ function indexWorkspace(root, findings) {
270
281
 
271
282
  function resolveHarnessContext(home, explicitRoot, findings) {
272
283
  if (explicitRoot !== undefined) {
273
- const root = resolve(explicitRoot)
284
+ const root = resolve(expandUserPath(explicitRoot))
274
285
  if (!existsSync(join(root, 'package.json')) || !existsSync(join(root, 'packages'))) {
275
286
  findings.push(finding('error', 'INVALID_HARNESS_ROOT', 'The configured Harness root is not a source checkout.', {
276
287
  evidence: root,
@@ -284,7 +295,16 @@ function resolveHarnessContext(home, explicitRoot, findings) {
284
295
 
285
296
  const sharedDsh = join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh')
286
297
  if (existsSync(sharedDsh)) {
287
- const canonical = realpathSync(sharedDsh)
298
+ let canonical
299
+ try {
300
+ canonical = realpathSync(sharedDsh)
301
+ } catch (error) {
302
+ findings.push(finding('warning', 'HARNESS_INSTALLATION_UNKNOWN', 'Could not resolve the DSH installation currently used by this home.', {
303
+ evidence: `${sharedDsh}: ${error instanceof Error ? error.message : String(error)}`,
304
+ suggestion: 'Repair the shared DSH installation or pass --harness-root for a source checkout.',
305
+ }))
306
+ return { root: undefined, packages: new Map(), version: undefined, authoritative: false }
307
+ }
288
308
  const root = findHarnessRoot(canonical)
289
309
  if (root !== undefined) {
290
310
  const manifest = readJson(join(root, 'package.json'), 'Harness root manifest', findings)
@@ -393,7 +413,10 @@ export function extractStaticRequires(source) {
393
413
  if (!code[match.index]) continue
394
414
  const specifier = match[2]
395
415
  if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || BUILTIN_MODULES.has(specifier)) continue
396
- if (packagePathParts(specifier) !== undefined || specifier.startsWith('@')) values.add(specifier)
416
+ const packageName = specifier.startsWith('@')
417
+ ? specifier.split('/').slice(0, 2).join('/')
418
+ : specifier.split('/')[0]
419
+ if (packagePathParts(packageName) !== undefined) values.add(specifier)
397
420
  }
398
421
  return [...values].sort()
399
422
  }
@@ -401,6 +424,8 @@ export function extractStaticRequires(source) {
401
424
  function codePositions(source) {
402
425
  const code = new Uint8Array(source.length)
403
426
  let state = 'code'
427
+ let regexClass = false
428
+ const templateExpressions = []
404
429
  for (let index = 0; index < source.length; index += 1) {
405
430
  const char = source[index]
406
431
  const next = source[index + 1]
@@ -411,8 +436,25 @@ function codePositions(source) {
411
436
  } else if (char === '/' && next === '*') {
412
437
  state = 'block-comment'
413
438
  index += 1
414
- } else if (char === "'" || char === '"' || char === '`') {
439
+ } else if (char === "'" || char === '"') {
415
440
  state = char
441
+ } else if (char === '`') {
442
+ state = 'template'
443
+ } else if (char === '/' && regexCanStart(source, index)) {
444
+ state = 'regex'
445
+ regexClass = false
446
+ } else if (templateExpressions.length > 0 && char === '{') {
447
+ templateExpressions[templateExpressions.length - 1] += 1
448
+ code[index] = 1
449
+ } else if (templateExpressions.length > 0 && char === '}') {
450
+ const last = templateExpressions.length - 1
451
+ templateExpressions[last] -= 1
452
+ if (templateExpressions[last] === 0) {
453
+ templateExpressions.pop()
454
+ state = 'template'
455
+ } else {
456
+ code[index] = 1
457
+ }
416
458
  } else {
417
459
  code[index] = 1
418
460
  }
@@ -426,6 +468,20 @@ function codePositions(source) {
426
468
  state = 'code'
427
469
  index += 1
428
470
  }
471
+ } else if (state === 'regex') {
472
+ if (char === '\\') index += 1
473
+ else if (char === '[') regexClass = true
474
+ else if (char === ']') regexClass = false
475
+ else if (char === '/' && !regexClass) state = 'code'
476
+ else if (char === '\n' || char === '\r') state = 'code'
477
+ } else if (state === 'template') {
478
+ if (char === '\\') index += 1
479
+ else if (char === '`') state = 'code'
480
+ else if (char === '$' && next === '{') {
481
+ templateExpressions.push(1)
482
+ state = 'code'
483
+ index += 1
484
+ }
429
485
  } else if (char === '\\') {
430
486
  index += 1
431
487
  } else if (char === state) {
@@ -435,6 +491,17 @@ function codePositions(source) {
435
491
  return code
436
492
  }
437
493
 
494
+ function regexCanStart(source, slashIndex) {
495
+ let index = slashIndex - 1
496
+ while (index >= 0 && /\s/.test(source[index])) index -= 1
497
+ if (index < 0) return true
498
+ if ('([{=,:;!&|?~+-*%^<>'.includes(source[index])) return true
499
+ if (!/[a-zA-Z0-9_$]/.test(source[index])) return false
500
+ const end = index + 1
501
+ while (index >= 0 && /[a-zA-Z0-9_$]/.test(source[index])) index -= 1
502
+ return /^(?:await|case|delete|do|else|in|instanceof|new|of|return|throw|typeof|void|yield)$/.test(source.slice(index + 1, end))
503
+ }
504
+
438
505
  function stripClientSuffix(specifier) {
439
506
  return specifier.endsWith('/client') ? specifier.slice(0, -'/client'.length) : specifier
440
507
  }
@@ -505,6 +572,7 @@ function inspectClientPackage(record, context) {
505
572
  package: name,
506
573
  evidence: record.file,
507
574
  suggestion: disableSuggestion,
575
+ repair: updateRepair(profile, name, commandRepair),
508
576
  }))
509
577
  return
510
578
  }
@@ -526,6 +594,8 @@ function inspectClientPackage(record, context) {
526
594
  findings.push(finding('error', 'CLIENT_BUNDLE_UNREADABLE', `${name} client bundle cannot be read.`, {
527
595
  package: name,
528
596
  evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
597
+ suggestion: disableSuggestion,
598
+ repair: updateRepair(profile, name, commandRepair),
529
599
  }))
530
600
  return
531
601
  }
@@ -1027,8 +1097,11 @@ function inspectCompatibility(record, context) {
1027
1097
  }
1028
1098
  }
1029
1099
  const mismatches = []
1100
+ let declaredPeers = 0
1101
+ let resolvedPeers = 0
1030
1102
  for (const [name, range] of peers) {
1031
1103
  if (!name.startsWith('@deepseek-ai/') && name !== 'cordis') continue
1104
+ declaredPeers += 1
1032
1105
  if (semver.validRange(range) === null) {
1033
1106
  findings.push(finding('warning', 'INVALID_HARNESS_PEER_RANGE', `${packageName} declares an invalid Harness peer range for ${name}.`, {
1034
1107
  package: packageName,
@@ -1041,6 +1114,7 @@ function inspectCompatibility(record, context) {
1041
1114
  if (supplier === undefined) continue
1042
1115
  const version = supplier.manifest?.version
1043
1116
  if (typeof version !== 'string' || semver.valid(version) === null) continue
1117
+ resolvedPeers += 1
1044
1118
  if (semver.satisfies(version, range, { includePrerelease: true })) continue
1045
1119
  mismatches.push({ name, required: range, active: version })
1046
1120
  }
@@ -1070,6 +1144,7 @@ function inspectCompatibility(record, context) {
1070
1144
  repair: updateRepair(profile, packageName, commandRepair),
1071
1145
  }))
1072
1146
  }
1147
+ return { declaredPeers, resolvedPeers }
1073
1148
  }
1074
1149
 
1075
1150
  function hasHarnessCompatibilityDeclaration(record) {
@@ -1078,28 +1153,31 @@ function hasHarnessCompatibilityDeclaration(record) {
1078
1153
  .some(name => name.startsWith('@deepseek-ai/') || name === 'cordis')
1079
1154
  }
1080
1155
 
1081
- function pluginCompatibility(record, findings) {
1156
+ function pluginCompatibility(record, findings, compatibilityCheck) {
1082
1157
  const name = record.requestedName ?? record.manifest.name
1083
1158
  const related = findings.filter(item => item.package === name)
1084
1159
  if (related.some(item => item.severity === 'error')) return 'incompatible'
1085
1160
  if (related.some(item => item.severity === 'warning')) return 'risk'
1086
1161
  if (!hasHarnessCompatibilityDeclaration(record)) return 'unknown'
1162
+ if (compatibilityCheck.resolvedPeers < compatibilityCheck.declaredPeers) return 'unknown'
1087
1163
  return 'compatible'
1088
1164
  }
1089
1165
 
1090
1166
  export function defaultDshHome(env = process.env) {
1091
1167
  const configured = env.DSH_HOME?.trim()
1092
1168
  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)
1169
+ return resolve(expandUserPath(configured))
1096
1170
  }
1097
1171
  return join(homedir(), '.dsh')
1098
1172
  }
1099
1173
 
1174
+ export function resolveDshHome(value, env = process.env) {
1175
+ return resolve(expandUserPath(value ?? defaultDshHome(env)))
1176
+ }
1177
+
1100
1178
  export function diagnose(options = {}) {
1101
1179
  const findings = []
1102
- const home = resolve(options.home ?? defaultDshHome(options.env))
1180
+ const home = resolveDshHome(options.home, options.env)
1103
1181
  const profile = options.profile ?? 'web'
1104
1182
  if (profile === '' || profile === '.' || profile === '..' || profile.includes('/') || profile.includes('\\')) {
1105
1183
  findings.push(finding('error', 'INVALID_PROFILE_NAME', `Invalid profile name ${JSON.stringify(profile)}.`))
@@ -1268,6 +1346,7 @@ export function diagnose(options = {}) {
1268
1346
  const thirdPartyRecords = dependencyNames
1269
1347
  .map(name => records.get(name))
1270
1348
  .filter(record => record !== undefined)
1349
+ const compatibilityChecks = new Map()
1271
1350
  for (const record of thirdPartyRecords) {
1272
1351
  inspectClientPackage(record, {
1273
1352
  findings,
@@ -1277,7 +1356,7 @@ export function diagnose(options = {}) {
1277
1356
  profile,
1278
1357
  resolvePackage,
1279
1358
  })
1280
- inspectCompatibility(record, {
1359
+ const check = inspectCompatibility(record, {
1281
1360
  findings,
1282
1361
  commandRepair,
1283
1362
  profile,
@@ -1285,6 +1364,7 @@ export function diagnose(options = {}) {
1285
1364
  harnessPackages: harness.packages,
1286
1365
  harnessPackagesAuthoritative: harness.authoritative,
1287
1366
  })
1367
+ compatibilityChecks.set(record.requestedName ?? record.manifest.name, check)
1288
1368
  inspectPluginNodeEngine(
1289
1369
  record,
1290
1370
  dshCli?.command?.[0] === process.execPath ? process.version : undefined,
@@ -1311,7 +1391,11 @@ export function diagnose(options = {}) {
1311
1391
  installed: true,
1312
1392
  client: record.manifest?.dsh?.client !== undefined,
1313
1393
  bundle: record.manifest?.dsh?.bundle !== undefined,
1314
- compatibility: pluginCompatibility(record, findings),
1394
+ compatibility: pluginCompatibility(
1395
+ record,
1396
+ findings,
1397
+ compatibilityChecks.get(record.requestedName ?? record.manifest.name),
1398
+ ),
1315
1399
  }
1316
1400
  }),
1317
1401
  }, findings)
@@ -1367,7 +1451,7 @@ export function formatReport(report, options = {}) {
1367
1451
  : zh ? '未找到(命令型修复已禁用)' : 'not found (command-based repairs disabled)'
1368
1452
  const lines = [
1369
1453
  'DSH Doctor',
1370
- `${zh ? 'Profile' : 'Profile'}: ${report.context.profile}`,
1454
+ `Profile: ${report.context.profile}`,
1371
1455
  `${zh ? 'DSH 主目录' : 'Home'}: ${report.context.home}`,
1372
1456
  `${zh ? '当前使用的 DSH' : 'Active DSH'}: ${report.context.harness.version ?? 'unknown'}${report.context.harness.root ? ` (${report.context.harness.root})` : ''}`,
1373
1457
  `${zh ? 'DSH CLI' : 'DSH CLI'}: ${cliText}`,
@@ -1400,7 +1484,7 @@ export function formatReport(report, options = {}) {
1400
1484
  .sort((left, right) => left.name.localeCompare(right.name))
1401
1485
 
1402
1486
  if (report.findings.length === 0 && unknownPackages.length === 0) {
1403
- lines.push(paint('info', zh ? '正常 当前检查范围内未发现问题。' : 'OK No problems found by the MVP checks.'))
1487
+ lines.push(paint('info', zh ? '正常 当前检查范围内未发现问题。' : 'OK No problems found by the current checks.'))
1404
1488
  } else {
1405
1489
  if (problemPackages.length > 0) {
1406
1490
  lines.push(zh
@@ -1459,8 +1543,8 @@ export function formatReport(report, options = {}) {
1459
1543
  for (const plugin of unknownPackages) {
1460
1544
  lines.push(`[${zh ? '未知' : 'UNKNOWN'}] ${plugin.name}${plugin.version ? ` ${plugin.version}` : ''}`)
1461
1545
  appendReportField(lines, zh ? '原因' : 'Reason', zh
1462
- ? '插件没有声明当前 DSH 的兼容范围。'
1463
- : 'The plugin does not declare a compatibility range for the active DSH.')
1546
+ ? '插件没有声明当前 DSH 的兼容范围,或 Doctor 无法解析声明所对应的当前版本。'
1547
+ : 'The plugin declares no range for the active DSH, or Doctor could not resolve the active version for a declared peer.')
1464
1548
  appendReportField(lines, zh ? '建议' : 'Action', zh
1465
1549
  ? '升级 DSH 后请关注该插件的发布说明或向插件作者确认。'
1466
1550
  : '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
  }