@bruc3van/dsh-doctor 0.1.1 → 0.1.3

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 ADDED
@@ -0,0 +1,140 @@
1
+ # DSH Doctor
2
+
3
+ [中文](README.md) | English
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.
6
+
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
+
9
+ ## Installation
10
+
11
+ Node.js `22.19+` or `24+` is required:
12
+
13
+ ```sh
14
+ npm install --global @bruc3van/dsh-doctor
15
+ dsh-doctor
16
+ ```
17
+
18
+ You can also run it without a global installation:
19
+
20
+ ```sh
21
+ npx @bruc3van/dsh-doctor
22
+ ```
23
+
24
+ By default, Doctor checks `$DSH_HOME/profiles/web`. If `DSH_HOME` is unset, it uses `~/.dsh`.
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.
27
+
28
+ ## How it works
29
+
30
+ A complete diagnosis and repair flow has four steps:
31
+
32
+ 1. `dsh-doctor` inspects the active DSH Home, profile, plugins, and Harness versions without making changes.
33
+ 2. Doctor reports evidence and recommendations by severity and plugin compatibility state.
34
+ 3. `dsh-doctor --fix` shows the exact file edits or DSH command plan and waits for confirmation.
35
+ 4. After applying confirmed repairs, Doctor runs the full diagnosis again and determines the exit code from the final state.
36
+
37
+ Doctor never loads inspected plugins and does not modify configuration during a normal diagnosis. Operations without one deterministic answer—such as guessing credentials, rewriting damaged YAML, or removing a plugin—remain recommendations only.
38
+
39
+ ## Output language
40
+
41
+ Text output supports English and Chinese. Doctor resolves the language in this order:
42
+
43
+ 1. `--lang zh|en`
44
+ 2. `DSH_DOCTOR_LANG`
45
+ 3. `locale.preference` in the active DSH Home's `settings.yaml`
46
+ 4. Terminal or system locale
47
+
48
+ ```sh
49
+ dsh-doctor --lang zh
50
+ dsh-doctor --lang en
51
+ DSH_DOCTOR_LANG=zh dsh-doctor
52
+ ```
53
+
54
+ `--json` always keeps stable English messages and diagnostic codes so language changes do not break automation.
55
+
56
+ ## Common commands
57
+
58
+ ```sh
59
+ # Read-only diagnosis
60
+ dsh-doctor
61
+ dsh-doctor --profile web
62
+ dsh-doctor --home /path/to/.dsh
63
+ dsh-doctor --dsh-command /path/to/@deepseek-ai/dsh/lib/bin.js
64
+
65
+ # Machine-readable read-only report with no prompts
66
+ dsh-doctor --json
67
+
68
+ # Show a repair plan, apply it after confirmation, and diagnose again
69
+ dsh-doctor --fix
70
+
71
+ # Explicitly confirm the current plan in automation
72
+ dsh-doctor --fix --yes --json
73
+ ```
74
+
75
+ `--repair` is an alias for `--fix`. `--yes` is valid only together with `--fix`.
76
+
77
+ ## Plugin compatibility after a DSH upgrade
78
+
79
+ After DSH is updated, Doctor assigns every direct profile plugin one explicit state and summarizes the result in both text and JSON reports:
80
+
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
+ - `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.
84
+ - `compatible`: The declared compatibility ranges accept the active Harness and no plugin-related errors or warnings were found.
85
+
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`.
87
+
88
+ ## Current checks
89
+
90
+ - JSON root structure, dependency maps, bundle lists, and reload lifecycle in the profile `package.json`
91
+ - Syntax and top-level structure of profile, home, and bundle `cordis.patch.yml` files, including `!!js` expressions
92
+ - Safe structural checks for `settings.yaml` and `.credentials.yaml`; credential diagnostics never expose secret values
93
+ - Presence of profile dependencies, bundle declarations, patch files, and client bundles
94
+ - Consistency among the profile `package.json`, the `pnpm-lock.yaml` importer, and installed versions
95
+ - Node.js `engines`, Harness peer ranges, and obsolete DSH dependencies for all direct plugins, including bundle-only and server-side plugins
96
+ - Version drift and stale top-level `@deepseek-ai/dsh-*` packages across the active DSH CLI, Harness workspace, and profile
97
+ - The `platform`, `immediately`, `inject`, `external`, and `./client` export contract for `dsh.client`
98
+ - Consistency between literal `require()` calls in client bundles and external or module suppliers
99
+ - References to removed Harness client packages
100
+ - Third-party plugin peer ranges against actual active Harness versions
101
+ - Real resolution precedence where the Harness installation wins over a profile-local bundle with the same name
102
+ - Static composition of bundle, profile, and home patches in official Harness order, including missing targets, invalid group inserts, and name assertions, without loading plugins
103
+
104
+ ## Repair safety
105
+
106
+ Every executable repair has a stable ID, risk level, description, and exact target:
107
+
108
+ - File repairs show their paths before confirmation and verify the SHA-256 fingerprint again before writing.
109
+ - Doctor creates a `.dsh-doctor-<timestamp>.bak` backup before replacing a file atomically through a temporary file in the same directory.
110
+ - External commands use fixed argument arrays and never construct shell commands.
111
+ - `--json --fix --yes` captures subprocess output in the repair result so stdout remains exactly one valid JSON document.
112
+ - Command repairs bind the diagnosed `DSH_HOME` and show the resolved CLI path instead of assuming `dsh` exists on PATH.
113
+ - A failed repair stops later actions and preserves backups already created.
114
+ - Doctor runs every diagnostic again after repairs and uses the final state for its exit code.
115
+
116
+ The initial release automatically performs only deterministic operations, such as restoring an installed bundle to the manifest list or running an exact profile install or update command. Damaged JSON or YAML, credential values, and plugin removal remain recommendations because Doctor cannot safely guess the intended result.
117
+
118
+ ## Exit codes
119
+
120
+ - `0`: No blocking errors were found; warnings may still be present
121
+ - `1`: Doctor found a problem that may prevent Harness from starting
122
+ - `2`: Invalid arguments, an operational failure, or a failed repair
123
+
124
+ ## Current limitations
125
+
126
+ - Static scanning recognizes only literal `require("package")` calls. Dynamic dependencies require a future bundle metadata contract.
127
+ - 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
+ - Lockfile checks deterministically cross-check the direct profile importer only; they do not recursively scan the complete npm dependency graph.
130
+ - 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
+ ## Development
133
+
134
+ ```sh
135
+ npm install
136
+ npm run check
137
+ node src/cli.mjs --help
138
+ ```
139
+
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.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # DSH Doctor
2
2
 
3
+ 中文 | [English](README.en.md)
4
+
3
5
  DSH Doctor 用来定位 DeepSeek Harness 无法启动、配置损坏和第三方插件不兼容等常见问题。默认运行完全只读;只有显式使用 `--fix`,查看精确的文件或命令计划并确认后,它才会实施可回滚的修复。
4
6
 
5
7
  这是社区维护的第三方工具,不属于 DeepSeek 官方项目。它不会加载或执行待检查插件的代码。
@@ -23,6 +25,17 @@ npx @bruc3van/dsh-doctor
23
25
 
24
26
  Doctor 不要求 `dsh` 必须是全局命令。它会按顺序查找 PATH、当前项目安装、profile 共享安装或 npx 缓存留下的链接、已构建的 Harness 源码工作区。DSH Desktop 内置运行时或其他特殊安装可以通过 `--dsh-command /path/to/dsh`(也接受官方包的 `lib/bin.js`)明确指定。找不到 CLI 时仍会完成只读诊断,但不会提供或执行无法验证的命令型修复。
25
27
 
28
+ ## 工作方式
29
+
30
+ 一次完整流程分为四步:
31
+
32
+ 1. `dsh-doctor` 只读检查当前 DSH Home、profile、插件和 Harness 版本。
33
+ 2. Doctor 按错误、警告和插件兼容状态展示证据与建议。
34
+ 3. `dsh-doctor --fix` 先展示精确的文件修改或 DSH 命令计划,并等待用户确认。
35
+ 4. 修复完成后自动重新诊断,以最终状态决定退出码。
36
+
37
+ Doctor 不会加载待检查插件,也不会在普通诊断时修改配置。无法确定正确结果的操作,例如猜测凭据、重写损坏 YAML 或直接移除插件,只会给出建议。
38
+
26
39
  ## 输出语言
27
40
 
28
41
  文本输出支持中文和英文。默认依次读取:
@@ -40,6 +53,17 @@ DSH_DOCTOR_LANG=zh dsh-doctor
40
53
 
41
54
  `--json` 始终保留稳定的英文消息与诊断 code,避免语言变化破坏脚本。
42
55
 
56
+ ## DSH 升级后的插件兼容性
57
+
58
+ DSH 更新后,Doctor 会把每个 profile 插件归入一个明确状态,并在文本与 JSON 报告中汇总:
59
+
60
+ - `incompatible`:已经发现会阻断插件加载或 Harness 启动的错误,例如插件未安装,或注入了已删除的 client runtime。
61
+ - `risk`:发现当前版本风险,例如 Harness peer range 不接受新版本、仍依赖已删除的 DSH 包、Node.js 不兼容,或安装版本发生漂移。
62
+ - `unknown`:插件没有通过 `peerDependencies` 声明 Harness 兼容范围;Doctor 无法证明它支持升级后的 DSH,但不会把未知误报成故障。
63
+ - `compatible`:插件声明的兼容范围接受当前 Harness,且没有发现插件相关错误或警告。
64
+
65
+ 兼容性检查覆盖所有 profile 直接插件,不再只检查带 `dsh.client` 的前端插件;纯 bundle 或服务端插件引用旧 Harness API 也会被报告。建议 DSH 升级后先运行一次 `dsh-doctor`,再根据精确的 update 建议决定是否执行 `dsh-doctor --fix`。
66
+
43
67
  ## 常用命令
