@moonquake2004/dsh-doctor 0.2.0 → 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 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
@@ -42,11 +42,11 @@
42
42
  * 退出码:0 = 全部通过;1 = 发现可修复问题(内置 + catalog severity=error);warn 级失败不改退出码。
43
43
  */
44
44
  import { execFileSync, spawnSync } from 'node:child_process';
45
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
45
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
46
46
  import { createRequire } from 'node:module';
47
47
  import { basename, delimiter as PATH_DELIM, dirname, join } from 'node:path';
48
48
  import { homedir } from 'node:os';
49
- import { pathToFileURL } from 'node:url';
49
+ import { pathToFileURL, fileURLToPath } from 'node:url';
50
50
 
51
51
  const HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
52
52
  const results = []; // { section, id, ok, detail, fix? }
@@ -235,7 +235,8 @@ function checkProfile(name) {
235
235
  const out = new Set();
236
236
  if (!existsSync(patchPath)) return out;
237
237
  const text = readFileSync(patchPath, 'utf8');
238
- for (const m of text.matchAll(/^\s*-\s*name:\s*['"]?([^'"\s]+)/gm)) out.add(m[1]);
238
+ // 真实格式:`- id:` 下缩进的 `name:` 行(无破折号)——2026-08-15 fixtures 发现旧正则从未匹配
239
+ for (const m of text.matchAll(/^\s*name:\s*['"]?([^'"\s]+)/gm)) out.add(m[1]);
239
240
  return out;
240
241
  })();
241
242
 
@@ -293,16 +294,27 @@ function checkProfile(name) {
293
294
  const dangling = Object.entries(deps).filter(([, spec]) => spec.startsWith('file:')).filter(([, spec]) => !existsSync(resolveFileSpec(spec)));
294
295
  if (dangling.length) report('profile', 'P4', false, `悬空 file: 依赖(#1197): ${dangling.map(([n, s]) => `${n} (${s})`).join(', ')}`, '恢复目录或移除依赖');
295
296
  else report('profile', 'P4', true, 'file: 依赖完整', undefined);
296
- // P5 顶层 @deepseek-ai/* 重复
297
+ // P5 顶层 @deepseek-ai/* 重复(#1486/#1697:hoisted 布局下同版本双实例 → 模块级 Symbol 不匹配)
298
+ // symlink 指向宿主同一份(#1697 的 link: workaround / pnpm file: 正常形态)= 单实例,放行
297
299
  const topDup = [];
298
300
  const topDir = join(dir, 'node_modules', '@deepseek-ai');
301
+ const hostScope = installAnchor ? join(installAnchor, '@deepseek-ai') : null;
299
302
  if (existsSync(topDir)) {
300
303
  for (const p of readdirSync(topDir)) {
301
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
+ }
302
314
  if (existsSync(join(fp, 'package.json'))) topDup.push(p);
303
315
  }
304
316
  }
305
- if (topDup.length) report('profile', 'P5', false, `profile 顶层存在 @deepseek-ai/* 重复(#1486 双实例风险): ${topDup.join(', ')}`, '清理 profile node_modules 中与框架版本相同的 @deepseek-ai 包(pnpm install 后会重建,需在 doctor 中提醒)');
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)');
306
318
  else report('profile', 'P5', true, '无顶层 @deepseek-ai 重复', undefined);
307
319
  }
308
320
 
@@ -707,10 +719,11 @@ export function runCatalogCheck(check, ctx) {
707
719
  }
708
720
  }
709
721
 
710
- /** 逐条执行目录检查,汇入统一 results 管线(src='catalog')。 */
722
+ /** 逐条执行目录检查,汇入统一 results 管线(src='catalog')。尊重 --profile/--env/--session 收窄。 */
711
723
  function checkCatalog(ctx, catalog) {
712
724
  const platform = process.platform;
713
725
  for (const check of catalog.checks ?? []) {
726
+ if (!wants(check.section)) continue; // 与内置检查一致的 section 收窄
714
727
  const when = check.when ?? {};
715
728
  if (Array.isArray(when.os) && !when.os.includes(platform)) continue;
716
729
  if (check.section === 'profile' && !ctx.profileDir) continue; // profile 无效时跳过 profile 段
@@ -723,6 +736,72 @@ function checkCatalog(ctx, catalog) {
723
736
  }
724
737
  }
725
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
+
726
805
  /* ================= main ================= */
727
806
  const profileArg = (() => { const i = process.argv.indexOf('--profile'); return i >= 0 ? process.argv[i + 1] : 'web'; })();
728
807
  const sessionArg = (() => { const i = process.argv.indexOf('--session'); return i >= 0 ? process.argv[i + 1] : undefined; })();
@@ -745,10 +824,22 @@ async function run() {
745
824
  catalogMeta = { source: 'error', checks: 0, error: e.message.slice(0, 80) };
746
825
  }
747
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
+
748
839
  // 退出码只计内置失败 + catalog 中 severity=error 的失败;warn 失败提示但不改退出码
749
840
  const bad = results.filter((r) => !r.ok && catalogSeverity.get(r.id) !== 'warn');
750
841
  if (jsonOut) {
751
- console.log(JSON.stringify({ ok: bad.length === 0, checks: results, catalog: catalogMeta }, null, 2));
842
+ console.log(JSON.stringify({ ok: bad.length === 0, checks: results, catalog: catalogMeta, update: updateInfo }, null, 2));
752
843
  } else {
753
844
  const sectionOrder = { env: 0, profile: 1, session: 2, catalog: 3 };
754
845
  const ordered = [...results].sort((a, b) => (sectionOrder[a.section] ?? 9) - (sectionOrder[b.section] ?? 9));
@@ -760,6 +851,11 @@ async function run() {
760
851
  console.log(` ${mark} [${r.id}] ${r.detail}${r.src === 'catalog' ? ' [目录]' : ''}`);
761
852
  if (!r.ok && r.fix) console.log(` ↳ 修复: ${r.fix}`);
762
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
+ }
763
859
  console.log(`\n${bad.length === 0 ? '✓ 全部通过' : `✗ ${bad.length} 个问题`}(profile=${profileArg},目录=${catalogMeta.source},${catalogMeta.checks} 条)`);
764
860
  }
765
861
  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.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",