@moonquake2004/dsh-doctor 0.2.0 → 0.2.3

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 CHANGED
@@ -41,8 +41,9 @@
41
41
  "probe": {
42
42
  "type": "text-not-contains",
43
43
  "path": "{profile}/cordis.patch.yml",
44
- "pattern": "^\\s*-\\s*name:\\s*['\"]?[^'\"\\n]*\\s[^'\"\\n]+",
45
- "flags": "m"
44
+ "pattern": "^\\s*name:\\s*['\"]?[^'\"\\s]+[^'\"\\n]*\\s[^'\"\\n]+",
45
+ "flags": "m",
46
+ "required": false
46
47
  },
47
48
  "detailOk": "用户 patch 的 insert name 均无空格",
48
49
  "detailFail": "用户 patch 存在含空格的 insert name(Windows 下 spawn 参数解析会断,#1420 待实现 lint 的目录版)",
package/dsh-doctor.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  * P3 用户 patch 的 insert name 从 profile 锚点不可解析(#1197/#880)
10
10
  * P4 file: 依赖指向不存在的目录(#1197:悬空 file: 链接)
11
11
  * P5 profile 顶层 @deepseek-ai/* 与框架重复(#1486:双模块实例 → Symbol 不匹配)
12
+ * P7 cordis.patch.yml 结构 lint(#1724:~ insert: 是 YAML null → parsePatchList 崩溃 → UI 打不开;tab 缩进/缺冒号同族)
12
13
  * [session]
13
14
  * S1 孤儿 tool_call(#1363:assistant tool_calls 无对应 tool 结果 → INVALID_REQUEST)
14
15
  * S2 未闭合 turn(#466/#1265:turn/start 无 turn/end → 会话永久"运行中")
@@ -25,6 +26,7 @@
25
26
  * E4 node-pty 原生模块完整性(#1219:pty.node 缺失 → dsh web 启动失败)
26
27
  * E5 存储 JSON 文件合法性(#1357:并发写 workspace.json 乱码 → 工作区列表消失)
27
28
  * E6 锚点元检查(tripwire:S6 的 expandRow seq0+k、S7 的 session/end-seed、S10 的 sourceEventSeqs 是否仍在安装的 dsh-session 中)
29
+ * E10 3080 Web 端口可用性(#1719:启动 dsh web 前检查;dsh web 自身占用=正常,其他程序占用=FAIL;DSH_DOCTOR_PORT 可覆盖)
28
30
  * (P6 Windows 空格参数 lint,#1420 —— 待实现)
29
31
  *
30
32
  * 用法:
@@ -42,11 +44,12 @@
42
44
  * 退出码:0 = 全部通过;1 = 发现可修复问题(内置 + catalog severity=error);warn 级失败不改退出码。
43
45
  */
44
46
  import { execFileSync, spawnSync } from 'node:child_process';
45
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
47
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
46
48
  import { createRequire } from 'node:module';
49
+ import net from 'node:net';
47
50
  import { basename, delimiter as PATH_DELIM, dirname, join } from 'node:path';
48
51
  import { homedir } from 'node:os';
49
- import { pathToFileURL } from 'node:url';
52
+ import { pathToFileURL, fileURLToPath } from 'node:url';
50
53
 
51
54
  const HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
52
55
  const results = []; // { section, id, ok, detail, fix? }
@@ -185,6 +188,55 @@ function checkEnv() {
185
188
  }
186
189
  }
187
190
 
191
+ /* ================= E10:Web 端口可用性(#1719 提案;启动 dsh web 前检查,避免 address in use) =================
192
+ * 本地 socket bind 探测(离线兼容):端口空闲 → PASS;被 dsh web 实例占用 → PASS+提示
193
+ * (宿主自身或另一实例,正常);被其他程序占用 → FAIL。
194
+ * 默认 3080,可用 DSH_DOCTOR_PORT 覆盖(测试/换端口)。
195
+ */
196
+ function portOccupierInfo(port) {
197
+ try {
198
+ if (process.platform === 'win32') {
199
+ const r = execFileSync('netstat', ['-ano'], { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
200
+ const m = new RegExp(`TCP\\s+[^\\s]+:${port}\\s+.*?LISTENING\\s+(\\d+)`).exec(r);
201
+ if (!m) return null;
202
+ return { pid: m[1], cmd: '', dsh: false };
203
+ }
204
+ const r = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 });
205
+ const lines = r.trim().split('\n').slice(1).filter(Boolean);
206
+ if (!lines.length) return null;
207
+ const parts = lines[0].trim().split(/\s+/);
208
+ const cmd = parts[0] || '';
209
+ const pid = parts[1] || '';
210
+ let dsh = /dsh|deepseek/.test(cmd);
211
+ if (!dsh && pid) {
212
+ try { dsh = /dsh web|deepseek-ai/.test(execFileSync('ps', ['-p', pid, '-o', 'command='], { encoding: 'utf8' })); } catch { /* 无法识别则按非 dsh 处理 */ }
213
+ }
214
+ return { pid, cmd, dsh };
215
+ } catch { return null; }
216
+ }
217
+
218
+ function checkPort3080() {
219
+ return new Promise((resolve) => {
220
+ if (!wants('env')) { resolve(); return; }
221
+ const port = Number(process.env.DSH_DOCTOR_PORT || 3080);
222
+ const srv = net.createServer();
223
+ srv.unref();
224
+ let done = false;
225
+ const finish = (fn) => (...args) => { if (done) return; done = true; try { fn(...args); } catch { } resolve(); };
226
+ srv.on('error', finish((e) => {
227
+ if (e.code === 'EADDRINUSE') {
228
+ const info = portOccupierInfo(port);
229
+ if (info && info.dsh) report('env', 'E10-port-3080', true, `端口 ${port} 被 dsh web 实例占用(PID ${info.pid})——宿主自身或另一实例,正常`, undefined);
230
+ else if (info) report('env', 'E10-port-3080', false, `端口 ${port} 被其他程序占用(PID ${info.pid}: ${info.cmd}),dsh web 启动会 address in use(#1719)`, `关掉占用进程,或让 dsh web 用别的端口`);
231
+ else report('env', 'E10-port-3080', true, `⚠ 端口 ${port} 被占用但无法识别占用者`, undefined);
232
+ } else {
233
+ report('env', 'E10-port-3080', false, `端口 ${port} 探测异常: ${e.message.slice(0, 60)}`, undefined);
234
+ }
235
+ }));
236
+ srv.listen(port, '127.0.0.1', finish(() => { srv.close(); report('env', 'E10-port-3080', true, `端口 ${port} 空闲`, undefined); }));
237
+ });
238
+ }
239
+
188
240
  /* ================= profile ================= */
189
241
  function checkProfile(name) {
190
242
  if (!wants('profile')) return;
@@ -235,7 +287,8 @@ function checkProfile(name) {
235
287
  const out = new Set();
236
288
  if (!existsSync(patchPath)) return out;
237
289
  const text = readFileSync(patchPath, 'utf8');
238
- for (const m of text.matchAll(/^\s*-\s*name:\s*['"]?([^'"\s]+)/gm)) out.add(m[1]);
290
+ // 真实格式:`- id:` 下缩进的 `name:` 行(无破折号)——2026-08-15 fixtures 发现旧正则从未匹配
291
+ for (const m of text.matchAll(/^\s*name:\s*['"]?([^'"\s]+)/gm)) out.add(m[1]);
239
292
  return out;
240
293
  })();
241
294
 
@@ -293,17 +346,54 @@ function checkProfile(name) {
293
346
  const dangling = Object.entries(deps).filter(([, spec]) => spec.startsWith('file:')).filter(([, spec]) => !existsSync(resolveFileSpec(spec)));
294
347
  if (dangling.length) report('profile', 'P4', false, `悬空 file: 依赖(#1197): ${dangling.map(([n, s]) => `${n} (${s})`).join(', ')}`, '恢复目录或移除依赖');
295
348
  else report('profile', 'P4', true, 'file: 依赖完整', undefined);
296
- // P5 顶层 @deepseek-ai/* 重复
349
+ // P5 顶层 @deepseek-ai/* 重复(#1486/#1697:hoisted 布局下同版本双实例 → 模块级 Symbol 不匹配)
350
+ // symlink 指向宿主同一份(#1697 的 link: workaround / pnpm file: 正常形态)= 单实例,放行
297
351
  const topDup = [];
298
352
  const topDir = join(dir, 'node_modules', '@deepseek-ai');
353
+ const hostScope = installAnchor ? join(installAnchor, '@deepseek-ai') : null;
299
354
  if (existsSync(topDir)) {
300
355
  for (const p of readdirSync(topDir)) {
301
356
  const fp = join(topDir, p);
357
+ let st;
358
+ try { st = lstatSync(fp); } catch { continue; }
359
+ if (st.isSymbolicLink() && hostScope) {
360
+ try {
361
+ const real = realpathSync(fp);
362
+ const hostPkg = join(hostScope, p);
363
+ if (existsSync(hostPkg) && realpathSync(hostPkg) === real) continue; // 宿主同一份
364
+ } catch { /* 无法解析 → 按独立副本处理 */ }
365
+ }
302
366
  if (existsSync(join(fp, 'package.json'))) topDup.push(p);
303
367
  }
304
368
  }
305
- if (topDup.length) report('profile', 'P5', false, `profile 顶层存在 @deepseek-ai/* 重复(#1486 双实例风险): ${topDup.join(', ')}`, '清理 profile node_modules 中与框架版本相同的 @deepseek-ai 包(pnpm install 后会重建,需在 doctor 中提醒)');
369
+ 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)');
306
370
  else report('profile', 'P5', true, '无顶层 @deepseek-ai 重复', undefined);
371
+
372
+ // P7 patch YAML 结构 lint(#1724:~ insert: / 顶层映射+序列混排 / tab / 缺冒号 → parsePatchList 崩 → UI 打不开)
373
+ // 离线、零依赖的保守检查,覆盖已实测的崩溃机制:
374
+ // 1) ~ / null 等非法 insert 标记(~ 是 YAML null)
375
+ // 2) 顶层映射(key: value)与顶层序列(- xxx)混排 → js-yaml "document separator expected"
376
+ // 3) tab 缩进(YAML 硬错误);4) insert 缺冒号
377
+ const yamlProblems = [];
378
+ const patchText = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
379
+ if (patchText) {
380
+ const topLines = patchText.split('\n');
381
+ let hasTopMapping = false, hasTopSeq = false;
382
+ topLines.forEach((line, i) => {
383
+ if (!line.trim() || line.trim().startsWith('#')) return;
384
+ if (line.includes('\t')) yamlProblems.push(`第 ${i + 1} 行含制表符缩进(YAML 禁止 tab)`);
385
+ if (/^\s*(~|null|Null|NULL)\s*insert\s*:/.test(line)) yamlProblems.push(`第 ${i + 1} 行 "${line.trim()}" —— ~ 是 YAML null 字面量,应为 "- insert:"(#1724)`);
386
+ else if (/^\s*-\s*insert(\s|$)/.test(line) && !/^\s*-\s*insert\s*:/.test(line)) yamlProblems.push(`第 ${i + 1} 行 "${line.trim()}" —— "- insert" 缺冒号`);
387
+ // 顶层混排检测:col 0 的映射键 vs col 0 的序列项
388
+ if (!/^\s/.test(line)) {
389
+ if (/^[^\s#-][^:]*:\s/.test(line)) hasTopMapping = true;
390
+ if (/^-\s/.test(line)) hasTopSeq = true;
391
+ }
392
+ });
393
+ if (hasTopMapping && hasTopSeq) yamlProblems.push('顶层同时存在 key: value 映射与 - xxx 序列(js-yaml 报 "stream or a document separator is expected",#1724 实测)');
394
+ }
395
+ if (yamlProblems.length) report('profile', 'P7', false, `cordis.patch.yml 结构错误(boot 会崩,UI 打不开 #1724): ${yamlProblems.join('; ')}`, 'patch 必须是顶层纯列表(只有 - insert: / - id: 条目):删掉顶层 key: value 行;~ 是 YAML null;缩进用空格不用 tab');
396
+ else report('profile', 'P7', true, 'cordis.patch.yml 结构正常(无 tab / 无 ~ insert / 无映射-序列混排)', undefined);
307
397
  }
308
398
 
309
399
  /* ================= session ================= */
@@ -707,10 +797,11 @@ export function runCatalogCheck(check, ctx) {
707
797
  }
708
798
  }
709
799
 
710
- /** 逐条执行目录检查,汇入统一 results 管线(src='catalog')。 */
800
+ /** 逐条执行目录检查,汇入统一 results 管线(src='catalog')。尊重 --profile/--env/--session 收窄。 */
711
801
  function checkCatalog(ctx, catalog) {
712
802
  const platform = process.platform;
713
803
  for (const check of catalog.checks ?? []) {
804
+ if (!wants(check.section)) continue; // 与内置检查一致的 section 收窄
714
805
  const when = check.when ?? {};
715
806
  if (Array.isArray(when.os) && !when.os.includes(platform)) continue;
716
807
  if (check.section === 'profile' && !ctx.profileDir) continue; // profile 无效时跳过 profile 段
@@ -723,12 +814,79 @@ function checkCatalog(ctx, catalog) {
723
814
  }
724
815
  }
725
816
 
817
+ /* ================= 层 B:版本检查与更新(v0.2.1) =================
818
+ * 检查 npm dist-tags.latest 是否比本地版本新(TTL 6h 缓存 + 离线回退 last-known-good)。
819
+ * 默认只提示;--update 手动执行更新;DSH_DOCTOR_AUTO_UPDATE=1 可用时自动更新。
820
+ * 诚实边界:cordis 启动时加载插件,更新后需重启 dsh web 才生效。
821
+ */
822
+ const UPDATE_URL = 'https://registry.npmjs.org/@moonquake2004%2Fdsh-doctor';
823
+ const UPDATE_TTL_MS = 6 * 60 * 60 * 1000;
824
+
825
+ export function localVersion() {
826
+ try {
827
+ const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
828
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
829
+ } catch { return '0.0.0'; }
830
+ }
831
+
832
+ /** 返回 { current, latest, available };latest=null 表示无法确认(离线且无缓存)。 */
833
+ export async function checkForUpdate({ noRemote = false, fetchImpl, home = HOME } = {}) {
834
+ const current = localVersion();
835
+ if (noRemote || typeof fetchImpl !== 'function') return { current, latest: null, available: false };
836
+ const cachePath = join(home, '.cache', 'dsh-doctor', 'update.json');
837
+ const readCache = () => { try { const d = JSON.parse(readFileSync(cachePath, 'utf8')); return d && typeof d.latest === 'string' ? d : null; } catch { return null; } };
838
+ try {
839
+ const c = readCache();
840
+ if (c && Date.now() - statSync(cachePath).mtimeMs < UPDATE_TTL_MS) return { current, latest: c.latest, available: c.latest !== current };
841
+ } catch { /* 回退 */ }
842
+ try {
843
+ const ac = new AbortController();
844
+ const timer = setTimeout(() => ac.abort(), 3000);
845
+ const res = await fetchImpl(UPDATE_URL, { signal: ac.signal });
846
+ clearTimeout(timer);
847
+ if (res && res.ok) {
848
+ const data = await res.json();
849
+ const latest = data?.['dist-tags']?.latest;
850
+ if (typeof latest === 'string') {
851
+ try { mkdirSync(dirname(cachePath), { recursive: true }); writeFileSync(cachePath, JSON.stringify({ latest, checkedAt: new Date().toISOString() })); } catch { /* 缓存失败不影响 */ }
852
+ return { current, latest, available: latest !== current };
853
+ }
854
+ }
855
+ } catch { /* 离线/超时 → last-known-good */ }
856
+ const stale = readCache();
857
+ if (stale) return { current, latest: stale.latest, available: stale.latest !== current };
858
+ return { current, latest: null, available: false };
859
+ }
860
+
861
+ /** 判断本模块是否安装在某个 profile 的 node_modules 下;返回 profile 目录或 null。 */
862
+ export function profileDirOfModule() {
863
+ const here = fileURLToPath(new URL('.', import.meta.url));
864
+ const m = here.match(/(\/\.dsh\/profiles\/[^/]+)\/node_modules\//);
865
+ return m ? m[1] : null;
866
+ }
867
+
868
+ /** 执行更新(profile 内 pnpm install 刷新 file:/npm 依赖;可用 DSH_DOCTOR_UPDATE_CMD 覆盖命令)。 */
869
+ export function runUpdate() {
870
+ const override = process.env.DSH_DOCTOR_UPDATE_CMD;
871
+ if (override) {
872
+ const r = spawnSync(override, { shell: true, stdio: 'inherit' });
873
+ return r.status === 0 ? '更新命令执行完成,请重启 dsh web 生效' : `更新命令失败(exit ${r.status ?? r.error?.message})`;
874
+ }
875
+ const profileDir = profileDirOfModule();
876
+ if (profileDir) {
877
+ const r = spawnSync('pnpm', ['install'], { cwd: profileDir, stdio: 'inherit' });
878
+ return r.status === 0 ? `已更新 ${profileDir},请重启 dsh web 使新版本生效` : `pnpm install 失败(exit ${r.status ?? r.error?.message})`;
879
+ }
880
+ return '仓库 checkout 模式:请 git pull 后重新安装插件(file: 依赖指向仓库)';
881
+ }
882
+
726
883
  /* ================= main ================= */
727
884
  const profileArg = (() => { const i = process.argv.indexOf('--profile'); return i >= 0 ? process.argv[i + 1] : 'web'; })();
728
885
  const sessionArg = (() => { const i = process.argv.indexOf('--session'); return i >= 0 ? process.argv[i + 1] : undefined; })();
729
886
 
730
887
  async function run() {
731
888
  try { checkEnv(); } catch (e) { report('env', 'E0', false, `env 检查异常: ${e.message.slice(0, 80)}`); }
889
+ try { await checkPort3080(); } catch (e) { report('env', 'E10-port-3080', false, `端口检查异常: ${e.message.slice(0, 60)}`); }
732
890
  try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
733
891
  try { checkSession(sessionArg); } catch (e) { report('session', 'S0', false, `session 检查异常: ${e.message.slice(0, 100)}`); }
734
892
  try { scanAllSessions(); } catch (e) { report('session', 'S11', false, `全会话扫描异常: ${e.message.slice(0, 100)}`); }
@@ -745,10 +903,22 @@ async function run() {
745
903
  catalogMeta = { source: 'error', checks: 0, error: e.message.slice(0, 80) };
746
904
  }
747
905
 
906
+ // 层 B:版本检查与更新(--no-catalog 同时禁用网络检查;--update 手动更新;DSH_DOCTOR_AUTO_UPDATE=1 自动)
907
+ const noRemote = process.argv.includes('--no-catalog');
908
+ let updateInfo = { current: localVersion(), latest: null, available: false };
909
+ try {
910
+ updateInfo = await checkForUpdate({ noRemote, fetchImpl: typeof fetch === 'function' ? fetch : undefined });
911
+ } catch (e) {
912
+ updateInfo = { current: localVersion(), latest: null, available: false, error: e.message.slice(0, 60) };
913
+ }
914
+ if (process.argv.includes('--update') || (process.env.DSH_DOCTOR_AUTO_UPDATE === '1' && updateInfo.available)) {
915
+ updateInfo.applied = runUpdate();
916
+ }
917
+
748
918
  // 退出码只计内置失败 + catalog 中 severity=error 的失败;warn 失败提示但不改退出码
749
919
  const bad = results.filter((r) => !r.ok && catalogSeverity.get(r.id) !== 'warn');
750
920
  if (jsonOut) {
751
- console.log(JSON.stringify({ ok: bad.length === 0, checks: results, catalog: catalogMeta }, null, 2));
921
+ console.log(JSON.stringify({ ok: bad.length === 0, checks: results, catalog: catalogMeta, update: updateInfo }, null, 2));
752
922
  } else {
753
923
  const sectionOrder = { env: 0, profile: 1, session: 2, catalog: 3 };
754
924
  const ordered = [...results].sort((a, b) => (sectionOrder[a.section] ?? 9) - (sectionOrder[b.section] ?? 9));
@@ -760,6 +930,11 @@ async function run() {
760
930
  console.log(` ${mark} [${r.id}] ${r.detail}${r.src === 'catalog' ? ' [目录]' : ''}`);
761
931
  if (!r.ok && r.fix) console.log(` ↳ 修复: ${r.fix}`);
762
932
  }
933
+ if (updateInfo.available && !updateInfo.applied) {
934
+ console.log(`\n⚠ 新版本 ${updateInfo.latest} 可用(当前 ${updateInfo.current})→ 运行 \`dsh-doctor --update\` 或 \`dsh plugin update\``);
935
+ } else if (updateInfo.applied) {
936
+ console.log(`\n✓ ${updateInfo.applied}`);
937
+ }
763
938
  console.log(`\n${bad.length === 0 ? '✓ 全部通过' : `✗ ${bad.length} 个问题`}(profile=${profileArg},目录=${catalogMeta.source},${catalogMeta.checks} 条)`);
764
939
  }
765
940
  process.exit(bad.length === 0 ? 0 : 1);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@moonquake2004/dsh-doctor",
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.",
3
+ "version": "0.2.3",
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",