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