@moonquake2004/dsh-doctor 0.4.17 → 0.4.19

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.
Files changed (2) hide show
  1. package/dsh-doctor.mjs +150 -0
  2. package/package.json +1 -1
package/dsh-doctor.mjs CHANGED
@@ -575,6 +575,26 @@ function checkProfile(name) {
575
575
  }
576
576
  if (bad.length) report('profile', 'P3', false, `用户 patch 中不可解析的 name(#1197/#880): ${bad.join(', ')}`, `dsh plugin --profile ${name} add <包> 或修复 file: 依赖`);
577
577
  else report('profile', 'P3', true, '用户 patch insert 均可解析', undefined);
578
+ // P18:profile manifest 的 version(#6667)
579
+ // 锚点:dsh-plugin-package-inventory-deepseek/lib/index.js:34 —— identityFromManifest 会抛
580
+ // `must declare non-empty name and version`;它虽有 allowAnonymous 容忍**缺 name**,但**不容忍缺 version**。
581
+ // 而 harness 自己生成的 profile manifest 恰恰是「有 name、无 version」(本机 web/daily 皆如此)。
582
+ // 触发条件:从 profile 根解析到**游离本地模块**时,最近的 manifest(即 profile 自身)会被当包处理 → 抛错 →
583
+ // DeepSeek 请求以 REQUEST_EXTENSION 失败(#6667 报告的最小复现)。故这里按"条件性风险"报 warn,不报 fail。
584
+ {
585
+ let profManifest = null;
586
+ try { profManifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); } catch { profManifest = null; }
587
+ if (!profManifest || !(profManifest.dsh && profManifest.dsh.profile)) {
588
+ reportSkip('profile', 'P18', '未找到 profile manifest(无 dsh.profile),跳过 version 检查');
589
+ } else if (typeof profManifest.version === 'string' && profManifest.version.length > 0) {
590
+ report('profile', 'P18', true, `profile manifest 声明了 version(${profManifest.version}),不触发 #6667`);
591
+ } else {
592
+ report('profile', 'P18', false,
593
+ `profile manifest 有 name(${profManifest.name})但**没有 version**——与 #6667 的条件一致:package inventory 在解析**游离本地模块**时会把该 manifest 当包处理并抛 "must declare non-empty name and version"(dsh-plugin-package-inventory-deepseek:34;其 allowAnonymous 只容忍缺 name),表现为 DeepSeek 请求 REQUEST_EXTENSION 失败`,
594
+ '上游修复前可先给 profile manifest 补一行 version(如 "version": "0.0.0")作为绕过;若有游离 .mjs/.js 模块被加载,这是首要排查点');
595
+ }
596
+ }
597
+
578
598
  // P4 file: 依赖悬空(file: 目标可能是相对(file:./plugins/x)或绝对(file:/abs/path))
