@moonquake2004/dsh-doctor 0.1.1 → 0.2.2
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/checks.json +65 -0
- package/dsh-doctor.mjs +344 -25
- package/package.json +3 -2
package/checks.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"description": "dsh-doctor remote check catalog — 规则是数据,不是代码。新检查只需在此文件追加一条 JSON,已装实例会在缓存 TTL(6h)内自动生效,无需重装插件。",
|
|
4
|
+
"checks": [
|
|
5
|
+
{
|
|
6
|
+
"id": "E7-dsh-in-path",
|
|
7
|
+
"section": "env",
|
|
8
|
+
"severity": "error",
|
|
9
|
+
"title": "dsh 可执行文件在 PATH 中",
|
|
10
|
+
"discussion": "#1270",
|
|
11
|
+
"when": { "os": ["darwin", "linux", "win32"] },
|
|
12
|
+
"probe": { "type": "command-exists", "cmd": "dsh" },
|
|
13
|
+
"detailOk": "dsh 在 PATH 中可用",
|
|
14
|
+
"detailFail": "dsh 不在 PATH(插件/CLI 安装链路不可用,#1270 同族)",
|
|
15
|
+
"fix": "安装 dsh 并将 node_modules/.bin 加入 PATH"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "E8-npmrc-workspace-flag",
|
|
19
|
+
"section": "env",
|
|
20
|
+
"severity": "warn",
|
|
21
|
+
"title": "workspace-root profile 的 .npmrc 安装开关",
|
|
22
|
+
"discussion": "#20",
|
|
23
|
+
"when": { "os": ["darwin", "linux", "win32"] },
|
|
24
|
+
"probe": {
|
|
25
|
+
"type": "text-contains",
|
|
26
|
+
"path": "{profile}/.npmrc",
|
|
27
|
+
"pattern": "ignore-workspace-root-check\\s*=\\s*true",
|
|
28
|
+
"flags": ""
|
|
29
|
+
},
|
|
30
|
+
"detailOk": "profile .npmrc 已含 ignore-workspace-root-check=true(dsh-market #20 workaround 在位)",
|
|
31
|
+
"detailFail": "profile .npmrc 缺 ignore-workspace-root-check=true——workspace-root profile 上 dsh plugin 安装/更新会报 ERR_PNPM_ADDING_TO_ROOT(dsh-market #20)",
|
|
32
|
+
"fix": "在 {profile}/.npmrc 追加 ignore-workspace-root-check=true"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "P6-patch-name-space",
|
|
36
|
+
"section": "profile",
|
|
37
|
+
"severity": "error",
|
|
38
|
+
"title": "patch insert name 含空格(Windows 参数 lint)",
|
|
39
|
+
"discussion": "#1420",
|
|
40
|
+
"when": { "os": ["darwin", "linux", "win32"] },
|
|
41
|
+
"probe": {
|
|
42
|
+
"type": "text-not-contains",
|
|
43
|
+
"path": "{profile}/cordis.patch.yml",
|
|
44
|
+
"pattern": "^\\s*name:\\s*['\"]?[^'\"\\s]+[^'\"\\n]*\\s[^'\"\\n]+",
|
|
45
|
+
"flags": "m",
|
|
46
|
+
"required": false
|
|
47
|
+
},
|
|
48
|
+
"detailOk": "用户 patch 的 insert name 均无空格",
|
|
49
|
+
"detailFail": "用户 patch 存在含空格的 insert name(Windows 下 spawn 参数解析会断,#1420 待实现 lint 的目录版)",
|
|
50
|
+
"fix": "把含空格的 insert name 改为不含空格的标识符"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"id": "E9-storages-json-valid",
|
|
54
|
+
"section": "env",
|
|
55
|
+
"severity": "error",
|
|
56
|
+
"title": "config 目录 JSON 文件合法性",
|
|
57
|
+
"discussion": "#1357",
|
|
58
|
+
"when": { "os": ["darwin", "linux", "win32"] },
|
|
59
|
+
"probe": { "type": "json-valid", "path": "{home}/config/workspace.json", "required": false },
|
|
60
|
+
"detailOk": "config/workspace.json 为合法 JSON",
|
|
61
|
+
"detailFail": "config/workspace.json 不是合法 JSON(#1357 并发写乱码类,工作区列表会消失)",
|
|
62
|
+
"fix": "排查多实例并发写;修复或删除损坏文件"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
package/dsh-doctor.mjs
CHANGED
|
@@ -33,14 +33,20 @@
|
|
|
33
33
|
* node dsh-doctor.mjs --session <path> # 仅会话检查(默认自动找最新会话)
|
|
34
34
|
* node dsh-doctor.mjs --env # 仅环境检查
|
|
35
35
|
* node dsh-doctor.mjs --json # 输出 JSON
|
|
36
|
+
* node dsh-doctor.mjs --no-catalog # 不拉远程检查目录(只用内置副本)
|
|
36
37
|
*
|
|
37
|
-
*
|
|
38
|
+
* 远程检查目录(层 A,v0.2.0):内置 19 项之外,追加执行仓库 checks.json 里的声明式规则
|
|
39
|
+
* (规则是数据、不是代码;只读探测原语,引擎不执行远程代码)。每次运行尝试拉取
|
|
40
|
+
* raw.githubusercontent(3s 超时)→ 失败回退缓存(TTL 6h)→ 内置副本;新检查最长 6h 自动生效。
|
|
41
|
+
*
|
|
42
|
+
* 退出码:0 = 全部通过;1 = 发现可修复问题(内置 + catalog severity=error);warn 级失败不改退出码。
|
|
38
43
|
*/
|
|
39
44
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
40
|
-
import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
45
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
41
46
|
import { createRequire } from 'node:module';
|
|
42
47
|
import { basename, delimiter as PATH_DELIM, dirname, join } from 'node:path';
|
|
43
48
|
import { homedir } from 'node:os';
|
|
49
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
44
50
|
|
|
45
51
|
const HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
46
52
|
const results = []; // { section, id, ok, detail, fix? }
|
|
@@ -81,8 +87,8 @@ function knownSessionEventTypes() {
|
|
|
81
87
|
}
|
|
82
88
|
const KNOWN = knownSessionEventTypes();
|
|
83
89
|
|
|
84
|
-
function report(section, id, ok, detail, fix) {
|
|
85
|
-
results.push({ section, id, ok, detail, fix });
|
|
90
|
+
function report(section, id, ok, detail, fix, src) {
|
|
91
|
+
results.push({ section, id, ok, detail, fix, src: src ?? 'builtin' });
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
function resolveProfile(name) {
|
|
@@ -229,7 +235,8 @@ function checkProfile(name) {
|
|
|
229
235
|
const out = new Set();
|
|
230
236
|
if (!existsSync(patchPath)) return out;
|
|
231
237
|
const text = readFileSync(patchPath, 'utf8');
|
|
232
|
-
|
|
238
|
+
// 真实格式:`- id:` 下缩进的 `name:` 行(无破折号)——2026-08-15 fixtures 发现旧正则从未匹配
|
|
239
|
+
for (const m of text.matchAll(/^\s*name:\s*['"]?([^'"\s]+)/gm)) out.add(m[1]);
|
|
233
240
|
return out;
|
|
234
241
|
})();
|
|
235
242
|
|
|
@@ -287,16 +294,27 @@ function checkProfile(name) {
|
|
|
287
294
|
const dangling = Object.entries(deps).filter(([, spec]) => spec.startsWith('file:')).filter(([, spec]) => !existsSync(resolveFileSpec(spec)));
|
|
288
295
|
if (dangling.length) report('profile', 'P4', false, `悬空 file: 依赖(#1197): ${dangling.map(([n, s]) => `${n} (${s})`).join(', ')}`, '恢复目录或移除依赖');
|
|
289
296
|
else report('profile', 'P4', true, 'file: 依赖完整', undefined);
|
|
290
|
-
// P5 顶层 @deepseek-ai/*
|
|
297
|
+
// P5 顶层 @deepseek-ai/* 重复(#1486/#1697:hoisted 布局下同版本双实例 → 模块级 Symbol 不匹配)
|
|
298
|
+
// symlink 指向宿主同一份(#1697 的 link: workaround / pnpm file: 正常形态)= 单实例,放行
|
|
291
299
|
const topDup = [];
|
|
292
300
|
const topDir = join(dir, 'node_modules', '@deepseek-ai');
|
|
301
|
+
const hostScope = installAnchor ? join(installAnchor, '@deepseek-ai') : null;
|
|
293
302
|
if (existsSync(topDir)) {
|
|
294
303
|
for (const p of readdirSync(topDir)) {
|
|
295
304
|
const fp = join(topDir, p);
|
|
305
|
+
let st;
|
|
306
|
+
try { st = lstatSync(fp); } catch { continue; }
|
|
307
|
+
if (st.isSymbolicLink() && hostScope) {
|
|
308
|
+
try {
|
|
309
|
+
const real = realpathSync(fp);
|
|
310
|
+
const hostPkg = join(hostScope, p);
|
|
311
|
+
if (existsSync(hostPkg) && realpathSync(hostPkg) === real) continue; // 宿主同一份
|
|
312
|
+
} catch { /* 无法解析 → 按独立副本处理 */ }
|
|
313
|
+
}
|
|
296
314
|
if (existsSync(join(fp, 'package.json'))) topDup.push(p);
|
|
297
315
|
}
|
|
298
316
|
}
|
|
299
|
-
if (topDup.length) report('profile', 'P5', false, `profile 顶层存在 @deepseek-ai/* 重复(#1486
|
|
317
|
+
if (topDup.length) report('profile', 'P5', false, `profile 顶层存在 @deepseek-ai/* 重复(#1486/#1697 双实例风险,hoisted 布局会让同版本工具包互相遮蔽导致 Symbol 不匹配): ${topDup.join(', ')}`, '清理 profile 顶层 node_modules/@deepseek-ai 中与宿主同名的独立副本(真实目录);指向宿主的 link: symlink 是安全的(#1697 workaround)');
|
|
300
318
|
else report('profile', 'P5', true, '无顶层 @deepseek-ai 重复', undefined);
|
|
301
319
|
}
|
|
302
320
|
|
|
@@ -523,26 +541,327 @@ function scanAllSessions() {
|
|
|
523
541
|
}
|
|
524
542
|
}
|
|
525
543
|
|
|
544
|
+
/* ================= 远程检查目录(层 A:规则是数据,不是代码) =================
|
|
545
|
+
* 新检查 = 在 checks.json 追加一条 JSON,已装实例在缓存 TTL 内自动生效,无需重装插件。
|
|
546
|
+
* 安全属性:目录内容只能声明"只读探测原语",引擎不执行远程代码。
|
|
547
|
+
*/
|
|
548
|
+
const REMOTE_CATALOG_URL = 'https://raw.githubusercontent.com/moonquake2004/dsh-doctor/main/plugin/checks.json';
|
|
549
|
+
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; // 6h:新检查最长 6h 内自动生效
|
|
550
|
+
const catalogSeverity = new Map(); // catalog 检查 id → severity('error' | 'warn')
|
|
551
|
+
|
|
552
|
+
function bundledCatalog() {
|
|
553
|
+
const p = new URL('./checks.json', import.meta.url);
|
|
554
|
+
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { schemaVersion: 1, checks: [] }; }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function validCatalog(data) {
|
|
558
|
+
return !!data && data.schemaVersion === 1 && Array.isArray(data.checks);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** 拉取目录:新鲜缓存(≤TTL) → 远程(raw.githubusercontent,3s 超时) → 旧缓存(last-known-good) → 内置副本。 */
|
|
562
|
+
async function loadCatalog({ noRemote = false, fetchImpl, home = HOME } = {}) {
|
|
563
|
+
const bundled = bundledCatalog();
|
|
564
|
+
if (noRemote || typeof fetchImpl !== 'function') return { checks: bundled.checks, source: 'bundled' };
|
|
565
|
+
const cachePath = join(home, '.cache', 'dsh-doctor', 'checks.json');
|
|
566
|
+
const readCache = () => { if (!existsSync(cachePath)) return null; try { const d = JSON.parse(readFileSync(cachePath, 'utf8')); return validCatalog(d) ? d : null; } catch { return null; } };
|
|
567
|
+
try {
|
|
568
|
+
const cached = readCache();
|
|
569
|
+
if (cached && Date.now() - statSync(cachePath).mtimeMs < CATALOG_TTL_MS) return { checks: cached.checks, source: 'cache' };
|
|
570
|
+
} catch { /* 回退 */ }
|
|
571
|
+
try {
|
|
572
|
+
const ac = new AbortController();
|
|
573
|
+
const timer = setTimeout(() => ac.abort(), 3000);
|
|
574
|
+
const res = await fetchImpl(REMOTE_CATALOG_URL, { signal: ac.signal });
|
|
575
|
+
clearTimeout(timer);
|
|
576
|
+
if (res && res.ok) {
|
|
577
|
+
const data = await res.json();
|
|
578
|
+
if (validCatalog(data)) {
|
|
579
|
+
try { mkdirSync(dirname(cachePath), { recursive: true }); writeFileSync(cachePath, JSON.stringify(data, null, 2)); } catch { /* 缓存写入失败不影响本次运行 */ }
|
|
580
|
+
return { checks: data.checks, source: 'remote' };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
} catch { /* 离线/超时 → 回退 */ }
|
|
584
|
+
const stale = readCache();
|
|
585
|
+
if (stale) return { checks: stale.checks, source: 'cache-stale' };
|
|
586
|
+
return { checks: bundled.checks, source: 'bundled' };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function expandPath(tpl, ctx) {
|
|
590
|
+
return String(tpl)
|
|
591
|
+
.replace(/\{home\}/g, ctx.home)
|
|
592
|
+
.replace(/\{profile\}/g, ctx.profileDir ?? '{profile}')
|
|
593
|
+
.replace(/\{profileName\}/g, ctx.profile);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function findCommand(cmd) {
|
|
597
|
+
for (const w of process.platform === 'win32' ? ['where'] : ['which']) {
|
|
598
|
+
const r = spawnSync(w, [cmd]);
|
|
599
|
+
if (r.status === 0) { const p = String(r.stdout).split(/\r?\n/)[0].trim(); if (p) return p; }
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function countRecursive(dir) {
|
|
605
|
+
let n = 0;
|
|
606
|
+
try { for (const e of readdirSync(dir, { withFileTypes: true })) { const fp = join(dir, e.name); if (e.isFile()) n++; else if (e.isDirectory()) n += countRecursive(fp); } } catch { /* 不可读目录跳过 */ }
|
|
607
|
+
return n;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** 极简 glob:`*` 匹配段内任意、`?` 单字符、`**` 递归目录;返回文件匹配数。 */
|
|
611
|
+
function globCount(base, pattern) {
|
|
612
|
+
if (!existsSync(base)) return 0;
|
|
613
|
+
const segs = String(pattern).split('/').filter(Boolean);
|
|
614
|
+
if (segs.length === 0) return 0;
|
|
615
|
+
let dirs = [base];
|
|
616
|
+
let count = 0;
|
|
617
|
+
for (let i = 0; i < segs.length; i++) {
|
|
618
|
+
const seg = segs[i];
|
|
619
|
+
const last = i === segs.length - 1;
|
|
620
|
+
const next = [];
|
|
621
|
+
if (seg === '**') {
|
|
622
|
+
if (last) { for (const d of dirs) count += countRecursive(d); return count; }
|
|
623
|
+
// `**` 匹配零层或多层目录:保留当前 dirs(零层)并追加所有递归子目录
|
|
624
|
+
const all = [...dirs];
|
|
625
|
+
const stack = [...dirs];
|
|
626
|
+
while (stack.length) {
|
|
627
|
+
const d = stack.pop();
|
|
628
|
+
if (!existsSync(d)) continue;
|
|
629
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
630
|
+
if (!e.isDirectory()) continue;
|
|
631
|
+
const fp = join(d, e.name);
|
|
632
|
+
all.push(fp);
|
|
633
|
+
stack.push(fp);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
dirs = all;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
const re = new RegExp('^' + seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]') + '$');
|
|
640
|
+
for (const d of dirs) {
|
|
641
|
+
if (!existsSync(d)) continue;
|
|
642
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
643
|
+
if (!re.test(e.name)) continue;
|
|
644
|
+
const fp = join(d, e.name);
|
|
645
|
+
if (last) { if (e.isFile()) count++; }
|
|
646
|
+
else if (e.isDirectory()) next.push(fp);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
dirs = next;
|
|
650
|
+
}
|
|
651
|
+
return count;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** 执行一条目录检查(只读探测原语)。返回 { ok, detail, skipped? }。 */
|
|
655
|
+
export function runCatalogCheck(check, ctx) {
|
|
656
|
+
const probe = check.probe ?? {};
|
|
657
|
+
const p = (tpl) => expandPath(tpl, ctx);
|
|
658
|
+
switch (probe.type) {
|
|
659
|
+
case 'command-exists': {
|
|
660
|
+
const found = findCommand(probe.cmd);
|
|
661
|
+
return found ? { ok: true, detail: check.detailOk ?? `${probe.cmd} 在 PATH: ${found}` }
|
|
662
|
+
: { ok: false, detail: check.detailFail ?? `${probe.cmd} 不在 PATH` };
|
|
663
|
+
}
|
|
664
|
+
case 'path-exists':
|
|
665
|
+
case 'path-is-dir':
|
|
666
|
+
case 'path-is-file': {
|
|
667
|
+
const fp = p(probe.path);
|
|
668
|
+
let ok = existsSync(fp);
|
|
669
|
+
if (ok && probe.type === 'path-is-dir') ok = lstatSync(fp).isDirectory();
|
|
670
|
+
if (ok && probe.type === 'path-is-file') ok = lstatSync(fp).isFile();
|
|
671
|
+
return ok ? { ok: true, detail: check.detailOk ?? `${fp} 存在` }
|
|
672
|
+
: { ok: false, detail: check.detailFail ?? `${fp} 不存在/类型不符` };
|
|
673
|
+
}
|
|
674
|
+
case 'json-valid': {
|
|
675
|
+
const fp = p(probe.path);
|
|
676
|
+
if (!existsSync(fp)) return probe.required === false
|
|
677
|
+
? { ok: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
|
|
678
|
+
: { ok: false, detail: check.detailFail ?? `${fp} 缺失` };
|
|
679
|
+
let utf8ok = true, jsonok = false;
|
|
680
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(fp)); } catch { utf8ok = false; }
|
|
681
|
+
if (utf8ok) { try { JSON.parse(readFileSync(fp, 'utf8')); jsonok = true; } catch { /* 非法 JSON */ } }
|
|
682
|
+
return jsonok ? { ok: true, detail: check.detailOk ?? `${fp} 为合法 JSON` }
|
|
683
|
+
: { ok: false, detail: check.detailFail ?? `${fp} 不是合法 JSON(UTF-8:${utf8ok ? 'OK' : 'BAD'})` };
|
|
684
|
+
}
|
|
685
|
+
case 'text-contains':
|
|
686
|
+
case 'text-not-contains': {
|
|
687
|
+
const fp = p(probe.path);
|
|
688
|
+
if (!existsSync(fp)) return probe.required === false
|
|
689
|
+
? { ok: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
|
|
690
|
+
: { ok: false, detail: check.detailFail ?? `${fp} 缺失` };
|
|
691
|
+
let re;
|
|
692
|
+
try { re = new RegExp(probe.pattern, probe.flags ?? ''); } catch (e) { return { ok: false, detail: `目录规则正则非法: ${e.message.slice(0, 60)}` }; }
|
|
693
|
+
const hit = re.test(readFileSync(fp, 'utf8'));
|
|
694
|
+
const want = probe.type === 'text-contains';
|
|
695
|
+
return hit === want ? { ok: true, detail: check.detailOk ?? `${fp} ${want ? '匹配' : '未匹配'} ${probe.pattern}` }
|
|
696
|
+
: { ok: false, detail: check.detailFail ?? `${fp} ${want ? '未匹配' : '意外匹配'} ${probe.pattern}` };
|
|
697
|
+
}
|
|
698
|
+
case 'file-size-above': {
|
|
699
|
+
const fp = p(probe.path);
|
|
700
|
+
if (!existsSync(fp)) return probe.required === true
|
|
701
|
+
? { ok: false, detail: check.detailFail ?? `${fp} 缺失` }
|
|
702
|
+
: { ok: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` };
|
|
703
|
+
const size = statSync(fp).size;
|
|
704
|
+
return size > probe.minBytes
|
|
705
|
+
? { ok: false, detail: check.detailFail ?? `${fp} 过大: ${size}B > ${probe.minBytes}B` }
|
|
706
|
+
: { ok: true, detail: check.detailOk ?? `${fp} 大小 ${size}B 在限内` };
|
|
707
|
+
}
|
|
708
|
+
case 'glob-count': {
|
|
709
|
+
const base = p(probe.base ?? probe.path);
|
|
710
|
+
const count = globCount(base, probe.pattern);
|
|
711
|
+
const min = probe.min ?? 1;
|
|
712
|
+
const max = probe.max ?? Infinity;
|
|
713
|
+
if (count < min) return { ok: false, detail: check.detailFail ?? `${probe.pattern} 匹配 ${count} 个(< ${min})` };
|
|
714
|
+
if (count > max) return { ok: false, detail: check.detailFail ?? `${probe.pattern} 匹配 ${count} 个(> ${max})` };
|
|
715
|
+
return { ok: true, detail: check.detailOk ?? `${probe.pattern} 匹配 ${count} 个(${min}..${max})` };
|
|
716
|
+
}
|
|
717
|
+
default:
|
|
718
|
+
return { ok: true, skipped: true, detail: `探测原语 ${probe.type} 本引擎不支持,已跳过(需更新插件)` };
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** 逐条执行目录检查,汇入统一 results 管线(src='catalog')。尊重 --profile/--env/--session 收窄。 */
|
|
723
|
+
function checkCatalog(ctx, catalog) {
|
|
724
|
+
const platform = process.platform;
|
|
725
|
+
for (const check of catalog.checks ?? []) {
|
|
726
|
+
if (!wants(check.section)) continue; // 与内置检查一致的 section 收窄
|
|
727
|
+
const when = check.when ?? {};
|
|
728
|
+
if (Array.isArray(when.os) && !when.os.includes(platform)) continue;
|
|
729
|
+
if (check.section === 'profile' && !ctx.profileDir) continue; // profile 无效时跳过 profile 段
|
|
730
|
+
let r;
|
|
731
|
+
try { r = runCatalogCheck(check, ctx); } catch (e) { r = { ok: false, detail: `catalog 检查异常: ${e.message.slice(0, 80)}` }; }
|
|
732
|
+
if (r.skipped) { report(check.section, check.id, true, r.detail, undefined, 'catalog'); continue; }
|
|
733
|
+
const severity = check.severity ?? 'error';
|
|
734
|
+
catalogSeverity.set(check.id, severity);
|
|
735
|
+
report(check.section, check.id, r.ok, r.detail, r.ok ? undefined : check.fix, 'catalog');
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/* ================= 层 B:版本检查与更新(v0.2.1) =================
|
|
740
|
+
* 检查 npm dist-tags.latest 是否比本地版本新(TTL 6h 缓存 + 离线回退 last-known-good)。
|
|
741
|
+
* 默认只提示;--update 手动执行更新;DSH_DOCTOR_AUTO_UPDATE=1 可用时自动更新。
|
|
742
|
+
* 诚实边界:cordis 启动时加载插件,更新后需重启 dsh web 才生效。
|
|
743
|
+
*/
|
|
744
|
+
const UPDATE_URL = 'https://registry.npmjs.org/@moonquake2004%2Fdsh-doctor';
|
|
745
|
+
const UPDATE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
746
|
+
|
|
747
|
+
export function localVersion() {
|
|
748
|
+
try {
|
|
749
|
+
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
750
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
751
|
+
} catch { return '0.0.0'; }
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** 返回 { current, latest, available };latest=null 表示无法确认(离线且无缓存)。 */
|
|
755
|
+
export async function checkForUpdate({ noRemote = false, fetchImpl, home = HOME } = {}) {
|
|
756
|
+
const current = localVersion();
|
|
757
|
+
if (noRemote || typeof fetchImpl !== 'function') return { current, latest: null, available: false };
|
|
758
|
+
const cachePath = join(home, '.cache', 'dsh-doctor', 'update.json');
|
|
759
|
+
const readCache = () => { try { const d = JSON.parse(readFileSync(cachePath, 'utf8')); return d && typeof d.latest === 'string' ? d : null; } catch { return null; } };
|
|
760
|
+
try {
|
|
761
|
+
const c = readCache();
|
|
762
|
+
if (c && Date.now() - statSync(cachePath).mtimeMs < UPDATE_TTL_MS) return { current, latest: c.latest, available: c.latest !== current };
|
|
763
|
+
} catch { /* 回退 */ }
|
|
764
|
+
try {
|
|
765
|
+
const ac = new AbortController();
|
|
766
|
+
const timer = setTimeout(() => ac.abort(), 3000);
|
|
767
|
+
const res = await fetchImpl(UPDATE_URL, { signal: ac.signal });
|
|
768
|
+
clearTimeout(timer);
|
|
769
|
+
if (res && res.ok) {
|
|
770
|
+
const data = await res.json();
|
|
771
|
+
const latest = data?.['dist-tags']?.latest;
|
|
772
|
+
if (typeof latest === 'string') {
|
|
773
|
+
try { mkdirSync(dirname(cachePath), { recursive: true }); writeFileSync(cachePath, JSON.stringify({ latest, checkedAt: new Date().toISOString() })); } catch { /* 缓存失败不影响 */ }
|
|
774
|
+
return { current, latest, available: latest !== current };
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
} catch { /* 离线/超时 → last-known-good */ }
|
|
778
|
+
const stale = readCache();
|
|
779
|
+
if (stale) return { current, latest: stale.latest, available: stale.latest !== current };
|
|
780
|
+
return { current, latest: null, available: false };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** 判断本模块是否安装在某个 profile 的 node_modules 下;返回 profile 目录或 null。 */
|
|
784
|
+
export function profileDirOfModule() {
|
|
785
|
+
const here = fileURLToPath(new URL('.', import.meta.url));
|
|
786
|
+
const m = here.match(/(\/\.dsh\/profiles\/[^/]+)\/node_modules\//);
|
|
787
|
+
return m ? m[1] : null;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** 执行更新(profile 内 pnpm install 刷新 file:/npm 依赖;可用 DSH_DOCTOR_UPDATE_CMD 覆盖命令)。 */
|
|
791
|
+
export function runUpdate() {
|
|
792
|
+
const override = process.env.DSH_DOCTOR_UPDATE_CMD;
|
|
793
|
+
if (override) {
|
|
794
|
+
const r = spawnSync(override, { shell: true, stdio: 'inherit' });
|
|
795
|
+
return r.status === 0 ? '更新命令执行完成,请重启 dsh web 生效' : `更新命令失败(exit ${r.status ?? r.error?.message})`;
|
|
796
|
+
}
|
|
797
|
+
const profileDir = profileDirOfModule();
|
|
798
|
+
if (profileDir) {
|
|
799
|
+
const r = spawnSync('pnpm', ['install'], { cwd: profileDir, stdio: 'inherit' });
|
|
800
|
+
return r.status === 0 ? `已更新 ${profileDir},请重启 dsh web 使新版本生效` : `pnpm install 失败(exit ${r.status ?? r.error?.message})`;
|
|
801
|
+
}
|
|
802
|
+
return '仓库 checkout 模式:请 git pull 后重新安装插件(file: 依赖指向仓库)';
|
|
803
|
+
}
|
|
804
|
+
|
|
526
805
|
/* ================= main ================= */
|
|
527
806
|
const profileArg = (() => { const i = process.argv.indexOf('--profile'); return i >= 0 ? process.argv[i + 1] : 'web'; })();
|
|
528
807
|
const sessionArg = (() => { const i = process.argv.indexOf('--session'); return i >= 0 ? process.argv[i + 1] : undefined; })();
|
|
529
808
|
|
|
530
|
-
|
|
531
|
-
try {
|
|
532
|
-
try {
|
|
533
|
-
try {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
809
|
+
async function run() {
|
|
810
|
+
try { checkEnv(); } catch (e) { report('env', 'E0', false, `env 检查异常: ${e.message.slice(0, 80)}`); }
|
|
811
|
+
try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
|
|
812
|
+
try { checkSession(sessionArg); } catch (e) { report('session', 'S0', false, `session 检查异常: ${e.message.slice(0, 100)}`); }
|
|
813
|
+
try { scanAllSessions(); } catch (e) { report('session', 'S11', false, `全会话扫描异常: ${e.message.slice(0, 100)}`); }
|
|
814
|
+
|
|
815
|
+
// 远程检查目录(层 A):内置检查之后追加执行;--no-catalog 只走内置副本
|
|
816
|
+
let catalogMeta = { source: 'none', checks: 0 };
|
|
817
|
+
try {
|
|
818
|
+
const catalog = await loadCatalog({ noRemote: process.argv.includes('--no-catalog'), fetchImpl: typeof fetch === 'function' ? fetch : undefined });
|
|
819
|
+
catalogMeta = { source: catalog.source, checks: catalog.checks.length };
|
|
820
|
+
const profileDir = (() => { try { return resolveProfile(profileArg); } catch { return null; } })();
|
|
821
|
+
if (catalog.checks.length && profileDir) checkCatalog({ home: HOME, profile: profileArg, profileDir }, catalog);
|
|
822
|
+
else if (catalog.checks.length) report('catalog', 'C0', true, `profile 无效(${profileArg}),目录检查跳过(${catalog.source})`, undefined, 'catalog');
|
|
823
|
+
} catch (e) {
|
|
824
|
+
catalogMeta = { source: 'error', checks: 0, error: e.message.slice(0, 80) };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// 层 B:版本检查与更新(--no-catalog 同时禁用网络检查;--update 手动更新;DSH_DOCTOR_AUTO_UPDATE=1 自动)
|
|
828
|
+
const noRemote = process.argv.includes('--no-catalog');
|
|
829
|
+
let updateInfo = { current: localVersion(), latest: null, available: false };
|
|
830
|
+
try {
|
|
831
|
+
updateInfo = await checkForUpdate({ noRemote, fetchImpl: typeof fetch === 'function' ? fetch : undefined });
|
|
832
|
+
} catch (e) {
|
|
833
|
+
updateInfo = { current: localVersion(), latest: null, available: false, error: e.message.slice(0, 60) };
|
|
834
|
+
}
|
|
835
|
+
if (process.argv.includes('--update') || (process.env.DSH_DOCTOR_AUTO_UPDATE === '1' && updateInfo.available)) {
|
|
836
|
+
updateInfo.applied = runUpdate();
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// 退出码只计内置失败 + catalog 中 severity=error 的失败;warn 失败提示但不改退出码
|
|
840
|
+
const bad = results.filter((r) => !r.ok && catalogSeverity.get(r.id) !== 'warn');
|
|
841
|
+
if (jsonOut) {
|
|
842
|
+
console.log(JSON.stringify({ ok: bad.length === 0, checks: results, catalog: catalogMeta, update: updateInfo }, null, 2));
|
|
843
|
+
} else {
|
|
844
|
+
const sectionOrder = { env: 0, profile: 1, session: 2, catalog: 3 };
|
|
845
|
+
const ordered = [...results].sort((a, b) => (sectionOrder[a.section] ?? 9) - (sectionOrder[b.section] ?? 9));
|
|
846
|
+
let lastSection = '';
|
|
847
|
+
for (const r of ordered) {
|
|
848
|
+
if (r.section !== lastSection) { console.log(`\n== ${r.section.toUpperCase()} ==`); lastSection = r.section; }
|
|
849
|
+
const sev = catalogSeverity.get(r.id);
|
|
850
|
+
const mark = !r.ok && sev === 'warn' ? '⚠' : (r.ok ? '✓' : '✗');
|
|
851
|
+
console.log(` ${mark} [${r.id}] ${r.detail}${r.src === 'catalog' ? ' [目录]' : ''}`);
|
|
852
|
+
if (!r.ok && r.fix) console.log(` ↳ 修复: ${r.fix}`);
|
|
853
|
+
}
|
|
854
|
+
if (updateInfo.available && !updateInfo.applied) {
|
|
855
|
+
console.log(`\n⚠ 新版本 ${updateInfo.latest} 可用(当前 ${updateInfo.current})→ 运行 \`dsh-doctor --update\` 或 \`dsh plugin update\``);
|
|
856
|
+
} else if (updateInfo.applied) {
|
|
857
|
+
console.log(`\n✓ ${updateInfo.applied}`);
|
|
858
|
+
}
|
|
859
|
+
console.log(`\n${bad.length === 0 ? '✓ 全部通过' : `✗ ${bad.length} 个问题`}(profile=${profileArg},目录=${catalogMeta.source},${catalogMeta.checks} 条)`);
|
|
860
|
+
}
|
|
861
|
+
process.exit(bad.length === 0 ? 0 : 1);
|
|
547
862
|
}
|
|
548
|
-
|
|
863
|
+
|
|
864
|
+
// 直接执行(CLI:根目录薄封装或 plugin 本体均可);被 import(测试/宿主)时不自动运行
|
|
865
|
+
if (process.argv[1] && basename(process.argv[1]) === 'dsh-doctor.mjs') run();
|
|
866
|
+
|
|
867
|
+
export { loadCatalog, bundledCatalog, validCatalog, expandPath, globCount, checkCatalog };
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moonquake2004/dsh-doctor",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Offline diagnostic for DeepSeek Harness — 19 checks
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Offline diagnostic for DeepSeek Harness — 19 built-in checks + self-updating catalog (Layer A) + self-update check (Layer B); 'Doctor' panel in web UI settings.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"files": [
|
|
7
7
|
"lib",
|
|
8
8
|
"client",
|
|
9
9
|
"dsh-doctor.mjs",
|
|
10
|
+
"checks.json",
|
|
10
11
|
"cordis.patch.yml",
|
|
11
12
|
"README.md"
|
|
12
13
|
],
|