@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 +21 -0
- package/README.md +89 -0
- package/package.json +41 -0
- package/src/cli.mjs +108 -0
- package/src/doctor.mjs +803 -0
- package/src/repair.mjs +82 -0
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
|
+
}
|