@moonquake2004/dsh-doctor 0.1.0 → 0.2.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/checks.json +64 -0
- package/dsh-doctor.mjs +244 -21
- package/package.json +3 -2
package/checks.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
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*-\\s*name:\\s*['\"]?[^'\"\\n]*\\s[^'\"\\n]+",
|
|
45
|
+
"flags": "m"
|
|
46
|
+
},
|
|
47
|
+
"detailOk": "用户 patch 的 insert name 均无空格",
|
|
48
|
+
"detailFail": "用户 patch 存在含空格的 insert name(Windows 下 spawn 参数解析会断,#1420 待实现 lint 的目录版)",
|
|
49
|
+
"fix": "把含空格的 insert name 改为不含空格的标识符"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"id": "E9-storages-json-valid",
|
|
53
|
+
"section": "env",
|
|
54
|
+
"severity": "error",
|
|
55
|
+
"title": "config 目录 JSON 文件合法性",
|
|
56
|
+
"discussion": "#1357",
|
|
57
|
+
"when": { "os": ["darwin", "linux", "win32"] },
|
|
58
|
+
"probe": { "type": "json-valid", "path": "{home}/config/workspace.json", "required": false },
|
|
59
|
+
"detailOk": "config/workspace.json 为合法 JSON",
|
|
60
|
+
"detailFail": "config/workspace.json 不是合法 JSON(#1357 并发写乱码类,工作区列表会消失)",
|
|
61
|
+
"fix": "排查多实例并发写;修复或删除损坏文件"
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
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, 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 } 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) {
|
|
@@ -523,26 +529,243 @@ function scanAllSessions() {
|
|
|
523
529
|
}
|
|
524
530
|
}
|
|
525
531
|
|
|
532
|
+
/* ================= 远程检查目录(层 A:规则是数据,不是代码) =================
|
|
533
|
+
* 新检查 = 在 checks.json 追加一条 JSON,已装实例在缓存 TTL 内自动生效,无需重装插件。
|
|
534
|
+
* 安全属性:目录内容只能声明"只读探测原语",引擎不执行远程代码。
|
|
535
|
+
*/
|
|
536
|
+
const REMOTE_CATALOG_URL = 'https://raw.githubusercontent.com/moonquake2004/dsh-doctor/main/plugin/checks.json';
|
|
537
|
+
const CATALOG_TTL_MS = 6 * 60 * 60 * 1000; // 6h:新检查最长 6h 内自动生效
|
|
538
|
+
const catalogSeverity = new Map(); // catalog 检查 id → severity('error' | 'warn')
|
|
539
|
+
|
|
540
|
+
function bundledCatalog() {
|
|
541
|
+
const p = new URL('./checks.json', import.meta.url);
|
|
542
|
+
try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { schemaVersion: 1, checks: [] }; }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function validCatalog(data) {
|
|
546
|
+
return !!data && data.schemaVersion === 1 && Array.isArray(data.checks);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** 拉取目录:新鲜缓存(≤TTL) → 远程(raw.githubusercontent,3s 超时) → 旧缓存(last-known-good) → 内置副本。 */
|
|
550
|
+
async function loadCatalog({ noRemote = false, fetchImpl, home = HOME } = {}) {
|
|
551
|
+
const bundled = bundledCatalog();
|
|
552
|
+
if (noRemote || typeof fetchImpl !== 'function') return { checks: bundled.checks, source: 'bundled' };
|
|
553
|
+
const cachePath = join(home, '.cache', 'dsh-doctor', 'checks.json');
|
|
554
|
+
const readCache = () => { if (!existsSync(cachePath)) return null; try { const d = JSON.parse(readFileSync(cachePath, 'utf8')); return validCatalog(d) ? d : null; } catch { return null; } };
|
|
555
|
+
try {
|
|
556
|
+
const cached = readCache();
|
|
557
|
+
if (cached && Date.now() - statSync(cachePath).mtimeMs < CATALOG_TTL_MS) return { checks: cached.checks, source: 'cache' };
|
|
558
|
+
} catch { /* 回退 */ }
|
|
559
|
+
try {
|
|
560
|
+
const ac = new AbortController();
|
|
561
|
+
const timer = setTimeout(() => ac.abort(), 3000);
|
|
562
|
+
const res = await fetchImpl(REMOTE_CATALOG_URL, { signal: ac.signal });
|
|
563
|
+
clearTimeout(timer);
|
|
564
|
+
if (res && res.ok) {
|
|
565
|
+
const data = await res.json();
|
|
566
|
+
if (validCatalog(data)) {
|
|
567
|
+
try { mkdirSync(dirname(cachePath), { recursive: true }); writeFileSync(cachePath, JSON.stringify(data, null, 2)); } catch { /* 缓存写入失败不影响本次运行 */ }
|
|
568
|
+
return { checks: data.checks, source: 'remote' };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
} catch { /* 离线/超时 → 回退 */ }
|
|
572
|
+
const stale = readCache();
|
|
573
|
+
if (stale) return { checks: stale.checks, source: 'cache-stale' };
|
|
574
|
+
return { checks: bundled.checks, source: 'bundled' };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function expandPath(tpl, ctx) {
|
|
578
|
+
return String(tpl)
|
|
579
|
+
.replace(/\{home\}/g, ctx.home)
|
|
580
|
+
.replace(/\{profile\}/g, ctx.profileDir ?? '{profile}')
|
|
581
|
+
.replace(/\{profileName\}/g, ctx.profile);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function findCommand(cmd) {
|
|
585
|
+
for (const w of process.platform === 'win32' ? ['where'] : ['which']) {
|
|
586
|
+
const r = spawnSync(w, [cmd]);
|
|
587
|
+
if (r.status === 0) { const p = String(r.stdout).split(/\r?\n/)[0].trim(); if (p) return p; }
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function countRecursive(dir) {
|
|
593
|
+
let n = 0;
|
|
594
|
+
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 { /* 不可读目录跳过 */ }
|
|
595
|
+
return n;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** 极简 glob:`*` 匹配段内任意、`?` 单字符、`**` 递归目录;返回文件匹配数。 */
|
|
599
|
+
function globCount(base, pattern) {
|
|
600
|
+
if (!existsSync(base)) return 0;
|
|
601
|
+
const segs = String(pattern).split('/').filter(Boolean);
|
|
602
|
+
if (segs.length === 0) return 0;
|
|
603
|
+
let dirs = [base];
|
|
604
|
+
let count = 0;
|
|
605
|
+
for (let i = 0; i < segs.length; i++) {
|
|
606
|
+
const seg = segs[i];
|
|
607
|
+
const last = i === segs.length - 1;
|
|
608
|
+
const next = [];
|
|
609
|
+
if (seg === '**') {
|
|
610
|
+
if (last) { for (const d of dirs) count += countRecursive(d); return count; }
|
|
611
|
+
// `**` 匹配零层或多层目录:保留当前 dirs(零层)并追加所有递归子目录
|
|
612
|
+
const all = [...dirs];
|
|
613
|
+
const stack = [...dirs];
|
|
614
|
+
while (stack.length) {
|
|
615
|
+
const d = stack.pop();
|
|
616
|
+
if (!existsSync(d)) continue;
|
|
617
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
618
|
+
if (!e.isDirectory()) continue;
|
|
619
|
+
const fp = join(d, e.name);
|
|
620
|
+
all.push(fp);
|
|
621
|
+
stack.push(fp);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
dirs = all;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const re = new RegExp('^' + seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]') + '$');
|
|
628
|
+
for (const d of dirs) {
|
|
629
|
+
if (!existsSync(d)) continue;
|
|
630
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
631
|
+
if (!re.test(e.name)) continue;
|
|
632
|
+
const fp = join(d, e.name);
|
|
633
|
+
if (last) { if (e.isFile()) count++; }
|
|
634
|
+
else if (e.isDirectory()) next.push(fp);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
dirs = next;
|
|
638
|
+
}
|
|
639
|
+
return count;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** 执行一条目录检查(只读探测原语)。返回 { ok, detail, skipped? }。 */
|
|
643
|
+
export function runCatalogCheck(check, ctx) {
|
|
644
|
+
const probe = check.probe ?? {};
|
|
645
|
+
const p = (tpl) => expandPath(tpl, ctx);
|
|
646
|
+
switch (probe.type) {
|
|
647
|
+
case 'command-exists': {
|
|
648
|
+
const found = findCommand(probe.cmd);
|
|
649
|
+
return found ? { ok: true, detail: check.detailOk ?? `${probe.cmd} 在 PATH: ${found}` }
|
|
650
|
+
: { ok: false, detail: check.detailFail ?? `${probe.cmd} 不在 PATH` };
|
|
651
|
+
}
|
|
652
|
+
case 'path-exists':
|
|
653
|
+
case 'path-is-dir':
|
|
654
|
+
case 'path-is-file': {
|
|
655
|
+
const fp = p(probe.path);
|
|
656
|
+
let ok = existsSync(fp);
|
|
657
|
+
if (ok && probe.type === 'path-is-dir') ok = lstatSync(fp).isDirectory();
|
|
658
|
+
if (ok && probe.type === 'path-is-file') ok = lstatSync(fp).isFile();
|
|
659
|
+
return ok ? { ok: true, detail: check.detailOk ?? `${fp} 存在` }
|
|
660
|
+
: { ok: false, detail: check.detailFail ?? `${fp} 不存在/类型不符` };
|
|
661
|
+
}
|
|
662
|
+
case 'json-valid': {
|
|
663
|
+
const fp = p(probe.path);
|
|
664
|
+
if (!existsSync(fp)) return probe.required === false
|
|
665
|
+
? { ok: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` }
|
|
666
|
+
: { ok: false, detail: check.detailFail ?? `${fp} 缺失` };
|
|
667
|
+
let utf8ok = true, jsonok = false;
|
|
668
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(fp)); } catch { utf8ok = false; }
|
|
669
|
+
if (utf8ok) { try { JSON.parse(readFileSync(fp, 'utf8')); jsonok = true; } catch { /* 非法 JSON */ } }
|
|
670
|
+
return jsonok ? { ok: true, detail: check.detailOk ?? `${fp} 为合法 JSON` }
|
|
671
|
+
: { ok: false, detail: check.detailFail ?? `${fp} 不是合法 JSON(UTF-8:${utf8ok ? 'OK' : 'BAD'})` };
|
|
672
|
+
}
|
|
673
|
+
case 'text-contains':
|
|
674
|
+
case 'text-not-contains': {
|
|
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 re;
|
|
680
|
+
try { re = new RegExp(probe.pattern, probe.flags ?? ''); } catch (e) { return { ok: false, detail: `目录规则正则非法: ${e.message.slice(0, 60)}` }; }
|
|
681
|
+
const hit = re.test(readFileSync(fp, 'utf8'));
|
|
682
|
+
const want = probe.type === 'text-contains';
|
|
683
|
+
return hit === want ? { ok: true, detail: check.detailOk ?? `${fp} ${want ? '匹配' : '未匹配'} ${probe.pattern}` }
|
|
684
|
+
: { ok: false, detail: check.detailFail ?? `${fp} ${want ? '未匹配' : '意外匹配'} ${probe.pattern}` };
|
|
685
|
+
}
|
|
686
|
+
case 'file-size-above': {
|
|
687
|
+
const fp = p(probe.path);
|
|
688
|
+
if (!existsSync(fp)) return probe.required === true
|
|
689
|
+
? { ok: false, detail: check.detailFail ?? `${fp} 缺失` }
|
|
690
|
+
: { ok: true, detail: check.detailOk ?? `${fp} 不存在(跳过)` };
|
|
691
|
+
const size = statSync(fp).size;
|
|
692
|
+
return size > probe.minBytes
|
|
693
|
+
? { ok: false, detail: check.detailFail ?? `${fp} 过大: ${size}B > ${probe.minBytes}B` }
|
|
694
|
+
: { ok: true, detail: check.detailOk ?? `${fp} 大小 ${size}B 在限内` };
|
|
695
|
+
}
|
|
696
|
+
case 'glob-count': {
|
|
697
|
+
const base = p(probe.base ?? probe.path);
|
|
698
|
+
const count = globCount(base, probe.pattern);
|
|
699
|
+
const min = probe.min ?? 1;
|
|
700
|
+
const max = probe.max ?? Infinity;
|
|
701
|
+
if (count < min) return { ok: false, detail: check.detailFail ?? `${probe.pattern} 匹配 ${count} 个(< ${min})` };
|
|
702
|
+
if (count > max) return { ok: false, detail: check.detailFail ?? `${probe.pattern} 匹配 ${count} 个(> ${max})` };
|
|
703
|
+
return { ok: true, detail: check.detailOk ?? `${probe.pattern} 匹配 ${count} 个(${min}..${max})` };
|
|
704
|
+
}
|
|
705
|
+
default:
|
|
706
|
+
return { ok: true, skipped: true, detail: `探测原语 ${probe.type} 本引擎不支持,已跳过(需更新插件)` };
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** 逐条执行目录检查,汇入统一 results 管线(src='catalog')。 */
|
|
711
|
+
function checkCatalog(ctx, catalog) {
|
|
712
|
+
const platform = process.platform;
|
|
713
|
+
for (const check of catalog.checks ?? []) {
|
|
714
|
+
const when = check.when ?? {};
|
|
715
|
+
if (Array.isArray(when.os) && !when.os.includes(platform)) continue;
|
|
716
|
+
if (check.section === 'profile' && !ctx.profileDir) continue; // profile 无效时跳过 profile 段
|
|
717
|
+
let r;
|
|
718
|
+
try { r = runCatalogCheck(check, ctx); } catch (e) { r = { ok: false, detail: `catalog 检查异常: ${e.message.slice(0, 80)}` }; }
|
|
719
|
+
if (r.skipped) { report(check.section, check.id, true, r.detail, undefined, 'catalog'); continue; }
|
|
720
|
+
const severity = check.severity ?? 'error';
|
|
721
|
+
catalogSeverity.set(check.id, severity);
|
|
722
|
+
report(check.section, check.id, r.ok, r.detail, r.ok ? undefined : check.fix, 'catalog');
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
526
726
|
/* ================= main ================= */
|
|
527
727
|
const profileArg = (() => { const i = process.argv.indexOf('--profile'); return i >= 0 ? process.argv[i + 1] : 'web'; })();
|
|
528
728
|
const sessionArg = (() => { const i = process.argv.indexOf('--session'); return i >= 0 ? process.argv[i + 1] : undefined; })();
|
|
529
729
|
|
|
530
|
-
|
|
531
|
-
try {
|
|
532
|
-
try {
|
|
533
|
-
try {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
730
|
+
async function run() {
|
|
731
|
+
try { checkEnv(); } catch (e) { report('env', 'E0', false, `env 检查异常: ${e.message.slice(0, 80)}`); }
|
|
732
|
+
try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
|
|
733
|
+
try { checkSession(sessionArg); } catch (e) { report('session', 'S0', false, `session 检查异常: ${e.message.slice(0, 100)}`); }
|
|
734
|
+
try { scanAllSessions(); } catch (e) { report('session', 'S11', false, `全会话扫描异常: ${e.message.slice(0, 100)}`); }
|
|
735
|
+
|
|
736
|
+
// 远程检查目录(层 A):内置检查之后追加执行;--no-catalog 只走内置副本
|
|
737
|
+
let catalogMeta = { source: 'none', checks: 0 };
|
|
738
|
+
try {
|
|
739
|
+
const catalog = await loadCatalog({ noRemote: process.argv.includes('--no-catalog'), fetchImpl: typeof fetch === 'function' ? fetch : undefined });
|
|
740
|
+
catalogMeta = { source: catalog.source, checks: catalog.checks.length };
|
|
741
|
+
const profileDir = (() => { try { return resolveProfile(profileArg); } catch { return null; } })();
|
|
742
|
+
if (catalog.checks.length && profileDir) checkCatalog({ home: HOME, profile: profileArg, profileDir }, catalog);
|
|
743
|
+
else if (catalog.checks.length) report('catalog', 'C0', true, `profile 无效(${profileArg}),目录检查跳过(${catalog.source})`, undefined, 'catalog');
|
|
744
|
+
} catch (e) {
|
|
745
|
+
catalogMeta = { source: 'error', checks: 0, error: e.message.slice(0, 80) };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// 退出码只计内置失败 + catalog 中 severity=error 的失败;warn 失败提示但不改退出码
|
|
749
|
+
const bad = results.filter((r) => !r.ok && catalogSeverity.get(r.id) !== 'warn');
|
|
750
|
+
if (jsonOut) {
|
|
751
|
+
console.log(JSON.stringify({ ok: bad.length === 0, checks: results, catalog: catalogMeta }, null, 2));
|
|
752
|
+
} else {
|
|
753
|
+
const sectionOrder = { env: 0, profile: 1, session: 2, catalog: 3 };
|
|
754
|
+
const ordered = [...results].sort((a, b) => (sectionOrder[a.section] ?? 9) - (sectionOrder[b.section] ?? 9));
|
|
755
|
+
let lastSection = '';
|
|
756
|
+
for (const r of ordered) {
|
|
757
|
+
if (r.section !== lastSection) { console.log(`\n== ${r.section.toUpperCase()} ==`); lastSection = r.section; }
|
|
758
|
+
const sev = catalogSeverity.get(r.id);
|
|
759
|
+
const mark = !r.ok && sev === 'warn' ? '⚠' : (r.ok ? '✓' : '✗');
|
|
760
|
+
console.log(` ${mark} [${r.id}] ${r.detail}${r.src === 'catalog' ? ' [目录]' : ''}`);
|
|
761
|
+
if (!r.ok && r.fix) console.log(` ↳ 修复: ${r.fix}`);
|
|
762
|
+
}
|
|
763
|
+
console.log(`\n${bad.length === 0 ? '✓ 全部通过' : `✗ ${bad.length} 个问题`}(profile=${profileArg},目录=${catalogMeta.source},${catalogMeta.checks} 条)`);
|
|
545
764
|
}
|
|
546
|
-
|
|
765
|
+
process.exit(bad.length === 0 ? 0 : 1);
|
|
547
766
|
}
|
|
548
|
-
|
|
767
|
+
|
|
768
|
+
// 直接执行(CLI:根目录薄封装或 plugin 本体均可);被 import(测试/宿主)时不自动运行
|
|
769
|
+
if (process.argv[1] && basename(process.argv[1]) === 'dsh-doctor.mjs') run();
|
|
770
|
+
|
|
771
|
+
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 across env/profile/session, plus a 'Doctor' panel in the web UI settings.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Offline diagnostic for DeepSeek Harness — 19 built-in checks across env/profile/session, plus a self-updating remote catalog of declarative pattern checks; 'Doctor' panel in the 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
|
],
|