@bruc3van/dsh-doctor 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DSH Doctor contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # DSH Doctor
2
+
3
+ DSH Doctor 用来定位 DeepSeek Harness 无法启动、配置损坏和第三方插件不兼容等常见问题。默认运行完全只读;只有显式使用 `--fix`,查看精确的文件或命令计划并确认后,它才会实施可回滚的修复。
4
+
5
+ 这是社区维护的第三方工具,不属于 DeepSeek 官方项目。它不会加载或执行待检查插件的代码。
6
+
7
+ ## 安装
8
+
9
+ 需要 Node.js `22.19+` 或 `24+`:
10
+
11
+ ```sh
12
+ npm install --global @bruc3van/dsh-doctor
13
+ dsh-doctor
14
+ ```
15
+
16
+ 也可以临时运行:
17
+
18
+ ```sh
19
+ npx @bruc3van/dsh-doctor
20
+ ```
21
+
22
+ 默认检查 `$DSH_HOME/profiles/web`;未设置 `DSH_HOME` 时使用 `~/.dsh`。
23
+
24
+ ## 常用命令
25
+
26
+ ```sh
27
+ # 只读诊断
28
+ dsh-doctor
29
+ dsh-doctor --profile web
30
+ dsh-doctor --home /path/to/.dsh
31
+
32
+ # 机器可读的只读报告,不显示提示
33
+ dsh-doctor --json
34
+
35
+ # 展示修复计划,确认后实施并重新诊断
36
+ dsh-doctor --fix
37
+
38
+ # 自动化环境中显式确认当前计划
39
+ dsh-doctor --fix --yes --json
40
+ ```
41
+
42
+ `--repair` 是 `--fix` 的别名。`--yes` 只有和 `--fix` 一起使用才有效。
43
+
44
+ ## 当前检查范围
45
+
46
+ - profile `package.json` 的 JSON 根结构、依赖表、bundle 列表和 reload 生命周期
47
+ - profile、home 和 bundle 的 `cordis.patch.yml` 语法与顶层结构,包括 `!!js` 表达式
48
+ - `settings.yaml` 和 `.credentials.yaml` 的安全结构检查;凭据诊断不输出秘密值
49
+ - profile 依赖、bundle 声明、patch 文件和 client bundle 是否存在
50
+ - `dsh.client` 的 `platform`、`immediately`、`inject`、`external` 和 `./client` export contract
51
+ - client bundle 中字面量 `require()` 与 external/module supplier 的一致性
52
+ - 已删除的 Harness client package 引用
53
+ - 第三方插件 peer range 与当前 Harness 实际版本的兼容性
54
+ - Harness installation 优先于 profile 同名 bundle 的真实解析顺序
55
+
56
+ ## 修复安全边界
57
+
58
+ 每条可执行修复都包含稳定 ID、风险级别、说明和精确目标:
59
+
60
+ - 文件修复在确认前展示路径,确认后再次校验 SHA-256 指纹。
61
+ - 写入前创建 `.dsh-doctor-<timestamp>.bak` 备份,再通过同目录临时文件原子替换。
62
+ - 外部命令使用固定 argv 调用,不拼接 shell 命令。
63
+ - 任一步失败即停止后续修复,并保留已经创建的备份。
64
+ - 完成后重新运行全部诊断,以最终状态决定退出码。
65
+
66
+ 首期只自动处理确定性操作,例如把已安装 bundle 恢复到 manifest 列表,或运行明确的 profile install/update。损坏 JSON/YAML、凭据值和插件移除只给建议,不猜测应该删除或改成什么。
67
+
68
+ ## 退出码
69
+
70
+ - `0`:没有阻断错误;warning 仍会显示
71
+ - `1`:发现可能阻断 Harness 启动的问题
72
+ - `2`:参数、运行环境或修复执行失败
73
+
74
+ ## 当前限制
75
+
76
+ - 静态扫描只识别代码中的字面量 `require("package")`;动态依赖需要未来的 bundle 元数据协议。
77
+ - 配置检查覆盖语法和 Doctor 能稳定对齐的结构,但不执行 `!!js`,也不启动第三方插件。
78
+ - 版本兼容以插件 `peerDependencies` 和当前可解析 Harness package 版本为依据;未声明兼容范围的插件只能做结构检查。
79
+ - 真实启动探针尚未默认启用,因为启动第三方插件可能产生网络、进程或持久化副作用。优先使用只读配置组合检查。
80
+
81
+ ## 从源码开发
82
+
83
+ ```sh
84
+ npm install
85
+ npm run check
86
+ node src/cli.mjs --help
87
+ ```
88
+
89
+ 新包需要先由 `@bruc3van` 对应的 npm 账号完成一次 `npm publish --access public`,创建公开包页面。然后在 npm 包设置中添加 GitHub Actions Trusted Publisher:Organization or user 为 `bruc3van`,Repository 为 `dsh-doctor`,Workflow filename 为 `release.yml`,Environment 留空,Allowed actions 只启用 `npm publish`。后续推送与 `package.json` 版本一致的 `vX.Y.Z` tag,workflow 会通过 OIDC 发布并由 npm 自动生成 provenance,不需要保存长期 npm token。
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@bruc3van/dsh-doctor",
3
+ "version": "0.1.0",
4
+ "description": "Diagnostics and confirmed recovery for DeepSeek Harness profiles and third-party plugins",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/bruc3van/dsh-doctor.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/bruc3van/dsh-doctor/issues"
13
+ },
14
+ "homepage": "https://github.com/bruc3van/dsh-doctor#readme",
15
+ "bin": {
16
+ "dsh-doctor": "src/cli.mjs"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "test": "node --test",
25
+ "check": "node --check src/cli.mjs && node --check src/doctor.mjs && node --check src/repair.mjs && npm test",
26
+ "prepublishOnly": "npm run check"
27
+ },
28
+ "dependencies": {
29
+ "cross-spawn": "^7.0.6",
30
+ "js-yaml": "^4.2.0",
31
+ "semver": "^7.7.2",
32
+ "yaml": "^2.8.1"
33
+ },
34
+ "engines": {
35
+ "node": "^22.19.0 || >=24.0.0"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org"
40
+ }
41
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { formatReport, diagnose } from './doctor.mjs'
4
+ import { applyRepairs, formatRepairPlan, repairsFromReport } from './repair.mjs'
5
+ import { readFileSync } from 'node:fs'
6
+ import { dirname, resolve } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { createInterface } from 'node:readline/promises'
9
+
10
+ const HELP = `Usage: dsh-doctor [options]
11
+
12
+ Diagnostics and confirmed recovery for a DeepSeek Harness profile.
13
+
14
+ Options:
15
+ --profile <name> profile to inspect (default: web)
16
+ --home <path> Harness home (default: $DSH_HOME or ~/.dsh)
17
+ --harness-root <path> active DeepSeek Harness source checkout
18
+ --json print machine-readable JSON
19
+ --fix, --repair preview and apply available repairs after confirmation
20
+ --yes confirm the displayed repair plan non-interactively
21
+ -h, --help show help
22
+ -v, --version show version
23
+ `
24
+
25
+ function fail(message) {
26
+ process.stderr.write(`dsh-doctor: ${message}\n\n${HELP}`)
27
+ process.exitCode = 2
28
+ }
29
+
30
+ function valueAfter(args, index, name) {
31
+ const value = args[index + 1]
32
+ if (value === undefined || value.startsWith('-')) throw new Error(`${name} needs a value`)
33
+ return value
34
+ }
35
+
36
+ function parse(args) {
37
+ const options = {}
38
+ for (let index = 0; index < args.length; index += 1) {
39
+ const arg = args[index]
40
+ if (arg === '--profile') options.profile = valueAfter(args, index++, arg)
41
+ else if (arg === '--home') options.home = valueAfter(args, index++, arg)
42
+ else if (arg === '--harness-root') options.harnessRoot = valueAfter(args, index++, arg)
43
+ else if (arg === '--json') options.json = true
44
+ else if (arg === '--fix' || arg === '--repair') options.fix = true
45
+ else if (arg === '--yes') options.yes = true
46
+ else if (arg === '--help' || arg === '-h') options.help = true
47
+ else if (arg === '--version' || arg === '-v') options.version = true
48
+ else throw new Error(`unknown option ${arg}`)
49
+ }
50
+ return options
51
+ }
52
+
53
+ async function main() {
54
+ let options
55
+ try {
56
+ options = parse(process.argv.slice(2))
57
+ } catch (error) {
58
+ fail(error instanceof Error ? error.message : String(error))
59
+ return
60
+ }
61
+ if (options.yes && !options.fix) throw new Error('--yes requires --fix')
62
+ if (options.json && options.fix && !options.yes) throw new Error('--json --fix requires --yes because a prompt would corrupt JSON output')
63
+ if (options.help) {
64
+ process.stdout.write(HELP)
65
+ } else if (options.version) {
66
+ const here = dirname(fileURLToPath(import.meta.url))
67
+ const manifest = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8'))
68
+ process.stdout.write(`${manifest.version}\n`)
69
+ } else {
70
+ let report = diagnose(options)
71
+ let repairs = []
72
+ if (options.fix) {
73
+ const actions = repairsFromReport(report)
74
+ if (actions.length > 0) {
75
+ let confirmed = options.yes === true
76
+ if (!confirmed) {
77
+ if (!process.stdin.isTTY) throw new Error('--fix needs an interactive terminal or explicit --yes')
78
+ const prompt = formatRepairPlan(actions)
79
+ const reader = createInterface({ input: process.stdin, output: process.stderr })
80
+ const answer = await reader.question(prompt)
81
+ reader.close()
82
+ confirmed = /^y(?:es)?$/i.test(answer.trim())
83
+ }
84
+ if (confirmed) {
85
+ repairs = applyRepairs(actions)
86
+ if (repairs.every(item => item.status === 'applied')) report = diagnose(options)
87
+ }
88
+ }
89
+ }
90
+ const output = options.fix ? { ...report, repairs } : report
91
+ process.stdout.write(options.json
92
+ ? `${JSON.stringify(output, null, 2)}\n`
93
+ : `${formatReport(report, { color: process.stdout.isTTY })}${repairs.length > 0 ? `Repairs: ${JSON.stringify(repairs, null, 2)}\n` : ''}`)
94
+ process.exitCode = repairs.some(item => item.status === 'failed') ? 2 : report.summary.errors > 0 ? 1 : 0
95
+ }
96
+ }
97
+
98
+ try {
99
+ await main()
100
+ } catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error)
102
+ if (process.argv.includes('--json')) {
103
+ process.stdout.write(`${JSON.stringify({ version: 1, operationalError: message }, null, 2)}\n`)
104
+ } else {
105
+ process.stderr.write(`dsh-doctor: ${message}\n`)
106
+ }
107
+ process.exitCode = 2
108
+ }
package/src/doctor.mjs ADDED
@@ -0,0 +1,803 @@
1
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
4
+ import yaml from 'js-yaml'
5
+ import semver from 'semver'
6
+ import { parseDocument } from 'yaml'
7
+
8
+ export const PLATFORM_MODULES = new Set([
9
+ 'react',
10
+ 'react/jsx-runtime',
11
+ 'react-dom',
12
+ 'react-dom/client',
13
+ '@deepseek-ai/cordis',
14
+ '@deepseek-ai/dsh-client-store',
15
+ '@deepseek-ai/dsh-client-ui-slots',
16
+ '@deepseek-ai/dsh-client-ui-primitives',
17
+ ])
18
+
19
+ const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 }
20
+ const JS_EXPRESSION = new yaml.Type('tag:yaml.org,2002:js', {
21
+ kind: 'scalar',
22
+ resolve: value => typeof value === 'string' && value.trim().length > 0,
23
+ construct: value => value,
24
+ })
25
+ const PATCH_SCHEMA = yaml.DEFAULT_SCHEMA.extend([JS_EXPRESSION])
26
+
27
+ function objectRecord(value) {
28
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined
29
+ }
30
+
31
+ function finding(severity, code, message, options = {}) {
32
+ return {
33
+ severity,
34
+ code,
35
+ message,
36
+ ...options.package === undefined ? {} : { package: options.package },
37
+ ...options.evidence === undefined ? {} : { evidence: options.evidence },
38
+ ...options.suggestion === undefined ? {} : { suggestion: options.suggestion },
39
+ ...options.repair === undefined ? {} : { repair: options.repair },
40
+ }
41
+ }
42
+
43
+ function readJson(file, subject, findings) {
44
+ let text
45
+ try {
46
+ text = readFileSync(file, 'utf8')
47
+ } catch (error) {
48
+ findings.push(finding('error', 'FILE_READ_FAILED', `Cannot read ${subject}.`, {
49
+ evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
50
+ }))
51
+ return undefined
52
+ }
53
+ try {
54
+ const parsed = JSON.parse(text)
55
+ if (objectRecord(parsed) === undefined) {
56
+ findings.push(finding('error', 'INVALID_JSON_OBJECT', `${subject} must contain a JSON object.`, {
57
+ evidence: file,
58
+ suggestion: 'Restore a valid package manifest object before starting Harness.',
59
+ }))
60
+ return undefined
61
+ }
62
+ return parsed
63
+ } catch (error) {
64
+ findings.push(finding('error', 'INVALID_JSON', `${subject} is not valid JSON.`, {
65
+ evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
66
+ suggestion: 'Repair the JSON before starting this profile.',
67
+ }))
68
+ return undefined
69
+ }
70
+ }
71
+
72
+ function packagePathParts(name) {
73
+ if (name.startsWith('@')) {
74
+ const parts = name.split('/')
75
+ return parts.length === 2
76
+ && /^@[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(parts[0])
77
+ && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(parts[1])
78
+ ? parts
79
+ : undefined
80
+ }
81
+ return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) ? [name] : undefined
82
+ }
83
+
84
+ function packageManifestAt(directory, expectedName, findings, source) {
85
+ const file = join(directory, 'package.json')
86
+ if (!existsSync(file)) return undefined
87
+ const manifest = readJson(file, `${source} package manifest`, findings)
88
+ if (manifest === undefined) return undefined
89
+ if (manifest.name !== expectedName) {
90
+ findings.push(finding('warning', 'PACKAGE_NAME_MISMATCH', `${source} resolves to a package named ${JSON.stringify(manifest.name)}.`, {
91
+ package: expectedName,
92
+ evidence: file,
93
+ suggestion: 'Reinstall the dependency so its directory and package name agree.',
94
+ }))
95
+ }
96
+ return { directory, file, manifest }
97
+ }
98
+
99
+ function findHarnessRoot(start) {
100
+ let current = resolve(start)
101
+ while (true) {
102
+ if (existsSync(join(current, 'pnpm-workspace.yaml'))
103
+ && existsSync(join(current, 'packages'))
104
+ && existsSync(join(current, 'apps'))) return current
105
+ const parent = dirname(current)
106
+ if (parent === current) return undefined
107
+ current = parent
108
+ }
109
+ }
110
+
111
+ function workspacePackageDirectories(root) {
112
+ const directories = []
113
+ const addChildren = (parent) => {
114
+ if (!existsSync(parent)) return
115
+ for (const entry of readdirSync(parent, { withFileTypes: true })) {
116
+ if (entry.isDirectory()) directories.push(join(parent, entry.name))
117
+ }
118
+ }
119
+ for (const group of ['packages', 'vendor']) {
120
+ const groupRoot = join(root, group)
121
+ if (!existsSync(groupRoot)) continue
122
+ for (const entry of readdirSync(groupRoot, { withFileTypes: true })) {
123
+ if (!entry.isDirectory()) continue
124
+ const first = join(groupRoot, entry.name)
125
+ if (existsSync(join(first, 'package.json'))) directories.push(first)
126
+ else addChildren(first)
127
+ }
128
+ }
129
+ addChildren(join(root, 'apps'))
130
+ return directories
131
+ }
132
+
133
+ function indexWorkspace(root, findings) {
134
+ const packages = new Map()
135
+ for (const directory of workspacePackageDirectories(root)) {
136
+ const file = join(directory, 'package.json')
137
+ if (!existsSync(file)) continue
138
+ const manifest = readJson(file, 'Harness workspace package manifest', findings)
139
+ if (typeof manifest?.name === 'string') packages.set(manifest.name, { directory, file, manifest })
140
+ }
141
+ return packages
142
+ }
143
+
144
+ function resolveHarnessContext(home, explicitRoot, findings) {
145
+ if (explicitRoot !== undefined) {
146
+ const root = resolve(explicitRoot)
147
+ if (!existsSync(join(root, 'package.json')) || !existsSync(join(root, 'packages'))) {
148
+ findings.push(finding('error', 'INVALID_HARNESS_ROOT', 'The configured Harness root is not a source checkout.', {
149
+ evidence: root,
150
+ suggestion: 'Pass the DeepSeek Harness repository root to --harness-root.',
151
+ }))
152
+ return { root, packages: new Map(), version: undefined, authoritative: true }
153
+ }
154
+ const manifest = readJson(join(root, 'package.json'), 'Harness root manifest', findings)
155
+ return { root, packages: indexWorkspace(root, findings), version: manifest?.version, authoritative: true }
156
+ }
157
+
158
+ const sharedDsh = join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh')
159
+ if (existsSync(sharedDsh)) {
160
+ const canonical = realpathSync(sharedDsh)
161
+ const root = findHarnessRoot(canonical)
162
+ if (root !== undefined) {
163
+ const manifest = readJson(join(root, 'package.json'), 'Harness root manifest', findings)
164
+ return { root, packages: indexWorkspace(root, findings), version: manifest?.version, authoritative: true }
165
+ }
166
+ const manifest = readJson(join(canonical, 'package.json'), 'installed Harness manifest', findings)
167
+ return { root: canonical, packages: new Map(), version: manifest?.version, authoritative: false }
168
+ }
169
+
170
+ findings.push(finding('warning', 'HARNESS_INSTALLATION_UNKNOWN', 'Could not locate the Harness installation used by this home.', {
171
+ evidence: sharedDsh,
172
+ suggestion: 'Pass --harness-root when diagnosing a source checkout.',
173
+ }))
174
+ return { root: undefined, packages: new Map(), version: undefined, authoritative: false }
175
+ }
176
+
177
+ function packageResolver(profileDir, home, harnessPackages, findings) {
178
+ const cache = new Map()
179
+ return (name) => {
180
+ if (cache.has(name)) return cache.get(name)
181
+ const parts = packagePathParts(name)
182
+ if (parts === undefined) {
183
+ cache.set(name, undefined)
184
+ return undefined
185
+ }
186
+ const candidates = [
187
+ join(profileDir, 'node_modules', ...parts),
188
+ join(home, 'profiles', 'node_modules', ...parts),
189
+ ]
190
+ for (const candidate of candidates) {
191
+ if (!existsSync(join(candidate, 'package.json'))) continue
192
+ const resolved = packageManifestAt(candidate, name, findings, 'installed')
193
+ cache.set(name, resolved)
194
+ return resolved
195
+ }
196
+ const workspace = harnessPackages.get(name)
197
+ cache.set(name, workspace)
198
+ return workspace
199
+ }
200
+ }
201
+
202
+ function bundleResolver(profileDir, home, harnessPackages, findings) {
203
+ return (name) => {
204
+ const workspace = harnessPackages.get(name)
205
+ if (workspace !== undefined) return workspace
206
+ const parts = packagePathParts(name)
207
+ if (parts === undefined) return undefined
208
+ for (const candidate of [
209
+ join(home, 'profiles', 'node_modules', ...parts),
210
+ join(profileDir, 'node_modules', ...parts),
211
+ ]) {
212
+ if (!existsSync(join(candidate, 'package.json'))) continue
213
+ return packageManifestAt(candidate, name, findings, 'bundle')
214
+ }
215
+ return undefined
216
+ }
217
+ }
218
+
219
+ function clientExport(manifest) {
220
+ const value = manifest?.exports?.['./client']
221
+ if (typeof value === 'string') return value
222
+ if (value !== null && typeof value === 'object') {
223
+ if (typeof value.default === 'string') return value.default
224
+ }
225
+ return undefined
226
+ }
227
+
228
+ function dependencyEntries(value, field, file, findings) {
229
+ if (value === undefined) return []
230
+ const record = objectRecord(value)
231
+ if (record === undefined || Object.values(record).some(item => typeof item !== 'string')) {
232
+ findings.push(finding('error', 'INVALID_DEPENDENCY_MAP', `${field} must be an object of package names to string ranges.`, {
233
+ evidence: file,
234
+ suggestion: `Repair ${field} before managing or starting this profile.`,
235
+ }))
236
+ return []
237
+ }
238
+ return Object.entries(record)
239
+ }
240
+
241
+ function updateRepair(profile, name) {
242
+ return {
243
+ id: `update-package:${name}`,
244
+ kind: 'command',
245
+ risk: 'medium',
246
+ description: `Update ${name} in profile ${profile}.`,
247
+ command: ['dsh', 'plugin', '--profile', profile, 'update', name],
248
+ }
249
+ }
250
+
251
+ function safePackageFile(packageDir, exported) {
252
+ if (!exported.startsWith('./')) return undefined
253
+ const file = resolve(packageDir, exported)
254
+ const rel = relative(packageDir, file)
255
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined
256
+ return file
257
+ }
258
+
259
+ export function extractStaticRequires(source) {
260
+ const values = new Set()
261
+ const code = codePositions(source)
262
+ const pattern = /\brequire\s*\(\s*(['"])([^'"\\\r\n]+)\1\s*\)/g
263
+ for (const match of source.matchAll(pattern)) {
264
+ if (!code[match.index]) continue
265
+ const specifier = match[2]
266
+ if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:')) continue
267
+ if (packagePathParts(specifier) !== undefined || specifier.startsWith('@')) values.add(specifier)
268
+ }
269
+ return [...values].sort()
270
+ }
271
+
272
+ function codePositions(source) {
273
+ const code = new Uint8Array(source.length)
274
+ let state = 'code'
275
+ for (let index = 0; index < source.length; index += 1) {
276
+ const char = source[index]
277
+ const next = source[index + 1]
278
+ if (state === 'code') {
279
+ if (char === '/' && next === '/') {
280
+ state = 'line-comment'
281
+ index += 1
282
+ } else if (char === '/' && next === '*') {
283
+ state = 'block-comment'
284
+ index += 1
285
+ } else if (char === "'" || char === '"' || char === '`') {
286
+ state = char
287
+ } else {
288
+ code[index] = 1
289
+ }
290
+ } else if (state === 'line-comment') {
291
+ if (char === '\n') {
292
+ state = 'code'
293
+ code[index] = 1
294
+ }
295
+ } else if (state === 'block-comment') {
296
+ if (char === '*' && next === '/') {
297
+ state = 'code'
298
+ index += 1
299
+ }
300
+ } else if (char === '\\') {
301
+ index += 1
302
+ } else if (char === state) {
303
+ state = 'code'
304
+ }
305
+ }
306
+ return code
307
+ }
308
+
309
+ function stripClientSuffix(specifier) {
310
+ return specifier.endsWith('/client') ? specifier.slice(0, -'/client'.length) : specifier
311
+ }
312
+
313
+ function stringArray(value) {
314
+ return Array.isArray(value) && value.every(item => typeof item === 'string') ? value : undefined
315
+ }
316
+
317
+ function inspectClientPackage(record, context) {
318
+ const {
319
+ findings, harnessPackages, harnessPackagesAuthoritative, profile, resolvePackage,
320
+ } = context
321
+ const name = record.manifest.name
322
+ const disableSuggestion = `Upgrade ${name}; if no compatible release exists, run dsh plugin --profile ${profile} remove ${name}.`
323
+ const declaration = record.manifest?.dsh?.client
324
+ if (declaration === undefined) return
325
+ if (declaration === null || typeof declaration !== 'object') {
326
+ findings.push(finding('error', 'INVALID_CLIENT_DECLARATION', `${name} has an invalid dsh.client declaration.`, {
327
+ package: name,
328
+ evidence: record.file,
329
+ suggestion: disableSuggestion,
330
+ repair: updateRepair(profile, name),
331
+ }))
332
+ return
333
+ }
334
+
335
+ if (typeof declaration.platform !== 'string') {
336
+ findings.push(finding('error', 'INVALID_CLIENT_PLATFORM', `${name} dsh.client.platform must be a string.`, {
337
+ package: name,
338
+ evidence: record.file,
339
+ suggestion: disableSuggestion,
340
+ repair: updateRepair(profile, name),
341
+ }))
342
+ return
343
+ }
344
+ if (declaration.immediately !== undefined && typeof declaration.immediately !== 'boolean') {
345
+ findings.push(finding('error', 'INVALID_CLIENT_IMMEDIATELY', `${name} dsh.client.immediately must be a boolean.`, {
346
+ package: name,
347
+ evidence: record.file,
348
+ suggestion: disableSuggestion,
349
+ repair: updateRepair(profile, name),
350
+ }))
351
+ }
352
+ if (declaration.platform !== 'web') return
353
+
354
+ const external = stringArray(declaration.external)
355
+ const inject = stringArray(declaration.inject)
356
+ if (declaration.external !== undefined && external === undefined) {
357
+ findings.push(finding('error', 'INVALID_CLIENT_EXTERNAL', `${name} dsh.client.external must be a string array.`, {
358
+ package: name,
359
+ evidence: record.file,
360
+ suggestion: disableSuggestion,
361
+ repair: updateRepair(profile, name),
362
+ }))
363
+ }
364
+ if (declaration.inject !== undefined && inject === undefined) {
365
+ findings.push(finding('error', 'INVALID_CLIENT_INJECT', `${name} dsh.client.inject must be a string array.`, {
366
+ package: name,
367
+ evidence: record.file,
368
+ suggestion: disableSuggestion,
369
+ repair: updateRepair(profile, name),
370
+ }))
371
+ }
372
+
373
+ const exported = clientExport(record.manifest)
374
+ if (exported === undefined) {
375
+ findings.push(finding('error', 'CLIENT_EXPORT_MISSING', `${name} declares dsh.client but exports no ./client entry.`, {
376
+ package: name,
377
+ evidence: record.file,
378
+ suggestion: disableSuggestion,
379
+ }))
380
+ return
381
+ }
382
+ const file = safePackageFile(record.directory, exported)
383
+ if (file === undefined || !existsSync(file) || !statSync(file).isFile()) {
384
+ findings.push(finding('error', 'CLIENT_BUNDLE_MISSING', `${name} client bundle is missing.`, {
385
+ package: name,
386
+ evidence: file ?? `${record.file}: exports["./client"] = ${JSON.stringify(exported)}`,
387
+ suggestion: `Reinstall or rebuild ${name}; if it remains broken, run dsh plugin --profile ${profile} remove ${name}.`,
388
+ repair: updateRepair(profile, name),
389
+ }))
390
+ return
391
+ }
392
+
393
+ let source
394
+ try {
395
+ source = readFileSync(file, 'utf8')
396
+ } catch (error) {
397
+ findings.push(finding('error', 'CLIENT_BUNDLE_UNREADABLE', `${name} client bundle cannot be read.`, {
398
+ package: name,
399
+ evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
400
+ }))
401
+ return
402
+ }
403
+
404
+ const declared = new Set(external ?? [])
405
+ for (const specifier of extractStaticRequires(source)) {
406
+ if (PLATFORM_MODULES.has(specifier) || specifier === name || stripClientSuffix(specifier) === name) continue
407
+ if (!declared.has(specifier)) {
408
+ findings.push(finding('error', 'UNDECLARED_CLIENT_REQUIRE', `${name} requires ${specifier} but does not declare it in dsh.client.external.`, {
409
+ package: name,
410
+ evidence: file,
411
+ suggestion: disableSuggestion,
412
+ repair: updateRepair(profile, name),
413
+ }))
414
+ }
415
+ }
416
+
417
+ for (const specifier of declared) {
418
+ if (PLATFORM_MODULES.has(specifier)) {
419
+ findings.push(finding('warning', 'REDUNDANT_CLIENT_EXTERNAL', `${name} declares platform module ${specifier} as an external.`, {
420
+ package: name,
421
+ evidence: record.file,
422
+ suggestion: 'The plugin author should remove this redundant dsh.client.external entry.',
423
+ }))
424
+ continue
425
+ }
426
+ const supplier = stripClientSuffix(specifier)
427
+ const supplied = harnessPackagesAuthoritative
428
+ ? harnessPackages.get(supplier)
429
+ : harnessPackages.get(supplier) ?? resolvePackage(supplier)
430
+ if (supplied === undefined || supplied.manifest?.dsh?.client === undefined) {
431
+ findings.push(finding('error', 'CLIENT_EXTERNAL_WITHOUT_SUPPLIER', `${name} requests ${specifier}, but the active Harness has no client module supplier.`, {
432
+ package: name,
433
+ evidence: record.file,
434
+ suggestion: disableSuggestion,
435
+ repair: updateRepair(profile, name),
436
+ }))
437
+ }
438
+ }
439
+
440
+ if (harnessPackagesAuthoritative) {
441
+ for (const dependency of inject ?? []) {
442
+ if (!dependency.startsWith('@deepseek-ai/dsh-')) continue
443
+ if (!harnessPackages.has(stripClientSuffix(dependency))) {
444
+ findings.push(finding('error', 'REMOVED_CLIENT_INJECT', `${name} injects ${dependency}, which is absent from the active Harness source tree.`, {
445
+ package: name,
446
+ evidence: record.file,
447
+ suggestion: disableSuggestion,
448
+ repair: updateRepair(profile, name),
449
+ }))
450
+ }
451
+ }
452
+
453
+ const legacyPeers = Object.keys(record.manifest.peerDependencies ?? {})
454
+ .filter(peer => peer.startsWith('@deepseek-ai/dsh-') && !harnessPackages.has(peer))
455
+ .sort()
456
+ if (legacyPeers.length > 0) {
457
+ findings.push(finding('warning', 'LEGACY_HARNESS_PEERS', `${name} still declares Harness packages that no longer exist in the active source tree.`, {
458
+ package: name,
459
+ evidence: legacyPeers.join(', '),
460
+ suggestion: 'Treat this plugin as compatibility-risky and update it before the next Harness upgrade.',
461
+ }))
462
+ }
463
+ }
464
+ }
465
+
466
+ function inspectBundle(name, record, findings) {
467
+ if (record === undefined) {
468
+ findings.push(finding('error', 'BUNDLE_NOT_INSTALLED', `Profile bundle ${name} is not installed.`, {
469
+ package: name,
470
+ suggestion: `Run dsh plugin --profile <profile> install, upgrade the bundle, or remove it from the profile.`,
471
+ }))
472
+ return
473
+ }
474
+ const patch = record.manifest?.dsh?.bundle?.patch
475
+ if (typeof patch !== 'string' || patch.length === 0) {
476
+ findings.push(finding('error', 'BUNDLE_DECLARATION_MISSING', `${name} is listed as a profile bundle but declares no dsh.bundle.patch.`, {
477
+ package: name,
478
+ evidence: record.file,
479
+ suggestion: 'Upgrade or remove this bundle from dsh.profile.bundles.',
480
+ }))
481
+ return
482
+ }
483
+ const file = safePackageFile(record.directory, patch)
484
+ if (file === undefined || !existsSync(file) || !statSync(file).isFile()) {
485
+ findings.push(finding('error', 'BUNDLE_PATCH_MISSING', `${name} bundle patch is missing.`, {
486
+ package: name,
487
+ evidence: file ?? `${record.file}: dsh.bundle.patch = ${JSON.stringify(patch)}`,
488
+ suggestion: 'Reinstall or upgrade this bundle, or remove it from the profile.',
489
+ }))
490
+ } else inspectPatchFile(file, `${name} bundle patch`, findings)
491
+ }
492
+
493
+ function inspectPatchFile(file, subject, findings) {
494
+ let parsed
495
+ try {
496
+ parsed = yaml.load(readFileSync(file, 'utf8'), { schema: PATCH_SCHEMA })
497
+ } catch (error) {
498
+ findings.push(finding('error', 'INVALID_PATCH_YAML', `${subject} cannot be parsed.`, {
499
+ evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
500
+ suggestion: 'Repair the YAML syntax before starting this profile.',
501
+ }))
502
+ return
503
+ }
504
+ if (!Array.isArray(parsed) || parsed.some(item => objectRecord(item) === undefined)) {
505
+ findings.push(finding('error', 'INVALID_PATCH_LIST', `${subject} must be a top-level YAML array of mappings.`, {
506
+ evidence: file,
507
+ suggestion: 'Repair the patch structure before starting this profile.',
508
+ }))
509
+ }
510
+ }
511
+
512
+ function inspectSettings(file, findings) {
513
+ if (!existsSync(file)) return
514
+ let root
515
+ try {
516
+ if (file.endsWith('.json')) root = JSON.parse(readFileSync(file, 'utf8'))
517
+ else {
518
+ const document = parseDocument(readFileSync(file, 'utf8'), { prettyErrors: true })
519
+ if (document.errors.length > 0) {
520
+ const positions = document.errors.map(error => {
521
+ const at = error.linePos?.[0]
522
+ return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}`
523
+ })
524
+ throw new Error(positions.join('; '))
525
+ }
526
+ root = document.toJS() ?? {}
527
+ }
528
+ } catch (error) {
529
+ findings.push(finding('error', 'INVALID_SETTINGS_DOCUMENT', 'Harness settings cannot be parsed.', {
530
+ evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
531
+ suggestion: 'Repair the settings syntax; Doctor will not guess credential or model values.',
532
+ }))
533
+ return
534
+ }
535
+ if (objectRecord(root) === undefined) {
536
+ findings.push(finding('error', 'INVALID_SETTINGS_ROOT', 'Harness settings must be a map of namespace sections.', {
537
+ evidence: file,
538
+ suggestion: 'Replace the root scalar or array with a mapping.',
539
+ }))
540
+ }
541
+ }
542
+
543
+ function inspectCredentials(file, findings) {
544
+ if (!existsSync(file)) return
545
+ let root
546
+ try {
547
+ const document = parseDocument(readFileSync(file, 'utf8'), { prettyErrors: true, uniqueKeys: true })
548
+ if (document.errors.length > 0) {
549
+ const positions = document.errors.map(error => {
550
+ const at = error.linePos?.[0]
551
+ return `${error.code}${at === undefined ? '' : ` at line ${String(at.line)}, column ${String(at.col)}`}`
552
+ })
553
+ throw new Error(positions.join('; '))
554
+ }
555
+ root = document.toJS() ?? {}
556
+ } catch (error) {
557
+ findings.push(finding('error', 'INVALID_CREDENTIALS_DOCUMENT', 'Harness credentials cannot be parsed.', {
558
+ evidence: `${file}: ${error instanceof Error ? error.message : String(error)}`,
559
+ suggestion: 'Repair only the reported structure; Doctor never prints or rewrites secret values.',
560
+ }))
561
+ return
562
+ }
563
+ const fields = objectRecord(root)
564
+ if (fields === undefined) {
565
+ findings.push(finding('error', 'INVALID_CREDENTIALS_ROOT', 'Harness credentials must be a mapping.', { evidence: file }))
566
+ return
567
+ }
568
+ const keys = Object.keys(fields)
569
+ if (keys.length === 0) return
570
+ if (fields.version !== 1 || keys.some(key => !['version', 'refs', 'records'].includes(key))) {
571
+ findings.push(finding('error', 'INVALID_CREDENTIALS_LAYOUT', 'Harness credentials do not use the supported version 1 layout.', {
572
+ evidence: file,
573
+ suggestion: 'Migrate the document structure without exposing or changing its secret values.',
574
+ }))
575
+ }
576
+ }
577
+
578
+ function inspectPatchFileIfPresent(file, subject, findings) {
579
+ if (existsSync(file)) inspectPatchFile(file, subject, findings)
580
+ }
581
+
582
+ function inspectCompatibility(record, context) {
583
+ const { findings, harnessPackages, profile, resolvePackage } = context
584
+ const peers = dependencyEntries(record.manifest.peerDependencies, 'peerDependencies', record.file, findings)
585
+ const mismatches = []
586
+ for (const [name, range] of peers) {
587
+ if (!name.startsWith('@deepseek-ai/') && name !== 'cordis') continue
588
+ const supplier = harnessPackages.get(name) ?? resolvePackage(name)
589
+ if (supplier === undefined) continue
590
+ const version = supplier.manifest?.version
591
+ if (typeof version !== 'string' || semver.valid(version) === null || semver.validRange(range) === null) continue
592
+ if (semver.satisfies(version, range, { includePrerelease: true })) continue
593
+ mismatches.push(`${name} ${range} (active ${version})`)
594
+ }
595
+ if (mismatches.length > 0) {
596
+ findings.push(finding('warning', 'HARNESS_PEER_VERSION_MISMATCH', `${record.manifest.name} has Harness peer ranges that do not accept the active versions.`, {
597
+ package: record.manifest.name,
598
+ evidence: mismatches.join(', '),
599
+ suggestion: `Update ${record.manifest.name} to a release compatible with the active Harness.`,
600
+ repair: updateRepair(profile, record.manifest.name),
601
+ }))
602
+ }
603
+ }
604
+
605
+ export function defaultDshHome(env = process.env) {
606
+ const configured = env.DSH_HOME
607
+ if (configured !== undefined && configured.trim().length > 0) {
608
+ if (configured === '~') return homedir()
609
+ if (configured.startsWith('~/') || configured.startsWith('~\\')) return resolve(homedir(), configured.slice(2))
610
+ return resolve(configured)
611
+ }
612
+ return join(homedir(), '.dsh')
613
+ }
614
+
615
+ export function diagnose(options = {}) {
616
+ const findings = []
617
+ const home = resolve(options.home ?? defaultDshHome(options.env))
618
+ const profile = options.profile ?? 'web'
619
+ if (profile === '' || profile === '.' || profile === '..' || profile.includes('/') || profile.includes('\\')) {
620
+ findings.push(finding('error', 'INVALID_PROFILE_NAME', `Invalid profile name ${JSON.stringify(profile)}.`))
621
+ return finish({ home, profile, profileDir: undefined, harness: {}, packages: [] }, findings)
622
+ }
623
+ const profileDir = join(home, 'profiles', profile)
624
+ const profileManifestFile = join(profileDir, 'package.json')
625
+ if (!existsSync(profileManifestFile)) {
626
+ findings.push(finding('error', 'PROFILE_NOT_FOUND', `Profile ${profile} does not exist.`, {
627
+ evidence: profileDir,
628
+ suggestion: `Start the profile once or initialize it with dsh plugin --profile ${profile} install.`,
629
+ }))
630
+ return finish({ home, profile, profileDir, harness: {}, packages: [] }, findings)
631
+ }
632
+ const profileManifest = readJson(profileManifestFile, `profile ${profile} manifest`, findings)
633
+ if (profileManifest === undefined) {
634
+ return finish({ home, profile, profileDir, harness: {}, packages: [] }, findings)
635
+ }
636
+
637
+ const harness = resolveHarnessContext(home, options.harnessRoot, findings)
638
+ const resolvePackage = packageResolver(profileDir, home, harness.packages, findings)
639
+ const resolveBundle = bundleResolver(profileDir, home, harness.packages, findings)
640
+ const dependencyEntriesList = dependencyEntries(profileManifest.dependencies, 'dependencies', profileManifestFile, findings)
641
+ const dependencyNames = dependencyEntriesList.map(([name]) => name)
642
+ const bundles = profileManifest?.dsh?.profile?.bundles
643
+ if (bundles !== undefined && (!Array.isArray(bundles) || !bundles.every(item => typeof item === 'string'))) {
644
+ findings.push(finding('error', 'INVALID_BUNDLE_LIST', 'dsh.profile.bundles must be a string array.', {
645
+ evidence: profileManifestFile,
646
+ suggestion: 'Repair the profile manifest before starting Harness.',
647
+ }))
648
+ }
649
+ const bundleNames = Array.isArray(bundles) ? bundles.filter(item => typeof item === 'string') : []
650
+ const patchReload = profileManifest?.dsh?.profile?.patchReload
651
+ if (patchReload !== undefined && patchReload !== 'live' && patchReload !== 'startup') {
652
+ findings.push(finding('error', 'INVALID_PATCH_RELOAD', 'dsh.profile.patchReload must be "live" or "startup".', {
653
+ evidence: profileManifestFile,
654
+ suggestion: 'Choose the reload lifecycle intended for this profile.',
655
+ }))
656
+ }
657
+
658
+ const records = new Map()
659
+ for (const name of dependencyNames) {
660
+ const record = resolvePackage(name)
661
+ if (record !== undefined) records.set(name, record)
662
+ }
663
+ const bundleRecords = new Map()
664
+ for (const name of bundleNames) {
665
+ const record = resolveBundle(name)
666
+ if (record !== undefined) bundleRecords.set(name, record)
667
+ }
668
+ for (const name of dependencyNames) {
669
+ const record = records.get(name)
670
+ if (record === undefined) {
671
+ findings.push(finding('error', 'DEPENDENCY_NOT_INSTALLED', `Profile dependency ${name} is not installed.`, {
672
+ package: name,
673
+ evidence: profileManifestFile,
674
+ suggestion: `Run dsh plugin --profile ${profile} install.`,
675
+ repair: {
676
+ id: `install-profile:${profile}`,
677
+ kind: 'command',
678
+ risk: 'medium',
679
+ description: `Install the declared dependencies for profile ${profile}.`,
680
+ command: ['dsh', 'plugin', '--profile', profile, 'install'],
681
+ },
682
+ }))
683
+ continue
684
+ }
685
+ const declared = dependencyEntriesList.find(([dependency]) => dependency === name)?.[1]
686
+ const installed = record.manifest?.version
687
+ if (typeof declared === 'string' && typeof installed === 'string'
688
+ && semver.validRange(declared) !== null && semver.valid(installed) !== null
689
+ && !semver.satisfies(installed, declared, { includePrerelease: true })) {
690
+ findings.push(finding('warning', 'PROFILE_DEPENDENCY_VERSION_MISMATCH', `Profile requests ${name} ${declared}, but ${installed} is installed.`, {
691
+ package: name,
692
+ evidence: record.file,
693
+ suggestion: `Reconcile the profile installation with dsh plugin --profile ${profile} install.`,
694
+ repair: {
695
+ id: `install-profile:${profile}`,
696
+ kind: 'command',
697
+ risk: 'medium',
698
+ description: `Install the declared dependencies for profile ${profile}.`,
699
+ command: ['dsh', 'plugin', '--profile', profile, 'install'],
700
+ },
701
+ }))
702
+ }
703
+ if (record.manifest?.dsh?.bundle?.patch !== undefined && !bundleNames.includes(name)) {
704
+ findings.push(finding('warning', 'INSTALLED_BUNDLE_INACTIVE', `${name} is installed as a bundle but is absent from dsh.profile.bundles.`, {
705
+ package: name,
706
+ evidence: profileManifestFile,
707
+ suggestion: 'Re-run the matching dsh plugin add/update command or remove the unused dependency.',
708
+ repair: {
709
+ id: `activate-bundle:${name}`,
710
+ kind: 'json-edit',
711
+ risk: 'low',
712
+ description: `Add ${name} to dsh.profile.bundles.`,
713
+ file: profileManifestFile,
714
+ operation: { type: 'add-bundle', name },
715
+ },
716
+ }))
717
+ }
718
+ }
719
+ for (const name of bundleNames) inspectBundle(name, bundleRecords.get(name), findings)
720
+
721
+ inspectPatchFileIfPresent(join(profileDir, 'cordis.patch.yml'), 'profile patch', findings)
722
+ inspectPatchFileIfPresent(join(home, 'cordis.patch.yml'), 'home patch', findings)
723
+ inspectSettings(join(home, 'settings.yaml'), findings)
724
+ inspectCredentials(join(home, '.credentials.yaml'), findings)
725
+
726
+ const thirdPartyRecords = dependencyNames
727
+ .map(name => records.get(name))
728
+ .filter(record => record !== undefined)
729
+ for (const record of thirdPartyRecords) {
730
+ inspectClientPackage(record, {
731
+ findings,
732
+ harnessPackages: harness.packages,
733
+ harnessPackagesAuthoritative: harness.authoritative,
734
+ profile,
735
+ resolvePackage,
736
+ })
737
+ inspectCompatibility(record, {
738
+ findings,
739
+ profile,
740
+ resolvePackage,
741
+ harnessPackages: harness.packages,
742
+ })
743
+ }
744
+
745
+ return finish({
746
+ home,
747
+ profile,
748
+ profileDir,
749
+ harness: { root: harness.root, version: harness.version },
750
+ packages: thirdPartyRecords.map(record => ({
751
+ name: record.manifest.name,
752
+ version: record.manifest.version,
753
+ directory: record.directory,
754
+ client: record.manifest?.dsh?.client !== undefined,
755
+ bundle: record.manifest?.dsh?.bundle !== undefined,
756
+ })),
757
+ }, findings)
758
+ }
759
+
760
+ function finish(context, findings) {
761
+ findings.sort((left, right) => {
762
+ const severity = SEVERITY_ORDER[left.severity] - SEVERITY_ORDER[right.severity]
763
+ return severity || left.code.localeCompare(right.code) || left.message.localeCompare(right.message)
764
+ })
765
+ const summary = {
766
+ errors: findings.filter(item => item.severity === 'error').length,
767
+ warnings: findings.filter(item => item.severity === 'warning').length,
768
+ info: findings.filter(item => item.severity === 'info').length,
769
+ }
770
+ return { version: 1, ok: summary.errors === 0, context, summary, findings }
771
+ }
772
+
773
+ export function formatReport(report, options = {}) {
774
+ const color = options.color ?? false
775
+ const paint = (severity, value) => {
776
+ if (!color) return value
777
+ const code = severity === 'error' ? 31 : severity === 'warning' ? 33 : 36
778
+ return `\u001B[${code}m${value}\u001B[0m`
779
+ }
780
+ const lines = [
781
+ 'DSH Doctor',
782
+ `Profile: ${report.context.profile}`,
783
+ `Home: ${report.context.home}`,
784
+ `Harness: ${report.context.harness.version ?? 'unknown'}${report.context.harness.root ? ` (${report.context.harness.root})` : ''}`,
785
+ `Checked third-party packages: ${String(report.context.packages.length)}`,
786
+ '',
787
+ ]
788
+ if (report.findings.length === 0) {
789
+ lines.push(paint('info', 'OK No problems found by the MVP checks.'))
790
+ } else {
791
+ for (const item of report.findings) {
792
+ const label = item.severity === 'error' ? 'ERROR' : item.severity === 'warning' ? 'WARN ' : 'INFO '
793
+ lines.push(paint(item.severity, `${label} [${item.code}] ${item.message}`))
794
+ if (item.package !== undefined) lines.push(` Package: ${item.package}`)
795
+ if (item.evidence !== undefined) lines.push(` Evidence: ${item.evidence}`)
796
+ if (item.suggestion !== undefined) lines.push(` Action: ${item.suggestion}`)
797
+ lines.push('')
798
+ }
799
+ }
800
+ lines.push(`Summary: ${String(report.summary.errors)} error(s), ${String(report.summary.warnings)} warning(s)`)
801
+ if (report.summary.errors > 0) lines.push('Harness may fail to start. Upgrade or disable the error-producing plugin first.')
802
+ return `${lines.join('\n')}\n`
803
+ }
package/src/repair.mjs ADDED
@@ -0,0 +1,82 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { copyFileSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join } from 'node:path'
4
+ import crossSpawn from 'cross-spawn'
5
+
6
+ function hash(value) {
7
+ return createHash('sha256').update(value).digest('hex')
8
+ }
9
+
10
+ export function repairsFromReport(report) {
11
+ const unique = new Map()
12
+ for (const item of report.findings) {
13
+ if (item.repair !== undefined && !unique.has(item.repair.id)) unique.set(item.repair.id, item.repair)
14
+ }
15
+ return [...unique.values()].sort((left, right) => {
16
+ if (left.kind === right.kind) return left.id.localeCompare(right.id)
17
+ return left.kind === 'json-edit' ? -1 : 1
18
+ }).map(action => {
19
+ if (action.kind !== 'json-edit') return action
20
+ const text = readFileSync(action.file, 'utf8')
21
+ return { ...action, expectedHash: hash(text) }
22
+ })
23
+ }
24
+
25
+ export function formatRepairPlan(actions) {
26
+ const lines = ['Proposed repairs:', '']
27
+ actions.forEach((action, index) => {
28
+ lines.push(`${String(index + 1)}. [${action.risk.toUpperCase()}] ${action.description}`)
29
+ if (action.kind === 'command') lines.push(` Command: ${action.command.map(quoteArgument).join(' ')}`)
30
+ else lines.push(` File: ${action.file}`, ` Backup: ${action.file}.dsh-doctor-<timestamp>.bak`)
31
+ lines.push('')
32
+ })
33
+ return `${lines.join('\n')}Apply these repairs? [y/N] `
34
+ }
35
+
36
+ function quoteArgument(value) {
37
+ return /^[a-zA-Z0-9_@./:-]+$/.test(value) ? value : JSON.stringify(value)
38
+ }
39
+
40
+ function applyJsonEdit(action) {
41
+ const current = readFileSync(action.file, 'utf8')
42
+ if (hash(current) !== action.expectedHash) {
43
+ throw new Error(`${action.file} changed after the repair preview; diagnose again before applying`)
44
+ }
45
+ const manifest = JSON.parse(current)
46
+ if (action.operation.type !== 'add-bundle') throw new Error(`unsupported JSON repair ${action.operation.type}`)
47
+ const bundles = manifest?.dsh?.profile?.bundles
48
+ if (!Array.isArray(bundles)) throw new Error(`${action.file} no longer has a valid dsh.profile.bundles array`)
49
+ if (!bundles.includes(action.operation.name)) bundles.push(action.operation.name)
50
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
51
+ const backup = `${action.file}.dsh-doctor-${stamp}.bak`
52
+ copyFileSync(action.file, backup)
53
+ const temporary = join(dirname(action.file), `.dsh-doctor-${process.pid}-${randomBytes(6).toString('hex')}.tmp`)
54
+ writeFileSync(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { mode: statSync(action.file).mode })
55
+ renameSync(temporary, action.file)
56
+ return { id: action.id, status: 'applied', backup }
57
+ }
58
+
59
+ function applyCommand(action) {
60
+ const [command, ...args] = action.command
61
+ const result = crossSpawn.sync(command, args, { stdio: 'inherit' })
62
+ if (result.error != null) throw result.error
63
+ if (result.status !== 0) throw new Error(`${command} exited with status ${String(result.status)}`)
64
+ return { id: action.id, status: 'applied' }
65
+ }
66
+
67
+ export function applyRepairs(actions) {
68
+ const results = []
69
+ for (const action of actions) {
70
+ try {
71
+ results.push(action.kind === 'json-edit' ? applyJsonEdit(action) : applyCommand(action))
72
+ } catch (error) {
73
+ results.push({
74
+ id: action.id,
75
+ status: 'failed',
76
+ error: error instanceof Error ? error.message : String(error),
77
+ })
78
+ break
79
+ }
80
+ }
81
+ return results
82
+ }