@bruc3van/dsh-doctor 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +4 -3
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/cli.mjs +32 -24
- package/src/doctor.mjs +77 -19
- package/src/i18n.mjs +1 -0
- package/src/repair.mjs +23 -2
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
|
|
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
|
|
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` 必须是全局命令。它会按顺序查找
|
|
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
|
|
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
package/src/cli.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { applyRepairs, formatRepairPlan, repairsFromReport } from './repair.mjs'
|
|
3
|
+
import { formatReport, diagnose, resolveDshHome } from './doctor.mjs'
|
|
4
|
+
import { applyRepairs, formatRepairOutcome, formatRepairPlan, repairsFromReport } from './repair.mjs'
|
|
5
5
|
import { resolveLanguage } from './i18n.mjs'
|
|
6
6
|
import { readFileSync } from 'node:fs'
|
|
7
7
|
import { dirname, resolve } from 'node:path'
|
|
@@ -44,11 +44,6 @@ const HELP_ZH = `用法:dsh-doctor [选项]
|
|
|
44
44
|
|
|
45
45
|
const help = language => language === 'zh' ? HELP_ZH : HELP_EN
|
|
46
46
|
|
|
47
|
-
function fail(message, language = 'en') {
|
|
48
|
-
process.stderr.write(`dsh-doctor: ${message}\n\n${help(language)}`)
|
|
49
|
-
process.exitCode = 2
|
|
50
|
-
}
|
|
51
|
-
|
|
52
47
|
function valueAfter(args, index, name) {
|
|
53
48
|
const value = args[index + 1]
|
|
54
49
|
if (value === undefined || value.startsWith('-')) throw new Error(`${name} needs a value`)
|
|
@@ -75,31 +70,37 @@ function parse(args) {
|
|
|
75
70
|
}
|
|
76
71
|
|
|
77
72
|
async function main() {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
73
|
+
const options = parse(process.argv.slice(2))
|
|
74
|
+
if (options.version) {
|
|
75
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
76
|
+
const manifest = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8'))
|
|
77
|
+
process.stdout.write(`${manifest.version}\n`)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (options.help) {
|
|
81
|
+
const requested = ['auto', 'zh', 'en'].includes(options.lang) ? options.lang : undefined
|
|
82
|
+
const language = resolveLanguage({
|
|
83
|
+
requested,
|
|
84
|
+
home: resolveDshHome(options.home),
|
|
85
|
+
systemLocale: Intl.DateTimeFormat().resolvedOptions().locale,
|
|
86
|
+
})
|
|
87
|
+
process.stdout.write(help(language))
|
|
83
88
|
return
|
|
84
89
|
}
|
|
85
90
|
const language = resolveLanguage({
|
|
86
91
|
requested: options.lang,
|
|
87
|
-
home:
|
|
92
|
+
home: resolveDshHome(options.home),
|
|
88
93
|
systemLocale: Intl.DateTimeFormat().resolvedOptions().locale,
|
|
89
94
|
})
|
|
90
95
|
if (options.yes && !options.fix) throw new Error('--yes requires --fix')
|
|
91
96
|
if (options.json && options.fix && !options.yes) throw new Error('--json --fix requires --yes because a prompt would corrupt JSON output')
|
|
92
|
-
|
|
93
|
-
process.stdout.write(help(language))
|
|
94
|
-
} else if (options.version) {
|
|
95
|
-
const here = dirname(fileURLToPath(import.meta.url))
|
|
96
|
-
const manifest = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8'))
|
|
97
|
-
process.stdout.write(`${manifest.version}\n`)
|
|
98
|
-
} else {
|
|
97
|
+
{
|
|
99
98
|
let report = diagnose(options)
|
|
100
99
|
let repairs = []
|
|
100
|
+
let actions = []
|
|
101
|
+
let repairDeclined = false
|
|
101
102
|
if (options.fix) {
|
|
102
|
-
|
|
103
|
+
actions = repairsFromReport(report)
|
|
103
104
|
if (actions.length === 0 && report.context.dshCli?.commandRepairNeeded && !report.context.dshCli.available) {
|
|
104
105
|
throw new Error(language === 'zh'
|
|
105
106
|
? '未找到可用的 DSH CLI,无法执行命令型修复;请使用 --dsh-command 指定当前安装的 dsh 或 lib/bin.js'
|
|
@@ -120,13 +121,20 @@ async function main() {
|
|
|
120
121
|
if (confirmed) {
|
|
121
122
|
repairs = applyRepairs(actions, { captureOutput: options.json })
|
|
122
123
|
if (repairs.every(item => item.status === 'applied')) report = diagnose(options)
|
|
123
|
-
}
|
|
124
|
+
} else repairDeclined = true
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
const output = options.fix ? { ...report, repairs } : report
|
|
128
|
+
const noRepairMessage = options.fix && repairs.length === 0 && !options.json
|
|
129
|
+
? formatRepairOutcome(actions, repairs, {
|
|
130
|
+
declined: repairDeclined,
|
|
131
|
+
findingCount: report.findings.length,
|
|
132
|
+
language,
|
|
133
|
+
})
|
|
134
|
+
: ''
|
|
127
135
|
process.stdout.write(options.json
|
|
128
136
|
? `${JSON.stringify(output, null, 2)}\n`
|
|
129
|
-
: `${formatReport(report, { color: process.stdout.isTTY, language })}${repairs.length > 0 ? `${language === 'zh' ? '修复结果' : 'Repairs'}: ${JSON.stringify(repairs, null, 2)}\n` :
|
|
137
|
+
: `${formatReport(report, { color: process.stdout.isTTY, language })}${repairs.length > 0 ? `${language === 'zh' ? '修复结果' : 'Repairs'}: ${JSON.stringify(repairs, null, 2)}\n` : noRepairMessage}`)
|
|
130
138
|
process.exitCode = repairs.some(item => item.status === 'failed') ? 2 : report.summary.errors > 0 ? 1 : 0
|
|
131
139
|
}
|
|
132
140
|
}
|
|
@@ -138,7 +146,7 @@ try {
|
|
|
138
146
|
if (process.argv.includes('--json')) {
|
|
139
147
|
process.stdout.write(`${JSON.stringify({ version: 1, operationalError: message }, null, 2)}\n`)
|
|
140
148
|
} else {
|
|
141
|
-
process.stderr.write(`dsh-doctor: ${message}\n`)
|
|
149
|
+
process.stderr.write(`dsh-doctor: ${message}\n\n${help('en')}`)
|
|
142
150
|
}
|
|
143
151
|
process.exitCode = 2
|
|
144
152
|
}
|
package/src/doctor.mjs
CHANGED
|
@@ -87,6 +87,12 @@ function packagePathParts(name) {
|
|
|
87
87
|
return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) ? [name] : undefined
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
function expandUserPath(value) {
|
|
91
|
+
if (value === '~') return homedir()
|
|
92
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) return resolve(homedir(), value.slice(2))
|
|
93
|
+
return value
|
|
94
|
+
}
|
|
95
|
+
|
|
90
96
|
function packageManifestAt(directory, expectedName, findings, source) {
|
|
91
97
|
const file = join(directory, 'package.json')
|
|
92
98
|
if (!existsSync(file)) return undefined
|
|
@@ -174,11 +180,7 @@ function localDshPackage(start) {
|
|
|
174
180
|
}
|
|
175
181
|
|
|
176
182
|
function commandFromValue(value, env, cwd) {
|
|
177
|
-
const expanded = value
|
|
178
|
-
? homedir()
|
|
179
|
-
: value.startsWith('~/') || value.startsWith('~\\')
|
|
180
|
-
? resolve(homedir(), value.slice(2))
|
|
181
|
-
: value
|
|
183
|
+
const expanded = expandUserPath(value)
|
|
182
184
|
const looksLikePath = isAbsolute(expanded) || expanded.includes('/') || expanded.includes('\\') || expanded.startsWith('.')
|
|
183
185
|
const file = looksLikePath ? resolve(cwd, expanded) : executableOnPath(expanded, env)
|
|
184
186
|
if (file === undefined) return undefined
|
|
@@ -206,7 +208,13 @@ function resolveDshCli(options, harness, home) {
|
|
|
206
208
|
|
|
207
209
|
const sharedPackage = join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh')
|
|
208
210
|
if (existsSync(sharedPackage)) {
|
|
209
|
-
|
|
211
|
+
let canonical
|
|
212
|
+
try {
|
|
213
|
+
canonical = realpathSync(sharedPackage)
|
|
214
|
+
} catch {
|
|
215
|
+
canonical = undefined
|
|
216
|
+
}
|
|
217
|
+
const bin = canonical === undefined ? undefined : manifestBin(canonical)
|
|
210
218
|
if (bin !== undefined) return { command: [process.execPath, bin.bin], path: bin.bin, source: 'profile', version: bin.version }
|
|
211
219
|
}
|
|
212
220
|
|
|
@@ -270,7 +278,7 @@ function indexWorkspace(root, findings) {
|
|
|
270
278
|
|
|
271
279
|
function resolveHarnessContext(home, explicitRoot, findings) {
|
|
272
280
|
if (explicitRoot !== undefined) {
|
|
273
|
-
const root = resolve(explicitRoot)
|
|
281
|
+
const root = resolve(expandUserPath(explicitRoot))
|
|
274
282
|
if (!existsSync(join(root, 'package.json')) || !existsSync(join(root, 'packages'))) {
|
|
275
283
|
findings.push(finding('error', 'INVALID_HARNESS_ROOT', 'The configured Harness root is not a source checkout.', {
|
|
276
284
|
evidence: root,
|
|
@@ -284,7 +292,16 @@ function resolveHarnessContext(home, explicitRoot, findings) {
|
|
|
284
292
|
|
|
285
293
|
const sharedDsh = join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh')
|
|
286
294
|
if (existsSync(sharedDsh)) {
|
|
287
|
-
|
|
295
|
+
let canonical
|
|
296
|
+
try {
|
|
297
|
+
canonical = realpathSync(sharedDsh)
|
|
298
|
+
} catch (error) {
|
|
299
|
+
findings.push(finding('warning', 'HARNESS_INSTALLATION_UNKNOWN', 'Could not resolve the DSH installation currently used by this home.', {
|
|
300
|
+
evidence: `${sharedDsh}: ${error instanceof Error ? error.message : String(error)}`,
|
|
301
|
+
suggestion: 'Repair the shared DSH installation or pass --harness-root for a source checkout.',
|
|
302
|
+
}))
|
|
303
|
+
return { root: undefined, packages: new Map(), version: undefined, authoritative: false }
|
|
304
|
+
}
|
|
288
305
|
const root = findHarnessRoot(canonical)
|
|
289
306
|
if (root !== undefined) {
|
|
290
307
|
const manifest = readJson(join(root, 'package.json'), 'Harness root manifest', findings)
|
|
@@ -393,7 +410,10 @@ export function extractStaticRequires(source) {
|
|
|
393
410
|
if (!code[match.index]) continue
|
|
394
411
|
const specifier = match[2]
|
|
395
412
|
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || BUILTIN_MODULES.has(specifier)) continue
|
|
396
|
-
|
|
413
|
+
const packageName = specifier.startsWith('@')
|
|
414
|
+
? specifier.split('/').slice(0, 2).join('/')
|
|
415
|
+
: specifier.split('/')[0]
|
|
416
|
+
if (packagePathParts(packageName) !== undefined) values.add(specifier)
|
|
397
417
|
}
|
|
398
418
|
return [...values].sort()
|
|
399
419
|
}
|
|
@@ -401,6 +421,7 @@ export function extractStaticRequires(source) {
|
|
|
401
421
|
function codePositions(source) {
|
|
402
422
|
const code = new Uint8Array(source.length)
|
|
403
423
|
let state = 'code'
|
|
424
|
+
let regexClass = false
|
|
404
425
|
for (let index = 0; index < source.length; index += 1) {
|
|
405
426
|
const char = source[index]
|
|
406
427
|
const next = source[index + 1]
|
|
@@ -413,6 +434,9 @@ function codePositions(source) {
|
|
|
413
434
|
index += 1
|
|
414
435
|
} else if (char === "'" || char === '"' || char === '`') {
|
|
415
436
|
state = char
|
|
437
|
+
} else if (char === '/' && regexCanStart(source, index)) {
|
|
438
|
+
state = 'regex'
|
|
439
|
+
regexClass = false
|
|
416
440
|
} else {
|
|
417
441
|
code[index] = 1
|
|
418
442
|
}
|
|
@@ -426,6 +450,12 @@ function codePositions(source) {
|
|
|
426
450
|
state = 'code'
|
|
427
451
|
index += 1
|
|
428
452
|
}
|
|
453
|
+
} else if (state === 'regex') {
|
|
454
|
+
if (char === '\\') index += 1
|
|
455
|
+
else if (char === '[') regexClass = true
|
|
456
|
+
else if (char === ']') regexClass = false
|
|
457
|
+
else if (char === '/' && !regexClass) state = 'code'
|
|
458
|
+
else if (char === '\n' || char === '\r') state = 'code'
|
|
429
459
|
} else if (char === '\\') {
|
|
430
460
|
index += 1
|
|
431
461
|
} else if (char === state) {
|
|
@@ -435,6 +465,17 @@ function codePositions(source) {
|
|
|
435
465
|
return code
|
|
436
466
|
}
|
|
437
467
|
|
|
468
|
+
function regexCanStart(source, slashIndex) {
|
|
469
|
+
let index = slashIndex - 1
|
|
470
|
+
while (index >= 0 && /\s/.test(source[index])) index -= 1
|
|
471
|
+
if (index < 0) return true
|
|
472
|
+
if ('([{=,:;!&|?~+-*%^<>'.includes(source[index])) return true
|
|
473
|
+
if (!/[a-zA-Z0-9_$]/.test(source[index])) return false
|
|
474
|
+
const end = index + 1
|
|
475
|
+
while (index >= 0 && /[a-zA-Z0-9_$]/.test(source[index])) index -= 1
|
|
476
|
+
return /^(?:await|case|delete|do|else|in|instanceof|new|of|return|throw|typeof|void|yield)$/.test(source.slice(index + 1, end))
|
|
477
|
+
}
|
|
478
|
+
|
|
438
479
|
function stripClientSuffix(specifier) {
|
|
439
480
|
return specifier.endsWith('/client') ? specifier.slice(0, -'/client'.length) : specifier
|
|
440
481
|
}
|
|
@@ -505,6 +546,7 @@ function inspectClientPackage(record, context) {
|
|
|
505
546
|
package: name,
|
|
506
547
|
evidence: record.file,
|
|
507
548
|
suggestion: disableSuggestion,
|
|
549
|
+
repair: updateRepair(profile, name, commandRepair),
|
|
508
550
|
}))
|
|
509
551
|
return
|
|
510
552
|
}
|
|
@@ -526,6 +568,8 @@ function inspectClientPackage(record, context) {
|
|
|
526
568
|
findings.push(finding('error', 'CLIENT_BUNDLE_UNREADABLE', `${name} client bundle cannot be read.`, {
|
|
527
569
|
package: name,
|
|
528
570
|
evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
571
|
+
suggestion: disableSuggestion,
|
|
572
|
+
repair: updateRepair(profile, name, commandRepair),
|
|
529
573
|
}))
|
|
530
574
|
return
|
|
531
575
|
}
|
|
@@ -1027,8 +1071,11 @@ function inspectCompatibility(record, context) {
|
|
|
1027
1071
|
}
|
|
1028
1072
|
}
|
|
1029
1073
|
const mismatches = []
|
|
1074
|
+
let declaredPeers = 0
|
|
1075
|
+
let resolvedPeers = 0
|
|
1030
1076
|
for (const [name, range] of peers) {
|
|
1031
1077
|
if (!name.startsWith('@deepseek-ai/') && name !== 'cordis') continue
|
|
1078
|
+
declaredPeers += 1
|
|
1032
1079
|
if (semver.validRange(range) === null) {
|
|
1033
1080
|
findings.push(finding('warning', 'INVALID_HARNESS_PEER_RANGE', `${packageName} declares an invalid Harness peer range for ${name}.`, {
|
|
1034
1081
|
package: packageName,
|
|
@@ -1041,6 +1088,7 @@ function inspectCompatibility(record, context) {
|
|
|
1041
1088
|
if (supplier === undefined) continue
|
|
1042
1089
|
const version = supplier.manifest?.version
|
|
1043
1090
|
if (typeof version !== 'string' || semver.valid(version) === null) continue
|
|
1091
|
+
resolvedPeers += 1
|
|
1044
1092
|
if (semver.satisfies(version, range, { includePrerelease: true })) continue
|
|
1045
1093
|
mismatches.push({ name, required: range, active: version })
|
|
1046
1094
|
}
|
|
@@ -1070,6 +1118,7 @@ function inspectCompatibility(record, context) {
|
|
|
1070
1118
|
repair: updateRepair(profile, packageName, commandRepair),
|
|
1071
1119
|
}))
|
|
1072
1120
|
}
|
|
1121
|
+
return { declaredPeers, resolvedPeers }
|
|
1073
1122
|
}
|
|
1074
1123
|
|
|
1075
1124
|
function hasHarnessCompatibilityDeclaration(record) {
|
|
@@ -1078,28 +1127,31 @@ function hasHarnessCompatibilityDeclaration(record) {
|
|
|
1078
1127
|
.some(name => name.startsWith('@deepseek-ai/') || name === 'cordis')
|
|
1079
1128
|
}
|
|
1080
1129
|
|
|
1081
|
-
function pluginCompatibility(record, findings) {
|
|
1130
|
+
function pluginCompatibility(record, findings, compatibilityCheck) {
|
|
1082
1131
|
const name = record.requestedName ?? record.manifest.name
|
|
1083
1132
|
const related = findings.filter(item => item.package === name)
|
|
1084
1133
|
if (related.some(item => item.severity === 'error')) return 'incompatible'
|
|
1085
1134
|
if (related.some(item => item.severity === 'warning')) return 'risk'
|
|
1086
1135
|
if (!hasHarnessCompatibilityDeclaration(record)) return 'unknown'
|
|
1136
|
+
if (compatibilityCheck.resolvedPeers < compatibilityCheck.declaredPeers) return 'unknown'
|
|
1087
1137
|
return 'compatible'
|
|
1088
1138
|
}
|
|
1089
1139
|
|
|
1090
1140
|
export function defaultDshHome(env = process.env) {
|
|
1091
1141
|
const configured = env.DSH_HOME?.trim()
|
|
1092
1142
|
if (configured !== undefined && configured.length > 0) {
|
|
1093
|
-
|
|
1094
|
-
if (configured.startsWith('~/') || configured.startsWith('~\\')) return resolve(homedir(), configured.slice(2))
|
|
1095
|
-
return resolve(configured)
|
|
1143
|
+
return resolve(expandUserPath(configured))
|
|
1096
1144
|
}
|
|
1097
1145
|
return join(homedir(), '.dsh')
|
|
1098
1146
|
}
|
|
1099
1147
|
|
|
1148
|
+
export function resolveDshHome(value, env = process.env) {
|
|
1149
|
+
return resolve(expandUserPath(value ?? defaultDshHome(env)))
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1100
1152
|
export function diagnose(options = {}) {
|
|
1101
1153
|
const findings = []
|
|
1102
|
-
const home =
|
|
1154
|
+
const home = resolveDshHome(options.home, options.env)
|
|
1103
1155
|
const profile = options.profile ?? 'web'
|
|
1104
1156
|
if (profile === '' || profile === '.' || profile === '..' || profile.includes('/') || profile.includes('\\')) {
|
|
1105
1157
|
findings.push(finding('error', 'INVALID_PROFILE_NAME', `Invalid profile name ${JSON.stringify(profile)}.`))
|
|
@@ -1268,6 +1320,7 @@ export function diagnose(options = {}) {
|
|
|
1268
1320
|
const thirdPartyRecords = dependencyNames
|
|
1269
1321
|
.map(name => records.get(name))
|
|
1270
1322
|
.filter(record => record !== undefined)
|
|
1323
|
+
const compatibilityChecks = new Map()
|
|
1271
1324
|
for (const record of thirdPartyRecords) {
|
|
1272
1325
|
inspectClientPackage(record, {
|
|
1273
1326
|
findings,
|
|
@@ -1277,7 +1330,7 @@ export function diagnose(options = {}) {
|
|
|
1277
1330
|
profile,
|
|
1278
1331
|
resolvePackage,
|
|
1279
1332
|
})
|
|
1280
|
-
inspectCompatibility(record, {
|
|
1333
|
+
const check = inspectCompatibility(record, {
|
|
1281
1334
|
findings,
|
|
1282
1335
|
commandRepair,
|
|
1283
1336
|
profile,
|
|
@@ -1285,6 +1338,7 @@ export function diagnose(options = {}) {
|
|
|
1285
1338
|
harnessPackages: harness.packages,
|
|
1286
1339
|
harnessPackagesAuthoritative: harness.authoritative,
|
|
1287
1340
|
})
|
|
1341
|
+
compatibilityChecks.set(record.requestedName ?? record.manifest.name, check)
|
|
1288
1342
|
inspectPluginNodeEngine(
|
|
1289
1343
|
record,
|
|
1290
1344
|
dshCli?.command?.[0] === process.execPath ? process.version : undefined,
|
|
@@ -1311,7 +1365,11 @@ export function diagnose(options = {}) {
|
|
|
1311
1365
|
installed: true,
|
|
1312
1366
|
client: record.manifest?.dsh?.client !== undefined,
|
|
1313
1367
|
bundle: record.manifest?.dsh?.bundle !== undefined,
|
|
1314
|
-
compatibility: pluginCompatibility(
|
|
1368
|
+
compatibility: pluginCompatibility(
|
|
1369
|
+
record,
|
|
1370
|
+
findings,
|
|
1371
|
+
compatibilityChecks.get(record.requestedName ?? record.manifest.name),
|
|
1372
|
+
),
|
|
1315
1373
|
}
|
|
1316
1374
|
}),
|
|
1317
1375
|
}, findings)
|
|
@@ -1400,7 +1458,7 @@ export function formatReport(report, options = {}) {
|
|
|
1400
1458
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
1401
1459
|
|
|
1402
1460
|
if (report.findings.length === 0 && unknownPackages.length === 0) {
|
|
1403
|
-
lines.push(paint('info', zh ? '正常 当前检查范围内未发现问题。' : 'OK No problems found by the
|
|
1461
|
+
lines.push(paint('info', zh ? '正常 当前检查范围内未发现问题。' : 'OK No problems found by the current checks.'))
|
|
1404
1462
|
} else {
|
|
1405
1463
|
if (problemPackages.length > 0) {
|
|
1406
1464
|
lines.push(zh
|
|
@@ -1459,8 +1517,8 @@ export function formatReport(report, options = {}) {
|
|
|
1459
1517
|
for (const plugin of unknownPackages) {
|
|
1460
1518
|
lines.push(`[${zh ? '未知' : 'UNKNOWN'}] ${plugin.name}${plugin.version ? ` ${plugin.version}` : ''}`)
|
|
1461
1519
|
appendReportField(lines, zh ? '原因' : 'Reason', zh
|
|
1462
|
-
? '插件没有声明当前 DSH
|
|
1463
|
-
: 'The plugin
|
|
1520
|
+
? '插件没有声明当前 DSH 的兼容范围,或 Doctor 无法解析声明所对应的当前版本。'
|
|
1521
|
+
: 'The plugin declares no range for the active DSH, or Doctor could not resolve the active version for a declared peer.')
|
|
1464
1522
|
appendReportField(lines, zh ? '建议' : 'Action', zh
|
|
1465
1523
|
? '升级 DSH 后请关注该插件的发布说明或向插件作者确认。'
|
|
1466
1524
|
: 'After a DSH upgrade, review the plugin release notes or ask its author to confirm compatibility.')
|
package/src/i18n.mjs
CHANGED
|
@@ -133,6 +133,7 @@ const ZH_SUGGESTIONS = {
|
|
|
133
133
|
INVALID_CLIENT_INJECT: update,
|
|
134
134
|
CLIENT_EXPORT_MISSING: update,
|
|
135
135
|
CLIENT_BUNDLE_MISSING: update,
|
|
136
|
+
CLIENT_BUNDLE_UNREADABLE: update,
|
|
136
137
|
UNDECLARED_CLIENT_REQUIRE: update,
|
|
137
138
|
CLIENT_EXTERNAL_WITHOUT_SUPPLIER: update,
|
|
138
139
|
REMOVED_CLIENT_INJECT: update,
|
package/src/repair.mjs
CHANGED
|
@@ -3,6 +3,9 @@ import { copyFileSync, readFileSync, renameSync, statSync, writeFileSync } from
|
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
import crossSpawn from 'cross-spawn'
|
|
5
5
|
|
|
6
|
+
export const COMMAND_TIMEOUT_MS = 10 * 60 * 1000
|
|
7
|
+
const COMMAND_MAX_BUFFER = 16 * 1024 * 1024
|
|
8
|
+
|
|
6
9
|
function hash(value) {
|
|
7
10
|
return createHash('sha256').update(value).digest('hex')
|
|
8
11
|
}
|
|
@@ -40,6 +43,16 @@ export function formatRepairPlan(actions, options = {}) {
|
|
|
40
43
|
: `${plan}${zh ? '执行这些修复吗?[y/N] ' : 'Apply these repairs? [y/N] '}`
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
export function formatRepairOutcome(actions, results, options = {}) {
|
|
47
|
+
if (results.length > 0) return ''
|
|
48
|
+
const zh = options.language === 'zh'
|
|
49
|
+
if (options.declined === true) return zh ? '修复结果:用户已取消。\n' : 'Repairs: cancelled by the user.\n'
|
|
50
|
+
if (actions.length > 0) return ''
|
|
51
|
+
return options.findingCount === 0
|
|
52
|
+
? zh ? '修复结果:当前无需修复。\n' : 'Repairs: no repairs are needed.\n'
|
|
53
|
+
: zh ? '修复结果:没有可安全自动执行的修复,请按诊断建议手动处理。\n' : 'Repairs: no safe automatic repairs are available; follow the diagnostic suggestions manually.\n'
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
function localizedDescription(action, zh) {
|
|
44
57
|
if (!zh) return action.description
|
|
45
58
|
if (action.id.startsWith('update-package:')) return `更新 profile ${action.profile} 中的 ${action.package}。`
|
|
@@ -90,11 +103,15 @@ function limitedOutput(value, limit = 8192) {
|
|
|
90
103
|
function applyCommand(action, options) {
|
|
91
104
|
const [command, ...args] = action.command
|
|
92
105
|
const captureOutput = options.captureOutput === true
|
|
106
|
+
const timeout = options.commandTimeoutMs ?? COMMAND_TIMEOUT_MS
|
|
107
|
+
if (!Number.isFinite(timeout) || timeout <= 0) throw new Error('command repair timeout must be a positive number')
|
|
93
108
|
const result = crossSpawn.sync(command, args, {
|
|
94
109
|
stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
95
|
-
...captureOutput ? { encoding: 'utf8' } : {},
|
|
110
|
+
...captureOutput ? { encoding: 'utf8', maxBuffer: COMMAND_MAX_BUFFER } : {},
|
|
96
111
|
env: action.env === undefined ? process.env : { ...process.env, ...action.env },
|
|
112
|
+
timeout,
|
|
97
113
|
})
|
|
114
|
+
if (result.error?.code === 'ETIMEDOUT') throw new Error(`${command} timed out after ${String(timeout)} ms`)
|
|
98
115
|
if (result.error != null) throw result.error
|
|
99
116
|
if (result.status !== 0) {
|
|
100
117
|
const reason = result.signal === null
|
|
@@ -115,7 +132,8 @@ function applyCommand(action, options) {
|
|
|
115
132
|
|
|
116
133
|
export function applyRepairs(actions, options = {}) {
|
|
117
134
|
const results = []
|
|
118
|
-
for (
|
|
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
|
}
|