579
599
  const resolveFileSpec = (spec) => {
580
600
  const target = spec.slice(5);
@@ -1618,6 +1638,7 @@ catalogSeverity.set('P14', 'warn');
1618
1638
  catalogSeverity.set('P15', 'error');
1619
1639
  catalogSeverity.set('P16', 'warn');
1620
1640
  catalogSeverity.set('P17', 'warn');
1641
+ catalogSeverity.set('P18', 'warn'); // #6667:条件性风险(需游离本地模块才触发),提示但不翻退出码
1621
1642
 
1622
1643
  function bundledCatalog() {
1623
1644
  const p = new URL('./checks.json', import.meta.url);
@@ -1929,7 +1950,136 @@ const flagValue = (name) => { const i = process.argv.indexOf(name); return i >=
1929
1950
  const profileArg = (() => { const i = process.argv.indexOf('--profile'); return i >= 0 ? process.argv[i + 1] : 'web'; })();
1930
1951
  const sessionArg = (() => { const i = process.argv.indexOf('--session'); return i >= 0 ? process.argv[i + 1] : undefined; })();
1931
1952
 
1953
+
1954
+ /* ================= 启动失败自救:装载模拟 + 隔离 =================
1955
+ * 场景(用户高频反馈):装了个不兼容的插件、或 dsh 升级后与旧插件不兼容 → **dsh 根本起不来**,
1956
+ * 于是没法用 dsh 自己来诊断,只能借别的工具。本工具是独立 Node CLI(`npx @moonquake2004/dsh-doctor`),
1957
+ * **不需要 dsh 能启动**,所以这两个子命令正是为此设计:
1958
+ * --boot-check 离线模拟插件树装载:逐条 entry 真去 import,报出到底哪一条炸、炸在哪
1959
+ * --quarantine <包名> 把某个 bundle 从启动列表里摘掉(先备份),让 dsh 能先起来
1960
+ * --unquarantine <包名> 撤销隔离
1961
+ * 注意:--boot-check 会**执行插件顶层代码**(这正是"启动"本身的语义),故为显式开关、非默认行为。
1962
+ */
1963
+
1964
+ /** 解析 profile 的启动列表与各 bundle 的 entry(含用户 patch 的 insert),跳过 disabled 与已隔离项。 */
1965
+ function collectBootEntries(profileDir) {
1966
+ const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'));
1967
+ const prof = manifest.dsh?.profile ?? {};
1968
+ const quarantined = new Set((prof._quarantined ?? []).map((q) => q.name));
1969
+ const bundles = (prof.bundles ?? []).filter((b) => !quarantined.has(b));
1970
+ const out = [];
1971
+ const parsePatch = (text, source) => {
1972
+ let cur = null;
1973
+ const flush = () => { if (cur) out.push(cur); cur = null; };
1974
+ for (const line of text.split('\n')) {
1975
+ const idM = /^\s*-?\s*id:\s*['"]?([\w@/.\-]+)['"]?\s*$/.exec(line);
1976
+ if (idM) { flush(); cur = { id: idM[1], bundle: source, name: null, disabled: false }; continue; }
1977
+ if (!cur) continue;
1978
+ const nameM = /^\s*name:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(line);
1979
+ if (nameM) { cur.name = nameM[1]; continue; }
1980
+ if (/^\s*disabled:\s*true\s*$/.test(line)) cur.disabled = true;
1981
+ }
1982
+ flush();
1983
+ };
1984
+ for (const b of bundles) {
1985
+ const patchPath = join(profileDir, 'node_modules', b, 'cordis.patch.yml');
1986
+ if (!existsSync(patchPath)) continue;
1987
+ try { parsePatch(readFileSync(patchPath, 'utf8'), b); } catch { /* 读不了由 P1/P7 报 */ }
1988
+ }
1989
+ const userPatch = join(profileDir, 'cordis.patch.yml');
1990
+ if (existsSync(userPatch)) { try { parsePatch(readFileSync(userPatch, 'utf8'), '(user patch)'); } catch { /* 同上 */ } }
1991
+ return out.filter((e) => !e.disabled);
1992
+ }
1993
+
1994
+ /** 归类 import 失败,给出人能照做的判断。 */
1995
+ function classifyImportError(msg) {
1996
+ if (/does not provide an export named/.test(msg)) return { kind: 'missing-export', hint: '插件比所装的 @deepseek-ai/* 旧/新:把该插件升级到匹配版本(或降级核心)' };
1997
+ if (/ERR_MODULE_NOT_FOUND|Cannot find module/.test(msg)) return { kind: 'not-installed', hint: '依赖或包本身没装全:在该 profile 里重装该插件' };
1998
+ if (/NODE_MODULE_VERSION|compiled against a different Node\.js version|invalid ELF header|not a valid Win32/.test(msg)) return { kind: 'native-abi', hint: '原生模块与当前 Node ABI 不匹配:重装该插件(npm rebuild / 重新 pnpm add)' };
1999
+ if (/ERR_PACKAGE_PATH_NOT_EXPORTED|ERR_REQUIRE_ESM|require\(\) of ES Module/.test(msg)) return { kind: 'module-format', hint: '包导出/模块格式问题:升级该插件(多为上游未适配)' };
2000
+ if (/was killed|ETIMEDOUT|timed out/.test(msg)) return { kind: 'hang', hint: '顶层代码卡住:该插件在加载期做了阻塞操作,需上游修复' };
2001
+ return { kind: 'other', hint: '按上面的原始报错定位;确认后可先隔离该 bundle 让 dsh 起来' };
2002
+ }
2003
+
2004
+ async function runBootCheck(profileDir) {
2005
+ const entries = collectBootEntries(profileDir);
2006
+ const targets = entries.filter((e) => e.name);
2007
+ const results = [];
2008
+ for (const e of targets) {
2009
+ const spec = e.name;
2010
+ // 宿主内置与相对路径不做 import 探测(前者由宿主提供,后者依赖运行上下文)
2011
+ if (spec.startsWith('cordis:') || spec.startsWith('.') || spec.startsWith('/')) {
2012
+ results.push({ id: e.id, bundle: e.bundle, spec, status: 'skipped', reason: '宿主内置或相对路径,不在本探测范围' });
2013
+ continue;
2014
+ }
2015
+ const r = spawnSync(process.execPath, ['--input-type=module', '-e', `await import(${JSON.stringify(spec)});`],
2016
+ { cwd: profileDir, encoding: 'utf8', timeout: 20000, maxBuffer: 8 * 1024 * 1024 });
2017
+ if (r.status === 0) { results.push({ id: e.id, bundle: e.bundle, spec, status: 'loadable' }); continue; }
2018
+ const raw = String((r.stderr || '') + (r.stdout || '')).trim();
2019
+ const first = raw.split('\n').find((l) => /Error|error/.test(l)) || raw.split('\n')[0] || '(无输出)';
2020
+ const cls = classifyImportError(raw);
2021
+ results.push({ id: e.id, bundle: e.bundle, spec, status: 'failed', kind: cls.kind, hint: cls.hint, error: first.slice(0, 220) });
2022
+ }
2023
+ return results;
2024
+ }
2025
+
2026
+ function quarantineBundle(profileDir, name, undo = false) {
2027
+ const manifestPath = join(profileDir, 'package.json');
2028
+ const raw = readFileSync(manifestPath, 'utf8');
2029
+ const manifest = JSON.parse(raw);
2030
+ manifest.dsh = manifest.dsh ?? {};
2031
+ manifest.dsh.profile = manifest.dsh.profile ?? {};
2032
+ const prof = manifest.dsh.profile;
2033
+ prof.bundles = prof.bundles ?? [];
2034
+ prof._quarantined = prof._quarantined ?? [];
2035
+ if (undo) {
2036
+ const hit = prof._quarantined.find((q) => q.name === name);
2037
+ if (!hit) return { ok: false, error: `隔离列表里没有 ${name}` };
2038
+ prof._quarantined = prof._quarantined.filter((q) => q.name !== name);
2039
+ if (!prof.bundles.includes(name)) prof.bundles.push(name);
2040
+ } else {
2041
+ if (!prof.bundles.includes(name)) return { ok: false, error: `${name} 不在 dsh.profile.bundles 里(当前 ${prof.bundles.length} 项)` };
2042
+ writeFileSync(`${manifestPath}.bak.${Date.now()}`, raw); // 备份原始 manifest
2043
+ prof.bundles = prof.bundles.filter((b) => b !== name);
2044
+ prof._quarantined.push({ name, at: new Date().toISOString(), by: 'dsh-doctor --quarantine' });
2045
+ }
2046
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
2047
+ return { ok: true, bundles: prof.bundles.length, quarantined: prof._quarantined.map((q) => q.name) };
2048
+ }
2049
+
1932
2050
  async function run() {
2051
+ // 启动失败自救子命令(不需要 dsh 能启动)
2052
+ const bootCheckArg = process.argv.includes('--boot-check');
2053
+ const quarantineArg = flagValue('--quarantine');
2054
+ const unquarantineArg = flagValue('--unquarantine');
2055
+ if (bootCheckArg || quarantineArg || unquarantineArg) {
2056
+ const profDir = resolveProfile(profileArg || 'web');
2057
+ try {
2058
+ if (quarantineArg || unquarantineArg) {
2059
+ const r = quarantineBundle(profDir, quarantineArg || unquarantineArg, !!unquarantineArg);
2060
+ if (!r.ok) { console.error(`✗ ${r.error}`); process.exit(1); }
2061
+ console.log(JSON.stringify({ ok: true, action: unquarantineArg ? 'unquarantine' : 'quarantine', ...r, next: '重启 dsh;随后用 --boot-check 复查,或用 --unquarantine 撤销' }, null, 2));
2062
+ process.exit(0);
2063
+ }
2064
+ const results = await runBootCheck(profDir);
2065
+ const failed = results.filter((r) => r.status === 'failed');
2066
+ if (jsonOut) {
2067
+ console.log(JSON.stringify({ ok: failed.length === 0, profile: profDir, checked: results.length, failed: failed.length, results }, null, 2));
2068
+ } else {
2069
+ console.log(`装载模拟(${profDir}):检查 ${results.length} 条 entry,失败 ${failed.length} 条`);
2070
+ for (const r of results) {
2071
+ if (r.status === 'failed') console.log(` ✗ [${r.bundle}] ${r.id} → ${r.spec}\n ${r.kind}: ${r.error}\n 修复方向:${r.hint}\n 先起来:npx @moonquake2004/dsh-doctor --quarantine ${r.bundle}`);
2072
+ else if (r.status === 'skipped') console.log(` ⊖ ${r.id}(${r.reason})`);
2073
+ }
2074
+ if (!failed.length) console.log(' ✓ 所有可探测 entry 均可导入——启动失败若仍发生,问题多在配置合并或原生环境,请贴 --json 输出');
2075
+ }
2076
+ process.exit(failed.length ? 2 : 0);
2077
+ } catch (e) {
2078
+ console.error(`启动模拟失败: ${e.message}`);
2079
+ process.exit(1);
2080
+ }
2081
+ }
2082
+
1933
2083
  // 层 C 观察者(--observe / --observe-apply):独立子命令,跑完即退出,不执行常规检查
1934
2084
  const observeArg = flagValue('--observe');
1935
2085
  const observeApplyArg = flagValue('--observe-apply');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moonquake2004/dsh-doctor",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
4
4
  "description": "Offline diagnostic for DeepSeek Harness — 28 built-in + 5 catalog checks across env/profile/session (Layer A checks-as-data), self-update (Layer B), and a semi-automatic LLM observer (Layer C, --observe); Doctor panel in web UI settings.",
5
5
  "main": "lib/index.js",
6
6
  "files": [