@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 +3 -2
- package/dsh-doctor.mjs +103 -7
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
4
|
-
"description": "Offline diagnostic for DeepSeek Harness — 19 built-in 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",
|