44
68
 
45
69
  ```sh
@@ -67,11 +91,15 @@ dsh-doctor --fix --yes --json
67
91
  - profile、home 和 bundle 的 `cordis.patch.yml` 语法与顶层结构,包括 `!!js` 表达式
68
92
  - `settings.yaml` 和 `.credentials.yaml` 的安全结构检查;凭据诊断不输出秘密值
69
93
  - profile 依赖、bundle 声明、patch 文件和 client bundle 是否存在
94
+ - profile `package.json`、`pnpm-lock.yaml` importer 与实际安装版本是否一致
95
+ - 所有直接插件(包括纯 bundle/服务端插件)的 Node.js `engines`、Harness peer range、旧 DSH 依赖与当前运行时是否兼容
96
+ - 当前 DSH CLI、Harness 工作区和 profile 顶层 `@deepseek-ai/dsh-*` 包是否发生版本漂移或残留
70
97
  - `dsh.client` 的 `platform`、`immediately`、`inject`、`external` 和 `./client` export contract
71
98
  - client bundle 中字面量 `require()` 与 external/module supplier 的一致性
72
99
  - 已删除的 Harness client package 引用
73
100
  - 第三方插件 peer range 与当前 Harness 实际版本的兼容性
74
101
  - Harness installation 优先于 profile 同名 bundle 的真实解析顺序
102
+ - 按 Harness 官方层级顺序静态组合 bundle、profile 和 home patch,检查缺失 target、错误 group insert 与 name assertion;不会加载插件
75
103
 
76
104
  ## 修复安全边界
77
105
 
@@ -80,6 +108,7 @@ dsh-doctor --fix --yes --json
80
108
  - 文件修复在确认前展示路径,确认后再次校验 SHA-256 指纹。
81
109
  - 写入前创建 `.dsh-doctor-<timestamp>.bak` 备份,再通过同目录临时文件原子替换。
82
110
  - 外部命令使用固定 argv 调用,不拼接 shell 命令。
111
+ - `--json --fix --yes` 会捕获子命令输出并放入修复结果,保证 stdout 始终只有一个合法 JSON 文档。
83
112
  - 命令修复绑定当前诊断的 `DSH_HOME`,并展示解析出的真实 CLI 路径;不会假定 PATH 中存在 `dsh`。
84
113
  - 任一步失败即停止后续修复,并保留已经创建的备份。
85
114
  - 完成后重新运行全部诊断,以最终状态决定退出码。
@@ -95,9 +124,10 @@ dsh-doctor --fix --yes --json
95
124
  ## 当前限制
96
125
 
97
126
  - 静态扫描只识别代码中的字面量 `require("package")`;动态依赖需要未来的 bundle 元数据协议。
98
- - 配置检查覆盖语法和 Doctor 能稳定对齐的结构,但不执行 `!!js`,也不启动第三方插件。
127
+ - 配置检查覆盖语法和 Doctor 能稳定对齐的结构,并按当前 Harness patch 算法做无执行组合检查;不会求值 `!!js`,也不会加载第三方插件。
99
128
  - 版本兼容以插件 `peerDependencies` 和当前可解析 Harness package 版本为依据;未声明兼容范围的插件只能做结构检查。
100
- - 真实启动探针尚未默认启用,因为启动第三方插件可能产生网络、进程或持久化副作用。优先使用只读配置组合检查。
129
+ - lockfile 检查只对 profile 的直接依赖 importer 做确定性交叉验证,不递归扫描整个 npm 依赖树。
130
+ - 真实启动探针尚未启用;即使复制 `DSH_HOME`,第三方插件仍可能访问网络、绝对路径或启动外部进程,不能宣称无副作用。
101
131
 
102
132
  ## 从源码开发
103
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bruc3van/dsh-doctor",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Diagnostics and confirmed recovery for DeepSeek Harness profiles and third-party plugins",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "src",
20
20
  "README.md",
21
+ "README.en.md",
21
22
  "LICENSE"
22
23
  ],
23
24
  "scripts": {
package/src/cli.mjs CHANGED
@@ -107,16 +107,18 @@ async function main() {
107
107
  }
108
108
  if (actions.length > 0) {
109
109
  let confirmed = options.yes === true
110
- if (!confirmed) {
110
+ const plan = formatRepairPlan(actions, { language, prompt: !confirmed })
111
+ if (confirmed) {
112
+ process.stderr.write(`${plan}\n`)
113
+ } else {
111
114
  if (!process.stdin.isTTY) throw new Error('--fix needs an interactive terminal or explicit --yes')
112
- const prompt = formatRepairPlan(actions, { language })
113
115
  const reader = createInterface({ input: process.stdin, output: process.stderr })
114
- const answer = await reader.question(prompt)
116
+ const answer = await reader.question(plan)
115
117
  reader.close()
116
118
  confirmed = /^(?:y(?:es)?|是|确认)$/i.test(answer.trim())
117
119
  }
118
120
  if (confirmed) {
119
- repairs = applyRepairs(actions)
121
+ repairs = applyRepairs(actions, { captureOutput: options.json })
120
122
  if (repairs.every(item => item.status === 'applied')) report = diagnose(options)
121
123
  }
122
124
  }
package/src/doctor.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { accessSync, constants, existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
2
+ import { builtinModules } from 'node:module'
2
3
  import { homedir } from 'node:os'
3
4
  import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
4
5
  import yaml from 'js-yaml'
@@ -16,14 +17,16 @@ export const PLATFORM_MODULES = new Set([
16
17
  '@deepseek-ai/dsh-client-ui-slots',
17
18
  '@deepseek-ai/dsh-client-ui-primitives',
18
19
  ])
20
+ const BUILTIN_MODULES = new Set(builtinModules.map(name => name.replace(/^node:/, '')))
19
21
 
20
22
  const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 }
21
23
  const JS_EXPRESSION = new yaml.Type('tag:yaml.org,2002:js', {
22
24
  kind: 'scalar',
23
- resolve: value => typeof value === 'string' && value.trim().length > 0,
24
- construct: value => value,
25
+ resolve: value => typeof value === 'string',
26
+ construct: value => ({ __jsExpr: value }),
25
27
  })
26
- const PATCH_SCHEMA = yaml.DEFAULT_SCHEMA.extend([JS_EXPRESSION])
28
+ // Keep this dialect aligned with Harness entryListSchema: JSON values plus !!js.
29
+ const PATCH_SCHEMA = yaml.JSON_SCHEMA.extend([JS_EXPRESSION])
27
30
 
28
31
  function objectRecord(value) {
29
32
  return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined
@@ -71,6 +74,7 @@ function readJson(file, subject, findings) {
71
74
  }
72
75
 
73
76
  function packagePathParts(name) {
77
+ if (typeof name !== 'string') return undefined
74
78
  if (name.startsWith('@')) {
75
79
  const parts = name.split('/')
76
80
  return parts.length === 2
@@ -94,7 +98,7 @@ function packageManifestAt(directory, expectedName, findings, source) {
94
98
  suggestion: 'Reinstall the dependency so its directory and package name agree.',
95
99
  }))
96
100
  }
97
- return { directory, file, manifest }
101
+ return { directory, file, manifest, requestedName: expectedName }
98
102
  }
99
103
 
100
104
  function findHarnessRoot(start) {
@@ -118,6 +122,14 @@ function executable(file) {
118
122
  }
119
123
  }
120
124
 
125
+ function regularFile(file) {
126
+ try {
127
+ return statSync(file).isFile()
128
+ } catch {
129
+ return false
130
+ }
131
+ }
132
+
121
133
  function executableOnPath(name, env) {
122
134
  const path = env.PATH ?? env.Path ?? env.path ?? ''
123
135
  const extensions = process.platform === 'win32'
@@ -168,10 +180,10 @@ function commandFromValue(value, env, cwd) {
168
180
  : value
169
181
  const looksLikePath = isAbsolute(expanded) || expanded.includes('/') || expanded.includes('\\') || expanded.startsWith('.')
170
182
  const file = looksLikePath ? resolve(cwd, expanded) : executableOnPath(expanded, env)
171
- if (file === undefined || !existsSync(file)) return undefined
172
- return /\.(?:c?js|mjs)$/i.test(file)
173
- ? { command: [process.execPath, file], path: file }
174
- : { command: [file], path: file }
183
+ if (file === undefined) return undefined
184
+ if (!regularFile(file)) return undefined
185
+ if (/\.(?:c?js|mjs)$/i.test(file)) return { command: [process.execPath, file], path: file }
186
+ return executable(file) ? { command: [file], path: file } : undefined
175
187
  }
176
188
 
177
189
  function resolveDshCli(options, harness, home) {
@@ -242,7 +254,14 @@ function indexWorkspace(root, findings) {
242
254
  for (const directory of workspacePackageDirectories(root)) {
243
255
  const file = join(directory, 'package.json')
244
256
  if (!existsSync(file)) continue
245
- const manifest = readJson(file, 'Harness workspace package manifest', findings)
257
+ const manifestFindings = []
258
+ const manifest = readJson(file, 'Harness workspace package manifest', manifestFindings)
259
+ for (const item of manifestFindings) {
260
+ findings.push(finding('warning', 'INVALID_WORKSPACE_MANIFEST', 'A Harness workspace package manifest was ignored because it is invalid.', {
261
+ evidence: item.evidence ?? file,
262
+ suggestion: 'Repair this workspace package manifest to include it in compatibility checks.',
263
+ }))
264
+ }
246
265
  if (typeof manifest?.name === 'string') packages.set(manifest.name, { directory, file, manifest })
247
266
  }
248
267
  return packages
@@ -256,10 +275,10 @@ function resolveHarnessContext(home, explicitRoot, findings) {
256
275
  evidence: root,
257
276
  suggestion: 'Pass the DeepSeek Harness repository root to --harness-root.',
258
277
  }))
259
- return { root, packages: new Map(), version: undefined, authoritative: true }
278
+ return { root, packages: new Map(), version: undefined, authoritative: false }
260
279
  }
261
280
  const manifest = readJson(join(root, 'package.json'), 'Harness root manifest', findings)
262
- return { root, packages: indexWorkspace(root, findings), version: manifest?.version, authoritative: true }
281
+ return { root, packages: indexWorkspace(root, findings), version: manifest?.version, authoritative: manifest !== undefined }
263
282
  }
264
283
 
265
284
  const sharedDsh = join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh')
@@ -301,8 +320,9 @@ function packageResolver(profileDir, home, harnessPackages, findings) {
301
320
  return resolved
302
321
  }
303
322
  const workspace = harnessPackages.get(name)
304
- cache.set(name, workspace)
305
- return workspace
323
+ const resolved = workspace === undefined ? undefined : { ...workspace, requestedName: name }
324
+ cache.set(name, resolved)
325
+ return resolved
306
326
  }
307
327
  }
308
328
 
@@ -332,11 +352,12 @@ function clientExport(manifest) {
332
352
  return undefined
333
353
  }
334
354
 
335
- function dependencyEntries(value, field, file, findings) {
355
+ function dependencyEntries(value, field, file, findings, packageName) {
336
356
  if (value === undefined) return []
337
357
  const record = objectRecord(value)
338
358
  if (record === undefined || Object.values(record).some(item => typeof item !== 'string')) {
339
359
  findings.push(finding('error', 'INVALID_DEPENDENCY_MAP', `${field} must be an object of package names to string ranges.`, {
360
+ package: packageName,
340
361
  evidence: file,
341
362
  suggestion: `Repair ${field} before managing or starting this profile.`,
342
363
  }))
@@ -346,6 +367,7 @@ function dependencyEntries(value, field, file, findings) {
346
367
  }
347
368
 
348
369
  function updateRepair(profile, name, commandRepair) {
370
+ if (packagePathParts(name) === undefined) return undefined
349
371
  return commandRepair(
350
372
  `update-package:${name}`,
351
373
  `Update ${name} in profile ${profile}.`,
@@ -365,11 +387,11 @@ function safePackageFile(packageDir, exported) {
365
387
  export function extractStaticRequires(source) {
366
388
  const values = new Set()
367
389
  const code = codePositions(source)
368
- const pattern = /\brequire\s*\(\s*(['"])([^'"\\\r\n]+)\1\s*\)/g
390
+ const pattern = /(?<![\w$.])require\s*\(\s*(['"])([^'"\\\r\n]+)\1\s*\)/g
369
391
  for (const match of source.matchAll(pattern)) {
370
392
  if (!code[match.index]) continue
371
393
  const specifier = match[2]
372
- if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:')) continue
394
+ if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || BUILTIN_MODULES.has(specifier)) continue
373
395
  if (packagePathParts(specifier) !== undefined || specifier.startsWith('@')) values.add(specifier)
374
396
  }
375
397
  return [...values].sort()
@@ -424,7 +446,7 @@ function inspectClientPackage(record, context) {
424
446
  const {
425
447
  commandRepair, findings, harnessPackages, harnessPackagesAuthoritative, profile, resolvePackage,
426
448
  } = context
427
- const name = record.manifest.name
449
+ const name = record.requestedName ?? record.manifest.name
428
450
  const disableSuggestion = `Upgrade ${name}; if no compatible release exists, remove it through the same DSH installation.`
429
451
  const declaration = record.manifest?.dsh?.client
430
452
  if (declaration === undefined) return
@@ -486,7 +508,7 @@ function inspectClientPackage(record, context) {
486
508
  return
487
509
  }
488
510
  const file = safePackageFile(record.directory, exported)
489
- if (file === undefined || !existsSync(file) || !statSync(file).isFile()) {
511
+ if (file === undefined || !regularFile(file)) {
490
512
  findings.push(finding('error', 'CLIENT_BUNDLE_MISSING', `${name} client bundle is missing.`, {
491
513
  package: name,
492
514
  evidence: file ?? `${record.file}: exports["./client"] = ${JSON.stringify(exported)}`,
@@ -556,16 +578,6 @@ function inspectClientPackage(record, context) {
556
578
  }
557
579
  }
558
580
 
559
- const legacyPeers = Object.keys(record.manifest.peerDependencies ?? {})
560
- .filter(peer => peer.startsWith('@deepseek-ai/dsh-') && !harnessPackages.has(peer))
561
- .sort()
562
- if (legacyPeers.length > 0) {
563
- findings.push(finding('warning', 'LEGACY_HARNESS_PEERS', `${name} still declares Harness packages that no longer exist in the active source tree.`, {
564
- package: name,
565
- evidence: legacyPeers.join(', '),
566
- suggestion: 'Treat this plugin as compatibility-risky and update it before the next Harness upgrade.',
567
- }))
568
- }
569
581
  }
570
582
  }
571
583
 
@@ -575,7 +587,7 @@ function inspectBundle(name, record, findings) {
575
587
  package: name,
576
588
  suggestion: 'Install the profile dependencies with the active DSH installation, upgrade the bundle, or remove it from the profile.',
577
589
  }))
578
- return
590
+ return undefined
579
591
  }
580
592
  const patch = record.manifest?.dsh?.bundle?.patch
581
593
  if (typeof patch !== 'string' || patch.length === 0) {
@@ -584,34 +596,144 @@ function inspectBundle(name, record, findings) {
584
596
  evidence: record.file,
585
597
  suggestion: 'Upgrade or remove this bundle from dsh.profile.bundles.',
586
598
  }))
587
- return
599
+ return undefined
588
600
  }
589
601
  const file = safePackageFile(record.directory, patch)
590
- if (file === undefined || !existsSync(file) || !statSync(file).isFile()) {
602
+ if (file === undefined || !regularFile(file)) {
591
603
  findings.push(finding('error', 'BUNDLE_PATCH_MISSING', `${name} bundle patch is missing.`, {
592
604
  package: name,
593
605
  evidence: file ?? `${record.file}: dsh.bundle.patch = ${JSON.stringify(patch)}`,
594
606
  suggestion: 'Reinstall or upgrade this bundle, or remove it from the profile.',
595
607
  }))
596
- } else inspectPatchFile(file, `${name} bundle patch`, findings)
608
+ return undefined
609
+ }
610
+ const patches = inspectPatchFile(file, `${name} bundle patch`, findings, name)
611
+ return patches === undefined ? undefined : { label: name, file, patches, package: name }
597
612
  }
598
613
 
599
- function inspectPatchFile(file, subject, findings) {
614
+ function inspectPatchFile(file, subject, findings, packageName) {
600
615
  let parsed
601
616
  try {
602
617
  parsed = yaml.load(readFileSync(file, 'utf8'), { schema: PATCH_SCHEMA })
603
618
  } catch (error) {
604
619
  findings.push(finding('error', 'INVALID_PATCH_YAML', `${subject} cannot be parsed.`, {
620
+ package: packageName,
605
621
  evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
606
622
  suggestion: 'Repair the YAML syntax before starting this profile.',
607
623
  }))
608
- return
624
+ return undefined
609
625
  }
610
626
  if (!Array.isArray(parsed) || parsed.some(item => objectRecord(item) === undefined)) {
611
627
  findings.push(finding('error', 'INVALID_PATCH_LIST', `${subject} must be a top-level YAML array of mappings.`, {
628
+ package: packageName,
612
629
  evidence: file,
613
630
  suggestion: 'Repair the patch structure before starting this profile.',
614
631
  }))
632
+ return undefined
633
+ }
634
+ let valid = true
635
+ parsed.forEach((patch, index) => {
636
+ if (patch.id !== undefined && typeof patch.id !== 'string') {
637
+ valid = false
638
+ findings.push(finding('error', 'INVALID_PATCH_ID', `${subject} entry ${String(index + 1)} has a non-string id.`, {
639
+ package: packageName,
640
+ evidence: file,
641
+ suggestion: 'Use a string row id or omit id for a root insert patch.',
642
+ }))
643
+ }
644
+ if (patch.name !== undefined && typeof patch.name !== 'string') {
645
+ valid = false
646
+ findings.push(finding('error', 'INVALID_PATCH_NAME', `${subject} entry ${String(index + 1)} has a non-string name assertion.`, {
647
+ package: packageName,
648
+ evidence: file,
649
+ suggestion: 'Use a string plugin name assertion or omit the name field.',
650
+ }))
651
+ }
652
+ if (patch.insert !== undefined
653
+ && (!Array.isArray(patch.insert) || patch.insert.some(item => objectRecord(item) === undefined))) {
654
+ valid = false
655
+ findings.push(finding('error', 'INVALID_PATCH_INSERT', `${subject} entry ${String(index + 1)} insert must be an array of mappings.`, {
656
+ package: packageName,
657
+ evidence: file,
658
+ suggestion: 'Repair the insert list before starting this profile.',
659
+ }))
660
+ }
661
+ })
662
+ return valid ? parsed : undefined
663
+ }
664
+
665
+ // Mirrors the current Harness applyEntryPatches control flow without importing
666
+ // code from (or executing code inside) the installation being diagnosed.
667
+ function inspectPatchComposition(layers, findings) {
668
+ const entryMap = new Map()
669
+ const indexEntries = (values) => {
670
+ for (const entry of values) {
671
+ if (typeof entry.id === 'string' && entry.id.length > 0) entryMap.set(entry.id, entry)
672
+ if (entry.group && Array.isArray(entry.config)) indexEntries(entry.config)
673
+ }
674
+ }
675
+ for (const layer of layers) {
676
+ layer.patches.forEach((patch, index) => {
677
+ const evidence = `${layer.file}: entry ${String(index + 1)}`
678
+ const hasInsert = patch.insert !== undefined
679
+ if (hasInsert) {
680
+ if (!Array.isArray(patch.insert)) return
681
+ if (patch.id !== undefined) {
682
+ const target = entryMap.get(patch.id)
683
+ if (target === undefined) {
684
+ findings.push(finding('warning', 'PATCH_TARGET_NOT_FOUND', `${layer.label} insert targets missing row ${patch.id}.`, {
685
+ package: layer.package,
686
+ evidence,
687
+ suggestion: 'Check whether this overlay is intended for the selected profile and bundle order.',
688
+ }))
689
+ return
690
+ }
691
+ if (!target.group) {
692
+ findings.push(finding('warning', 'PATCH_TARGET_NOT_GROUP', `${layer.label} inserts into row ${patch.id}, which is not a group.`, {
693
+ package: layer.package,
694
+ evidence,
695
+ suggestion: 'Target a group row or use a root insert.',
696
+ }))
697
+ return
698
+ }
699
+ if (!Array.isArray(target.config)) target.config = []
700
+ target.config.push(...structuredClone(patch.insert))
701
+ indexEntries(target.config.slice(-patch.insert.length))
702
+ } else {
703
+ const inserted = structuredClone(patch.insert)
704
+ indexEntries(inserted)
705
+ }
706
+ return
707
+ }
708
+ if (patch.id === undefined) {
709
+ findings.push(finding('warning', 'PATCH_ID_REQUIRED', `${layer.label} has a non-insert patch without an id.`, {
710
+ package: layer.package,
711
+ evidence,
712
+ suggestion: 'Add the target row id or turn the entry into an insert patch.',
713
+ }))
714
+ return
715
+ }
716
+ const target = entryMap.get(patch.id)
717
+ if (target === undefined) {
718
+ findings.push(finding('warning', 'PATCH_TARGET_NOT_FOUND', `${layer.label} targets missing row ${patch.id}.`, {
719
+ package: layer.package,
720
+ evidence,
721
+ suggestion: 'Check whether this overlay is intended for the selected profile and bundle order.',
722
+ }))
723
+ return
724
+ }
725
+ if (patch.name !== undefined && patch.name !== target.name) {
726
+ findings.push(finding('warning', 'PATCH_NAME_MISMATCH', `${layer.label} name assertion does not match row ${patch.id}.`, {
727
+ package: layer.package,
728
+ evidence: `${evidence}: expected ${JSON.stringify(target.name)}, got ${JSON.stringify(patch.name)}`,
729
+ suggestion: 'Update the assertion or target the intended row.',
730
+ }))
731
+ return
732
+ }
733
+ for (const [key, value] of Object.entries(patch)) {
734
+ if (key !== 'id' && key !== 'insert' && key !== 'name') target[key] = structuredClone(value)
735
+ }
736
+ })
615
737
  }
616
738
  }
617
739
 
@@ -682,35 +804,273 @@ function inspectCredentials(file, findings) {
682
804
  }
683
805
 
684
806
  function inspectPatchFileIfPresent(file, subject, findings) {
685
- if (existsSync(file)) inspectPatchFile(file, subject, findings)
807
+ return existsSync(file) ? inspectPatchFile(file, subject, findings) : undefined
808
+ }
809
+
810
+ function looksLikeSemverRange(value) {
811
+ return /^(?:\s*[v=~^<>*]|\s*\d)/.test(value)
812
+ }
813
+
814
+ function inspectPluginNodeEngine(record, nodeVersion, findings) {
815
+ const range = record.manifest?.engines?.node
816
+ if (range === undefined) return
817
+ const name = record.requestedName ?? record.manifest.name
818
+ if (typeof range !== 'string' || semver.validRange(range) === null) {
819
+ findings.push(finding('warning', 'INVALID_NODE_ENGINE_RANGE', `${name} declares an invalid Node.js engine range.`, {
820
+ package: name,
821
+ evidence: `${record.file}: engines.node = ${JSON.stringify(range)}`,
822
+ suggestion: 'The plugin author should publish a valid engines.node range.',
823
+ }))
824
+ return
825
+ }
826
+ if (nodeVersion === undefined) return
827
+ if (!semver.satisfies(nodeVersion, range, { includePrerelease: true })) {
828
+ findings.push(finding('warning', 'PLUGIN_NODE_VERSION_MISMATCH', `${name} does not support the Node.js version used by the active DSH CLI.`, {
829
+ package: name,
830
+ evidence: `engines.node ${range} (active ${nodeVersion})`,
831
+ suggestion: `Update ${name} or run DSH with a supported Node.js version.`,
832
+ }))
833
+ }
834
+ }
835
+
836
+ function lockedRegistryVersion(value) {
837
+ if (typeof value !== 'string') return undefined
838
+ const matched = value.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\(|$)/)?.[1]
839
+ return matched !== undefined && semver.valid(matched) !== null ? matched : undefined
840
+ }
841
+
842
+ function inspectPnpmLock(profileDir, dependencyEntriesList, records, findings, commandRepair, profile) {
843
+ const file = join(profileDir, 'pnpm-lock.yaml')
844
+ const installRepair = () => commandRepair(
845
+ `install-profile:${profile}`,
846
+ `Install the declared dependencies for profile ${profile}.`,
847
+ ['plugin', '--profile', profile, 'install'],
848
+ { profile },
849
+ )
850
+ if (!existsSync(file)) return { file, present: false }
851
+ let root
852
+ try {
853
+ const document = parseDocument(readFileSync(file, 'utf8'), { prettyErrors: true, uniqueKeys: true })
854
+ if (document.errors.length > 0) throw new Error(document.errors.map(error => error.message).join('; '))
855
+ root = document.toJS()
856
+ } catch (error) {
857
+ findings.push(finding('error', 'INVALID_PNPM_LOCKFILE', 'The profile pnpm lockfile cannot be parsed.', {
858
+ evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
859
+ suggestion: 'Run the exact profile install command after repairing or regenerating the lockfile.',
860
+ repair: commandRepair(
861
+ `install-profile:${profile}`,
862
+ `Install the declared dependencies for profile ${profile}.`,
863
+ ['plugin', '--profile', profile, 'install'],
864
+ { profile },
865
+ ),
866
+ }))
867
+ return { file, present: true, valid: false }
868
+ }
869
+ const importer = objectRecord(objectRecord(objectRecord(root)?.importers)?.['.'])
870
+ const locked = importer?.dependencies
871
+ const lockedDependencies = objectRecord(locked)
872
+ if (lockedDependencies === undefined) {
873
+ if (dependencyEntriesList.length === 0 && locked === undefined) {
874
+ return { file, present: true, valid: true }
875
+ }
876
+ findings.push(finding('warning', 'PNPM_LOCKFILE_IMPORTER_MISSING', 'The profile pnpm lockfile has no usable root dependencies map.', {
877
+ evidence: file,
878
+ suggestion: 'Use the exact profile install command to reconcile the lockfile.',
879
+ repair: installRepair(),
880
+ }))
881
+ return { file, present: true, valid: false }
882
+ }
883
+ const declaredNames = new Set(dependencyEntriesList.map(([name]) => name))
884
+ for (const [name, declared] of dependencyEntriesList) {
885
+ const entry = lockedDependencies[name]
886
+ if (entry === undefined) {
887
+ findings.push(finding('warning', 'LOCKFILE_DEPENDENCY_MISSING', `Profile dependency ${name} is absent from the pnpm lockfile importer.`, {
888
+ package: name,
889
+ evidence: file,
890
+ suggestion: 'Use the exact profile install command to reconcile the manifest and lockfile.',
891
+ repair: installRepair(),
892
+ }))
893
+ continue
894
+ }
895
+ const lockedEntry = typeof entry === 'string' ? { version: entry } : objectRecord(entry)
896
+ const specifier = lockedEntry?.specifier
897
+ if (typeof specifier === 'string' && specifier !== declared) {
898
+ findings.push(finding('warning', 'LOCKFILE_SPECIFIER_MISMATCH', `Profile dependency ${name} has a different specifier in pnpm-lock.yaml.`, {
899
+ package: name,
900
+ evidence: `package.json ${declared} (lockfile ${specifier})`,
901
+ suggestion: 'Use the exact profile install command to reconcile the manifest and lockfile.',
902
+ repair: installRepair(),
903
+ }))
904
+ }
905
+ const lockedVersion = lockedRegistryVersion(lockedEntry?.version)
906
+ const installedVersion = records.get(name)?.manifest?.version
907
+ if (lockedVersion !== undefined && typeof installedVersion === 'string'
908
+ && semver.valid(installedVersion) !== null && installedVersion !== lockedVersion) {
909
+ findings.push(finding('warning', 'LOCKFILE_INSTALLED_VERSION_MISMATCH', `Profile dependency ${name} does not match its locked version.`, {
910
+ package: name,
911
+ evidence: `locked ${lockedVersion} (installed ${installedVersion})`,
912
+ suggestion: 'Use the exact profile install command to restore the locked installation.',
913
+ repair: installRepair(),
914
+ }))
915
+ }
916
+ }
917
+ for (const name of Object.keys(lockedDependencies)) {
918
+ if (declaredNames.has(name)) continue
919
+ findings.push(finding('warning', 'LOCKFILE_DEPENDENCY_STALE', `pnpm-lock.yaml still lists undeclared profile dependency ${name}.`, {
920
+ package: name,
921
+ evidence: file,
922
+ suggestion: 'Use the exact profile install command to remove stale lockfile importer entries.',
923
+ repair: installRepair(),
924
+ }))
925
+ }
926
+ return { file, present: true, valid: true }
927
+ }
928
+
929
+ function inspectRuntimeAlignment(harness, dshCli, findings) {
930
+ // Harness release tooling uses the root manifest as the DSH release-family
931
+ // baseline and bumps apps/cli plus the published members to the same version.
932
+ if (typeof harness.version !== 'string' || typeof dshCli?.version !== 'string') return
933
+ if (semver.valid(harness.version) === null || semver.valid(dshCli.version) === null) return
934
+ if (harness.version === dshCli.version) return
935
+ findings.push(finding('warning', 'DSH_CLI_HARNESS_VERSION_MISMATCH', 'The active DSH CLI and diagnosed Harness installation have different versions.', {
936
+ evidence: `DSH CLI ${dshCli.version} (Harness ${harness.version})`,
937
+ suggestion: 'Diagnose with the DSH CLI and Harness checkout used by the same installation.',
938
+ }))
939
+ }
940
+
941
+ function inspectProfileHarnessPackages(profileDir, home, harness, findings) {
942
+ const profileScope = join(profileDir, 'node_modules', '@deepseek-ai')
943
+ if (!existsSync(profileScope)) return
944
+ let entries
945
+ try {
946
+ entries = readdirSync(profileScope, { withFileTypes: true })
947
+ } catch (error) {
948
+ findings.push(finding('warning', 'PROFILE_HARNESS_SCOPE_UNREADABLE', 'The profile-local @deepseek-ai package scope cannot be read as a directory.', {
949
+ evidence: `${profileScope}: ${error instanceof Error ? error.message : String(error)}`,
950
+ suggestion: 'Reinstall the profile with the active DSH CLI to repair its node_modules layout.',
951
+ }))
952
+ return
953
+ }
954
+ for (const entry of entries) {
955
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
956
+ const name = `@deepseek-ai/${entry.name}`
957
+ if (!name.startsWith('@deepseek-ai/dsh-') && name !== '@deepseek-ai/dsh') continue
958
+ const profilePackage = join(profileScope, entry.name)
959
+ const profileManifestFile = join(profilePackage, 'package.json')
960
+ if (!existsSync(profileManifestFile)) continue
961
+ let profileManifest
962
+ try {
963
+ profileManifest = JSON.parse(readFileSync(profileManifestFile, 'utf8'))
964
+ } catch {
965
+ continue
966
+ }
967
+ const sharedPackage = join(home, 'profiles', 'node_modules', '@deepseek-ai', entry.name)
968
+ if (existsSync(join(sharedPackage, 'package.json'))) {
969
+ try {
970
+ if (realpathSync(profilePackage) !== realpathSync(sharedPackage)) {
971
+ const sharedManifest = JSON.parse(readFileSync(join(sharedPackage, 'package.json'), 'utf8'))
972
+ if (typeof profileManifest.version === 'string' && typeof sharedManifest.version === 'string'
973
+ && profileManifest.version !== sharedManifest.version) {
974
+ findings.push(finding('warning', 'DUPLICATE_HARNESS_PACKAGE_VERSION', `${name} exists in the profile and shared DSH installation at different versions.`, {
975
+ package: name,
976
+ evidence: `profile ${profileManifest.version} (${profilePackage}), shared ${sharedManifest.version} (${sharedPackage})`,
977
+ suggestion: 'Reinstall the profile with the active DSH CLI so its module resolution uses one compatible version.',
978
+ }))
979
+ }
980
+ }
981
+ } catch {
982
+ // Other manifest and filesystem checks report unreadable package state.
983
+ }
984
+ }
985
+ 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.`, {
987
+ package: name,
988
+ evidence: profileManifestFile,
989
+ suggestion: 'Reinstall the profile with the active DSH CLI and review plugins that still require this package.',
990
+ }))
991
+ }
992
+ }
686
993
  }
687
994
 
688
995
  function inspectCompatibility(record, context) {
689
- const { commandRepair, findings, harnessPackages, profile, resolvePackage } = context
690
- const peers = dependencyEntries(record.manifest.peerDependencies, 'peerDependencies', record.file, findings)
996
+ const {
997
+ commandRepair, findings, harnessPackages, harnessPackagesAuthoritative, profile, resolvePackage,
998
+ } = context
999
+ const packageName = record.requestedName ?? record.manifest.name
1000
+ const peers = dependencyEntries(record.manifest.peerDependencies, 'peerDependencies', record.file, findings, packageName)
1001
+ const dependencies = dependencyEntries(record.manifest.dependencies, 'dependencies', record.file, findings, packageName)
1002
+ if (harnessPackagesAuthoritative) {
1003
+ const removedPeers = peers
1004
+ .map(([name]) => name)
1005
+ .filter(name => name.startsWith('@deepseek-ai/dsh-') && !harnessPackages.has(name))
1006
+ .sort()
1007
+ 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.`, {
1009
+ package: packageName,
1010
+ evidence: removedPeers.join(', '),
1011
+ suggestion: 'Update this plugin before relying on it with the current DSH release.',
1012
+ repair: updateRepair(profile, packageName, commandRepair),
1013
+ }))
1014
+ }
1015
+ const removedDependencies = dependencies
1016
+ .map(([name]) => name)
1017
+ .filter(name => name.startsWith('@deepseek-ai/dsh-') && !harnessPackages.has(name))
1018
+ .sort()
1019
+ 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.`, {
1021
+ package: packageName,
1022
+ evidence: removedDependencies.join(', '),
1023
+ suggestion: 'Update this plugin; its bundled DSH APIs may be incompatible with the current release.',
1024
+ repair: updateRepair(profile, packageName, commandRepair),
1025
+ }))
1026
+ }
1027
+ }
691
1028
  const mismatches = []
692
1029
  for (const [name, range] of peers) {
693
1030
  if (!name.startsWith('@deepseek-ai/') && name !== 'cordis') continue
1031
+ if (semver.validRange(range) === null) {
1032
+ findings.push(finding('warning', 'INVALID_HARNESS_PEER_RANGE', `${packageName} declares an invalid Harness peer range for ${name}.`, {
1033
+ package: packageName,
1034
+ evidence: `${name}: ${range}`,
1035
+ suggestion: 'The plugin author should publish a valid peer dependency range.',
1036
+ }))
1037
+ continue
1038
+ }
694
1039
  const supplier = harnessPackages.get(name) ?? resolvePackage(name)
695
1040
  if (supplier === undefined) continue
696
1041
  const version = supplier.manifest?.version
697
- if (typeof version !== 'string' || semver.valid(version) === null || semver.validRange(range) === null) continue
1042
+ if (typeof version !== 'string' || semver.valid(version) === null) continue
698
1043
  if (semver.satisfies(version, range, { includePrerelease: true })) continue
699
1044
  mismatches.push(`${name} ${range} (active ${version})`)
700
1045
  }
701
1046
  if (mismatches.length > 0) {
702
- findings.push(finding('warning', 'HARNESS_PEER_VERSION_MISMATCH', `${record.manifest.name} has Harness peer ranges that do not accept the active versions.`, {
703
- package: record.manifest.name,
1047
+ findings.push(finding('warning', 'HARNESS_PEER_VERSION_MISMATCH', `${packageName} has Harness peer ranges that do not accept the active versions.`, {
1048
+ package: packageName,
704
1049
  evidence: mismatches.join(', '),
705
- suggestion: `Update ${record.manifest.name} to a release compatible with the active Harness.`,
706
- repair: updateRepair(profile, record.manifest.name, commandRepair),
1050
+ suggestion: `Update ${packageName} to a release compatible with the active Harness.`,
1051
+ repair: updateRepair(profile, packageName, commandRepair),
707
1052
  }))
708
1053
  }
709
1054
  }
710
1055
 
1056
+ function hasHarnessCompatibilityDeclaration(record) {
1057
+ const peers = objectRecord(record.manifest.peerDependencies)
1058
+ return peers !== undefined && Object.keys(peers)
1059
+ .some(name => name.startsWith('@deepseek-ai/') || name === 'cordis')
1060
+ }
1061
+
1062
+ function pluginCompatibility(record, findings) {
1063
+ const name = record.requestedName ?? record.manifest.name
1064
+ const related = findings.filter(item => item.package === name)
1065
+ if (related.some(item => item.severity === 'error')) return 'incompatible'
1066
+ if (related.some(item => item.severity === 'warning')) return 'risk'
1067
+ if (!hasHarnessCompatibilityDeclaration(record)) return 'unknown'
1068
+ return 'compatible'
1069
+ }
1070
+
711
1071
  export function defaultDshHome(env = process.env) {
712
- const configured = env.DSH_HOME
713
- if (configured !== undefined && configured.trim().length > 0) {
1072
+ const configured = env.DSH_HOME?.trim()
1073
+ if (configured !== undefined && configured.length > 0) {
714
1074
  if (configured === '~') return homedir()
715
1075
  if (configured.startsWith('~/') || configured.startsWith('~\\')) return resolve(homedir(), configured.slice(2))
716
1076
  return resolve(configured)
@@ -758,16 +1118,42 @@ export function diagnose(options = {}) {
758
1118
  const resolvePackage = packageResolver(profileDir, home, harness.packages, findings)
759
1119
  const resolveBundle = bundleResolver(profileDir, home, harness.packages, findings)
760
1120
  const dependencyEntriesList = dependencyEntries(profileManifest.dependencies, 'dependencies', profileManifestFile, findings)
1121
+ for (const [name, range] of dependencyEntriesList) {
1122
+ if (looksLikeSemverRange(range) && semver.validRange(range) === null) {
1123
+ findings.push(finding('error', 'INVALID_PROFILE_DEPENDENCY_RANGE', `Profile dependency ${name} has an invalid semantic version range.`, {
1124
+ package: name,
1125
+ evidence: `${profileManifestFile}: ${range}`,
1126
+ suggestion: 'Repair the dependency range before installing or starting this profile.',
1127
+ }))
1128
+ }
1129
+ }
761
1130
  const dependencyNames = dependencyEntriesList.map(([name]) => name)
762
- const bundles = profileManifest?.dsh?.profile?.bundles
1131
+ const dshConfig = profileManifest.dsh
1132
+ const dshConfigValid = dshConfig === undefined || objectRecord(dshConfig) !== undefined
1133
+ if (!dshConfigValid) {
1134
+ findings.push(finding('error', 'INVALID_DSH_CONFIGURATION', 'dsh must be an object when present.', {
1135
+ evidence: profileManifestFile,
1136
+ suggestion: 'Repair the dsh configuration object before starting Harness.',
1137
+ }))
1138
+ }
1139
+ const profileConfigValue = objectRecord(dshConfig)?.profile
1140
+ const profileConfigValid = profileConfigValue === undefined || objectRecord(profileConfigValue) !== undefined
1141
+ if (dshConfigValid && !profileConfigValid) {
1142
+ findings.push(finding('error', 'INVALID_PROFILE_CONFIGURATION', 'dsh.profile must be an object when present.', {
1143
+ evidence: profileManifestFile,
1144
+ suggestion: 'Repair the dsh.profile configuration object before starting Harness.',
1145
+ }))
1146
+ }
1147
+ const profileConfig = objectRecord(profileConfigValue)
1148
+ const bundles = profileConfig?.bundles
763
1149
  if (bundles !== undefined && (!Array.isArray(bundles) || !bundles.every(item => typeof item === 'string'))) {
764
1150
  findings.push(finding('error', 'INVALID_BUNDLE_LIST', 'dsh.profile.bundles must be a string array.', {
765
1151
  evidence: profileManifestFile,
766
1152
  suggestion: 'Repair the profile manifest before starting Harness.',
767
1153
  }))
768
1154
  }
769
- const bundleNames = Array.isArray(bundles) ? bundles.filter(item => typeof item === 'string') : []
770
- const patchReload = profileManifest?.dsh?.profile?.patchReload
1155
+ const bundleNames = Array.isArray(bundles) ? [...new Set(bundles.filter(item => typeof item === 'string'))] : []
1156
+ const patchReload = profileConfig?.patchReload
771
1157
  if (patchReload !== undefined && patchReload !== 'live' && patchReload !== 'startup') {
772
1158
  findings.push(finding('error', 'INVALID_PATCH_RELOAD', 'dsh.profile.patchReload must be "live" or "startup".', {
773
1159
  evidence: profileManifestFile,
@@ -780,6 +1166,11 @@ export function diagnose(options = {}) {
780
1166
  const record = resolvePackage(name)
781
1167
  if (record !== undefined) records.set(name, record)
782
1168
  }
1169
+ const lockfile = inspectPnpmLock(
1170
+ profileDir, dependencyEntriesList, records, findings, commandRepair, profile,
1171
+ )
1172
+ inspectRuntimeAlignment(harness, dshCli, findings)
1173
+ inspectProfileHarnessPackages(profileDir, home, harness, findings)
783
1174
  const bundleRecords = new Map()
784
1175
  for (const name of bundleNames) {
785
1176
  const record = resolveBundle(name)
@@ -818,7 +1209,8 @@ export function diagnose(options = {}) {
818
1209
  ),
819
1210
  }))
820
1211
  }
821
- if (record.manifest?.dsh?.bundle?.patch !== undefined && !bundleNames.includes(name)) {
1212
+ if (dshConfigValid && profileConfigValid
1213
+ && record.manifest?.dsh?.bundle?.patch !== undefined && !bundleNames.includes(name)) {
822
1214
  findings.push(finding('warning', 'INSTALLED_BUNDLE_INACTIVE', `${name} is installed as a bundle but is absent from dsh.profile.bundles.`, {
823
1215
  package: name,
824
1216
  evidence: profileManifestFile,
@@ -834,10 +1226,23 @@ export function diagnose(options = {}) {
834
1226
  }))
835
1227
  }
836
1228
  }
837
- for (const name of bundleNames) inspectBundle(name, bundleRecords.get(name), findings)
1229
+ const patchLayers = []
1230
+ let patchCompositionValid = true
1231
+ for (const name of bundleNames) {
1232
+ const layer = inspectBundle(name, bundleRecords.get(name), findings)
1233
+ if (layer === undefined) patchCompositionValid = false
1234
+ else patchLayers.push(layer)
1235
+ }
838
1236
 
839
- inspectPatchFileIfPresent(join(profileDir, 'cordis.patch.yml'), 'profile patch', findings)
840
- inspectPatchFileIfPresent(join(home, 'cordis.patch.yml'), 'home patch', findings)
1237
+ for (const [file, subject, label] of [
1238
+ [join(profileDir, 'cordis.patch.yml'), 'profile patch', 'profile patch'],
1239
+ [join(home, 'cordis.patch.yml'), 'home patch', 'home patch'],
1240
+ ]) {
1241
+ const patches = inspectPatchFileIfPresent(file, subject, findings)
1242
+ if (patches !== undefined) patchLayers.push({ file, label, patches })
1243
+ else if (existsSync(file)) patchCompositionValid = false
1244
+ }
1245
+ if (patchCompositionValid) inspectPatchComposition(patchLayers, findings)
841
1246
  inspectSettings(join(home, 'settings.yaml'), findings)
842
1247
  inspectCredentials(join(home, '.credentials.yaml'), findings)
843
1248
 
@@ -859,7 +1264,13 @@ export function diagnose(options = {}) {
859
1264
  profile,
860
1265
  resolvePackage,
861
1266
  harnessPackages: harness.packages,
1267
+ harnessPackagesAuthoritative: harness.authoritative,
862
1268
  })
1269
+ inspectPluginNodeEngine(
1270
+ record,
1271
+ dshCli?.command?.[0] === process.execPath ? process.version : undefined,
1272
+ findings,
1273
+ )
863
1274
  }
864
1275
 
865
1276
  return finish({
@@ -867,16 +1278,23 @@ export function diagnose(options = {}) {
867
1278
  profile,
868
1279
  profileDir,
869
1280
  harness: { root: harness.root, version: harness.version },
1281
+ lockfile,
870
1282
  dshCli: dshCli === undefined
871
1283
  ? { available: false, commandRepairNeeded }
872
1284
  : { available: true, commandRepairNeeded, ...dshCli },
873
- packages: thirdPartyRecords.map(record => ({
874
- name: record.manifest.name,
875
- version: record.manifest.version,
876
- directory: record.directory,
877
- client: record.manifest?.dsh?.client !== undefined,
878
- bundle: record.manifest?.dsh?.bundle !== undefined,
879
- })),
1285
+ packages: dependencyNames.map(name => {
1286
+ const record = records.get(name)
1287
+ if (record === undefined) return { name, installed: false, compatibility: 'incompatible' }
1288
+ return {
1289
+ name: record.requestedName ?? record.manifest.name,
1290
+ version: record.manifest.version,
1291
+ directory: record.directory,
1292
+ installed: true,
1293
+ client: record.manifest?.dsh?.client !== undefined,
1294
+ bundle: record.manifest?.dsh?.bundle !== undefined,
1295
+ compatibility: pluginCompatibility(record, findings),
1296
+ }
1297
+ }),
880
1298
  }, findings)
881
1299
  }
882
1300
 
@@ -890,6 +1308,13 @@ function finish(context, findings) {
890
1308
  warnings: findings.filter(item => item.severity === 'warning').length,
891
1309
  info: findings.filter(item => item.severity === 'info').length,
892
1310
  }
1311
+ const compatibility = {
1312
+ incompatible: context.packages.filter(item => item.compatibility === 'incompatible').length,
1313
+ risk: context.packages.filter(item => item.compatibility === 'risk').length,
1314
+ unknown: context.packages.filter(item => item.compatibility === 'unknown').length,
1315
+ compatible: context.packages.filter(item => item.compatibility === 'compatible').length,
1316
+ }
1317
+ context = { ...context, compatibility }
893
1318
  return { version: 1, ok: summary.errors === 0, context, summary, findings }
894
1319
  }
895
1320
 
@@ -913,7 +1338,10 @@ export function formatReport(report, options = {}) {
913
1338
  `${zh ? 'DSH 主目录' : 'Home'}: ${report.context.home}`,
914
1339
  `Harness: ${report.context.harness.version ?? 'unknown'}${report.context.harness.root ? ` (${report.context.harness.root})` : ''}`,
915
1340
  `${zh ? 'DSH CLI' : 'DSH CLI'}: ${cliText}`,
916
- `${zh ? '已检查第三方包' : 'Checked third-party packages'}: ${String(report.context.packages.length)}`,
1341
+ `${zh ? 'Profile 插件' : 'Profile plugins'}: ${String(report.context.packages.length)}`,
1342
+ zh
1343
+ ? `插件兼容性: ${String(report.context.compatibility.incompatible)} 个不兼容,${String(report.context.compatibility.risk)} 个风险,${String(report.context.compatibility.unknown)} 个未知,${String(report.context.compatibility.compatible)} 个兼容`
1344
+ : `Plugin compatibility: ${String(report.context.compatibility.incompatible)} incompatible, ${String(report.context.compatibility.risk)} risk, ${String(report.context.compatibility.unknown)} unknown, ${String(report.context.compatibility.compatible)} compatible`,
917
1345
  `${zh ? '输出语言' : 'Output language'}: ${languageName(language)}`,
918
1346
  '',
919
1347
  ]
package/src/i18n.mjs CHANGED
@@ -58,6 +58,7 @@ const ZH_MESSAGES = {
58
58
  INVALID_JSON: item => `${captured(item.message, /^(.+) is not valid JSON\.$/) ?? '该文件'}不是有效的 JSON。`,
59
59
  PACKAGE_NAME_MISMATCH: item => `${item.package ?? '依赖'}解析到了名称不匹配的包。`,
60
60
  INVALID_HARNESS_ROOT: () => '指定的 Harness 根目录不是有效的源码工作区。',
61
+ INVALID_WORKSPACE_MANIFEST: () => '已忽略一个无效的 Harness workspace 包清单。',
61
62
  HARNESS_INSTALLATION_UNKNOWN: () => '无法定位这个 DSH Home 实际使用的 Harness。',
62
63
  INVALID_DEPENDENCY_MAP: item => `${captured(item.message, /^(\S+) must be/) ?? '依赖字段'}必须是“包名到版本范围”的对象。`,
63
64
  INVALID_CLIENT_DECLARATION: item => `${item.package} 的 dsh.client 声明无效。`,
@@ -73,11 +74,19 @@ const ZH_MESSAGES = {
73
74
  CLIENT_EXTERNAL_WITHOUT_SUPPLIER: item => `${item.package} 请求了 ${captured(item.message, / requests (.+), but/) ?? '客户端模块'},但当前 Harness 没有对应的模块提供方。`,
74
75
  REMOVED_CLIENT_INJECT: item => `${item.package} 注入了 ${captured(item.message, / injects (.+), which/) ?? '已移除的模块'},但当前 Harness 源码中已不存在该模块。`,
75
76
  LEGACY_HARNESS_PEERS: item => `${item.package} 仍声明了当前 Harness 源码中已不存在的旧包。`,
77
+ LEGACY_HARNESS_DEPENDENCIES: item => `${item.package} 仍依赖当前 Harness 源码中已不存在的旧包。`,
76
78
  BUNDLE_NOT_INSTALLED: item => `配置中的 bundle ${item.package} 尚未安装。`,
77
79
  BUNDLE_DECLARATION_MISSING: item => `${item.package} 被列为 profile bundle,但没有声明 dsh.bundle.patch。`,
78
80
  BUNDLE_PATCH_MISSING: item => `${item.package} 的 bundle patch 文件缺失。`,
79
81
  INVALID_PATCH_YAML: item => `${captured(item.message, /^(.+) cannot be parsed\.$/) ?? 'Patch 文件'}无法解析。`,
80
82
  INVALID_PATCH_LIST: item => `${captured(item.message, /^(.+) must be/) ?? 'Patch 文件'}的顶层必须是由映射组成的 YAML 数组。`,
83
+ INVALID_PATCH_ID: () => 'Patch 条目的 id 必须是字符串。',
84
+ INVALID_PATCH_NAME: () => 'Patch 条目的 name 断言必须是字符串。',
85
+ INVALID_PATCH_INSERT: () => 'Patch 条目的 insert 必须是由映射组成的数组。',
86
+ PATCH_ID_REQUIRED: () => '非 insert patch 缺少目标 id。',
87
+ PATCH_TARGET_NOT_FOUND: item => `Patch 指向了不存在的配置行:${captured(item.message, / row ([^.]+)\.$/) ?? '未知'}。`,
88
+ PATCH_TARGET_NOT_GROUP: item => `Patch 尝试向非 group 配置行 ${captured(item.message, / row ([^,]+),/) ?? '未知'} 插入内容。`,
89
+ PATCH_NAME_MISMATCH: () => 'Patch 的 name 断言与目标配置行不一致。',
81
90
  INVALID_SETTINGS_DOCUMENT: () => 'Harness 设置文件无法解析。',
82
91
  INVALID_SETTINGS_ROOT: () => 'Harness 设置文件顶层必须是命名空间映射。',
83
92
  INVALID_CREDENTIALS_DOCUMENT: () => 'Harness 凭据文件无法解析。',
@@ -86,11 +95,27 @@ const ZH_MESSAGES = {
86
95
  HARNESS_PEER_VERSION_MISMATCH: item => `${item.package} 声明的 Harness peer 版本范围不接受当前已安装版本。`,
87
96
  INVALID_PROFILE_NAME: item => `Profile 名称无效:${captured(item.message, /^Invalid profile name (.+)\.$/) ?? ''}`,
88
97
  PROFILE_NOT_FOUND: item => `Profile ${captured(item.message, /^Profile (.+) does not exist\.$/) ?? ''} 不存在。`,
98
+ INVALID_DSH_CONFIGURATION: () => 'dsh 字段存在时必须是对象。',
99
+ INVALID_PROFILE_CONFIGURATION: () => 'dsh.profile 字段存在时必须是对象。',
89
100
  INVALID_BUNDLE_LIST: () => 'dsh.profile.bundles 必须是字符串数组。',
90
101
  INVALID_PATCH_RELOAD: () => 'dsh.profile.patchReload 必须是 “live” 或 “startup”。',
91
102
  DEPENDENCY_NOT_INSTALLED: item => `Profile 依赖 ${item.package} 尚未安装。`,
92
103
  PROFILE_DEPENDENCY_VERSION_MISMATCH: item => `${item.package} 的声明版本范围与当前安装版本不兼容。`,
93
104
  INSTALLED_BUNDLE_INACTIVE: item => `${item.package} 已作为 bundle 安装,但不在 dsh.profile.bundles 中。`,
105
+ INVALID_PROFILE_DEPENDENCY_RANGE: item => `Profile 依赖 ${item.package} 的语义版本范围无效。`,
106
+ INVALID_HARNESS_PEER_RANGE: item => `${item.package} 声明了无效的 Harness peer 版本范围。`,
107
+ INVALID_NODE_ENGINE_RANGE: item => `${item.package} 声明了无效的 Node.js engines 范围。`,
108
+ PLUGIN_NODE_VERSION_MISMATCH: item => `${item.package} 不支持当前 DSH CLI 使用的 Node.js 版本。`,
109
+ INVALID_PNPM_LOCKFILE: () => 'Profile 的 pnpm lockfile 无法解析。',
110
+ PNPM_LOCKFILE_IMPORTER_MISSING: () => 'Profile 的 pnpm lockfile 缺少可用的根 dependencies 映射。',
111
+ LOCKFILE_DEPENDENCY_MISSING: item => `Profile 依赖 ${item.package} 未出现在 pnpm lockfile importer 中。`,
112
+ LOCKFILE_SPECIFIER_MISMATCH: item => `${item.package} 在 package.json 与 pnpm lockfile 中的声明不一致。`,
113
+ LOCKFILE_INSTALLED_VERSION_MISMATCH: item => `${item.package} 的实际安装版本与 lockfile 不一致。`,
114
+ LOCKFILE_DEPENDENCY_STALE: item => `pnpm lockfile 仍包含未声明的依赖 ${item.package}。`,
115
+ DSH_CLI_HARNESS_VERSION_MISMATCH: () => '当前 DSH CLI 与诊断到的 Harness 版本不一致。',
116
+ DUPLICATE_HARNESS_PACKAGE_VERSION: item => `${item.package} 在 profile 与共享 DSH 安装中存在不同版本。`,
117
+ STALE_PROFILE_HARNESS_PACKAGE: item => `${item.package} 残留在 profile 中,但当前 Harness 源码已不再包含它。`,
118
+ PROFILE_HARNESS_SCOPE_UNREADABLE: () => 'Profile 内的 @deepseek-ai 包作用域无法作为目录读取。',
94
119
  }
95
120
 
96
121
  const ZH_SUGGESTIONS = {
@@ -98,6 +123,7 @@ const ZH_SUGGESTIONS = {
98
123
  INVALID_JSON: () => '启动该 profile 前,请先修复 JSON。',
99
124
  PACKAGE_NAME_MISMATCH: () => '重新安装依赖,使目录位置与包名一致。',
100
125
  INVALID_HARNESS_ROOT: () => '诊断源码工作区时,请把 DeepSeek Harness 仓库根目录传给 --harness-root。',
126
+ INVALID_WORKSPACE_MANIFEST: () => '修复该 workspace 包清单后,Doctor 才能把它纳入兼容性检查。',
101
127
  HARNESS_INSTALLATION_UNKNOWN: () => '若使用源码工作区,请通过 --harness-root 明确指定;若使用独立 CLI,请通过 --dsh-command 指定。',
102
128
  INVALID_DEPENDENCY_MAP: () => '管理或启动 profile 前,请先修复这个依赖字段。',
103
129
  INVALID_CLIENT_DECLARATION: update,
@@ -111,23 +137,47 @@ const ZH_SUGGESTIONS = {
111
137
  CLIENT_EXTERNAL_WITHOUT_SUPPLIER: update,
112
138
  REMOVED_CLIENT_INJECT: update,
113
139
  REDUNDANT_CLIENT_EXTERNAL: () => '插件作者应删除重复的 dsh.client.external 条目。',
114
- LEGACY_HARNESS_PEERS: () => '该插件存在兼容风险,请在下次升级 Harness 前更新。',
140
+ LEGACY_HARNESS_PEERS: () => '该插件存在兼容风险,请更新到支持当前 DSH 的版本。',
141
+ LEGACY_HARNESS_DEPENDENCIES: () => '请更新该插件;它依赖的 DSH API 可能与当前版本不兼容。',
115
142
  BUNDLE_NOT_INSTALLED: () => '使用当前 DSH 安装补齐 profile 依赖、升级 bundle,或从 profile 中移除它。',
116
143
  BUNDLE_DECLARATION_MISSING: () => '升级该 bundle,或把它从 dsh.profile.bundles 中移除。',
117
144
  BUNDLE_PATCH_MISSING: () => '重新安装或升级该 bundle,或者从 profile 中移除它。',
118
145
  INVALID_PATCH_YAML: () => '启动该 profile 前,请修复 YAML 语法。',
119
146
  INVALID_PATCH_LIST: () => '启动该 profile 前,请修复 patch 顶层结构。',
147
+ INVALID_PATCH_ID: () => '请使用字符串行 id;根级 insert 可以省略 id。',
148
+ INVALID_PATCH_NAME: () => '请使用字符串插件名称断言,或删除 name 字段。',
149
+ INVALID_PATCH_INSERT: () => '启动该 profile 前,请修复 insert 列表。',
150
+ PATCH_ID_REQUIRED: () => '请补充目标行 id,或把该条目改为 insert patch。',
151
+ PATCH_TARGET_NOT_FOUND: () => '确认该 overlay 是否适用于当前 profile,并检查 bundle 顺序。',
152
+ PATCH_TARGET_NOT_GROUP: () => '请选择 group 行,或使用根级 insert。',
153
+ PATCH_NAME_MISMATCH: () => '请更新 name 断言,或改为指向正确的配置行。',
120
154
  INVALID_SETTINGS_DOCUMENT: () => '修复设置文件语法;Doctor 不会猜测凭据或模型配置值。',
121
155
  INVALID_SETTINGS_ROOT: () => '把顶层标量或数组替换为映射。',
122
156
  INVALID_CREDENTIALS_DOCUMENT: () => '只修复报告的结构;Doctor 永远不会输出或重写秘密值。',
123
157
  INVALID_CREDENTIALS_LAYOUT: () => '迁移文档结构,不要暴露或修改秘密值。',
124
158
  HARNESS_PEER_VERSION_MISMATCH: item => `把 ${item.package} 更新到兼容当前 Harness 的版本。`,
125
159
  PROFILE_NOT_FOUND: () => '先启动一次该 profile,或用当前 DSH 安装初始化它。',
160
+ INVALID_DSH_CONFIGURATION: () => '启动 Harness 前,请先修复 dsh 配置对象。',
161
+ INVALID_PROFILE_CONFIGURATION: () => '启动 Harness 前,请先修复 dsh.profile 配置对象。',
126
162
  INVALID_BUNDLE_LIST: () => '启动 Harness 前,请修复 profile manifest。',
127
163
  INVALID_PATCH_RELOAD: () => '选择该 profile 需要的 reload 生命周期。',
128
164
  DEPENDENCY_NOT_INSTALLED: () => '使用下方精确命令安装该 profile 声明的依赖。',
129
165
  PROFILE_DEPENDENCY_VERSION_MISMATCH: () => '使用下方精确命令重新同步该 profile 的安装。',
130
166
  INSTALLED_BUNDLE_INACTIVE: () => '重新执行匹配的插件添加或更新操作,或者移除未使用的依赖。',
167
+ INVALID_PROFILE_DEPENDENCY_RANGE: () => '安装或启动 profile 前,请修复依赖版本范围。',
168
+ INVALID_HARNESS_PEER_RANGE: () => '插件作者应发布有效的 peer dependency 版本范围。',
169
+ INVALID_NODE_ENGINE_RANGE: () => '插件作者应发布有效的 engines.node 版本范围。',
170
+ PLUGIN_NODE_VERSION_MISMATCH: item => `更新 ${item.package},或使用该插件支持的 Node.js 版本运行 DSH。`,
171
+ INVALID_PNPM_LOCKFILE: () => '修复或重新生成 lockfile 后,再执行精确的 profile 安装命令。',
172
+ PNPM_LOCKFILE_IMPORTER_MISSING: () => '使用精确的 profile 安装命令重新同步 lockfile。',
173
+ LOCKFILE_DEPENDENCY_MISSING: () => '使用下方精确命令重新同步 manifest 与 lockfile。',
174
+ LOCKFILE_SPECIFIER_MISMATCH: () => '使用下方精确命令重新同步 manifest 与 lockfile。',
175
+ LOCKFILE_INSTALLED_VERSION_MISMATCH: () => '使用下方精确命令恢复 lockfile 锁定的安装。',
176
+ LOCKFILE_DEPENDENCY_STALE: () => '使用下方精确命令移除 lockfile importer 中的残留条目。',
177
+ DSH_CLI_HARNESS_VERSION_MISMATCH: () => '请使用属于同一安装的 DSH CLI 与 Harness 工作区进行诊断。',
178
+ DUPLICATE_HARNESS_PACKAGE_VERSION: () => '使用当前 DSH CLI 重新安装 profile,确保模块解析只使用一个兼容版本。',
179
+ STALE_PROFILE_HARNESS_PACKAGE: () => '使用当前 DSH CLI 重新安装 profile,并检查仍依赖该旧包的插件。',
180
+ PROFILE_HARNESS_SCOPE_UNREADABLE: () => '请使用当前 DSH CLI 重新安装 profile,以修复 node_modules 布局。',
131
181
  }
132
182
 
133
183
  function update(item) {
package/src/repair.mjs CHANGED
@@ -34,7 +34,10 @@ export function formatRepairPlan(actions, options = {}) {
34
34
  } else lines.push(` ${zh ? '文件' : 'File'}: ${action.file}`, ` ${zh ? '备份' : 'Backup'}: ${action.file}.dsh-doctor-<timestamp>.bak`)
35
35
  lines.push('')
36
36
  })
37
- return `${lines.join('\n')}${zh ? '执行这些修复吗?[y/N] ' : 'Apply these repairs? [y/N] '}`
37
+ const plan = lines.join('\n')
38
+ return options.prompt === false
39
+ ? plan.trimEnd()
40
+ : `${plan}${zh ? '执行这些修复吗?[y/N] ' : 'Apply these repairs? [y/N] '}`
38
41
  }
39
42
 
40
43
  function localizedDescription(action, zh) {
@@ -56,8 +59,19 @@ function applyJsonEdit(action) {
56
59
  }
57
60
  const manifest = JSON.parse(current)
58
61
  if (action.operation.type !== 'add-bundle') throw new Error(`unsupported JSON repair ${action.operation.type}`)
59
- const bundles = manifest?.dsh?.profile?.bundles
60
- if (!Array.isArray(bundles)) throw new Error(`${action.file} no longer has a valid dsh.profile.bundles array`)
62
+ if (manifest.dsh === undefined) manifest.dsh = {}
63
+ if (manifest.dsh === null || typeof manifest.dsh !== 'object' || Array.isArray(manifest.dsh)) {
64
+ throw new Error(`${action.file} no longer has a valid dsh object`)
65
+ }
66
+ if (manifest.dsh.profile === undefined) manifest.dsh.profile = {}
67
+ if (manifest.dsh.profile === null || typeof manifest.dsh.profile !== 'object' || Array.isArray(manifest.dsh.profile)) {
68
+ throw new Error(`${action.file} no longer has a valid dsh.profile object`)
69
+ }
70
+ if (manifest.dsh.profile.bundles === undefined) manifest.dsh.profile.bundles = []
71
+ const bundles = manifest.dsh.profile.bundles
72
+ if (!Array.isArray(bundles) || !bundles.every(item => typeof item === 'string')) {
73
+ throw new Error(`${action.file} no longer has a valid dsh.profile.bundles array`)
74
+ }
61
75
  if (!bundles.includes(action.operation.name)) bundles.push(action.operation.name)
62
76
  const stamp = new Date().toISOString().replace(/[:.]/g, '-')
63
77
  const backup = `${action.file}.dsh-doctor-${stamp}.bak`
@@ -68,22 +82,42 @@ function applyJsonEdit(action) {
68
82
  return { id: action.id, status: 'applied', backup }
69
83
  }
70
84
 
71
- function applyCommand(action) {
85
+ function limitedOutput(value, limit = 8192) {
86
+ if (typeof value !== 'string' || value.length === 0) return undefined
87
+ return value.length <= limit ? value : `${value.slice(0, limit)}\n... output truncated by DSH Doctor ...`
88
+ }
89
+
90
+ function applyCommand(action, options) {
72
91
  const [command, ...args] = action.command
92
+ const captureOutput = options.captureOutput === true
73
93
  const result = crossSpawn.sync(command, args, {
74
- stdio: 'inherit',
94
+ stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit',
95
+ ...captureOutput ? { encoding: 'utf8' } : {},
75
96
  env: action.env === undefined ? process.env : { ...process.env, ...action.env },
76
97
  })
77
98
  if (result.error != null) throw result.error
78
- if (result.status !== 0) throw new Error(`${command} exited with status ${String(result.status)}`)
79
- return { id: action.id, status: 'applied' }
99
+ if (result.status !== 0) {
100
+ const reason = result.signal === null
101
+ ? `${command} exited with status ${String(result.status)}`
102
+ : `${command} was terminated by signal ${result.signal}`
103
+ const details = captureOutput ? limitedOutput(result.stderr) ?? limitedOutput(result.stdout) : undefined
104
+ throw new Error(details === undefined ? reason : `${reason}: ${details.trimEnd()}`)
105
+ }
106
+ const stdout = captureOutput ? limitedOutput(result.stdout) : undefined
107
+ const stderr = captureOutput ? limitedOutput(result.stderr) : undefined
108
+ return {
109
+ id: action.id,
110
+ status: 'applied',
111
+ ...(stdout === undefined ? {} : { stdout }),
112
+ ...(stderr === undefined ? {} : { stderr }),
113
+ }
80
114
  }
81
115
 
82
- export function applyRepairs(actions) {
116
+ export function applyRepairs(actions, options = {}) {
83
117
  const results = []
84
118
  for (const action of actions) {
85
119
  try {
86
- results.push(action.kind === 'json-edit' ? applyJsonEdit(action) : applyCommand(action))
120
+ results.push(action.kind === 'json-edit' ? applyJsonEdit(action) : applyCommand(action, options))
87
121
  } catch (error) {
88
122
  results.push({
89
123
  id: action.id,