dsh-m 0.9.28 → 0.9.30
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/CHANGELOG.md +30 -0
- package/docs/DESIGN.md +1 -0
- package/lib/cli.js +66 -2
- package/lib/core/doctor.js +454 -0
- package/lib/core/dsh-version.js +6 -2
- package/lib/core/host-api.js +10 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,21 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
|
|
|
8
8
|
|
|
9
9
|
## 中文
|
|
10
10
|
|
|
11
|
+
### 0.9.30 变更:修复 bin 符号链接静默 no-op(F2)+ 版本解析 realpath 第三源(F1)
|
|
12
|
+
|
|
13
|
+
- **根因同源**:pnpm 生态里两类关键入口都是**符号链接**——`node_modules/.bin/dshm`(指向 lib/cli.js)与全局 shim `PNPM_HOME/dsh`(指向真实 bin.js)——而两处代码都在 realpath 之前做了路径身份判定。
|
|
14
|
+
- **F2(自 v0.2.0 bin 入口引入以来潜在,本机 0.9.28 装机形态首次踩中并实证;🔴 bin 入口全命令静默 exit 0)**:cli.ts `invokedDirectly` 比较 `argv[1]`(链接路径)与 `import.meta.url`(node realpath 后的真实路径)永假,`dshm <任何命令>` 无输出直接退出。修复:比较前对 `argv[1]` 同样 realpath。新增 `tests/cli-bin-symlink.test.mjs`(经符号链接调用 --help 与 doctor --json 的复活验证,EPERM 环境照仓内先例 skip)。
|
|
15
|
+
- **F1(0.9.29 装机验收实证)**:dsh-version.ts `readLauncherPackageVersion` 从 shim 位置直接向上三级找 package.json,落在 pnpm home 目录树上空走——宿主内纯 FS 版本解析在本机拓扑(`node PNPM_HOME/dsh web`)不可得,doctor 的 stale 判定降级、ping chip 被迫吃 spawn 回退。修复:判定 entry 形态后先 realpath 再上溯(本机实测解析得 0.2.0-rc.2)。新增 dsh-version.test.mjs shim 形态用例。
|
|
16
|
+
- **验证**:专项 9/9(含两新用例);全量 1079 pass / 0 fail / 0 skipped;typecheck 零错误。装机后预期:`dshm doctor`(bin 入口)可用;宿主 method 通路 runtimeVersion=0.2.0-rc.2、stale 判定复活(装机时农场 dsh 链接 0.1.7-rc.2 为现成 stale 形态,heal 后归零)。
|
|
17
|
+
|
|
18
|
+
### 0.9.29 变更:profile 体检 Doctor——`dshm doctor` 上线(只读:农场测活 / 残留物清点 / 账实一致)
|
|
19
|
+
|
|
20
|
+
- **动机**:dsh-m 至今没有诊断能力——know-how 014 的「每次 DSH 升级后重跑农场测活」是唯一现役周期必查项(曾 81 条悬空、230 行手工映射留档),know-how 023 实录「账实分裂」形态;本机实扫另发现 8 个空 scope 目录 + 12 个 `*.bak-*` 累积。学 dsh-market check.ts 的设计纪律(纯 FS 边界、三级严重度、unknown≠broken、误报记账、修复责任外移)落地 Day1 子集,选点裁决与对比详见 ADR-0010 与对比报告(2026-10-04)。
|
|
21
|
+
- **新增**:`src/core/doctor.ts` 纯函数核心(无进程/无网络/无写入,任意时刻可安全调用)+ `/dshm` 新 method `doctor` + CLI `dshm doctor [--json]`(error 级发现 exit 1;HELP 同步)。三项检查:**农场测活**(`@deepseek-ai/*` 符号链祖先链遍历;悬空=error;dsh 伞包指向旧运行时 store=提示级——lockstep 店内非伞包版本不与 runtimeVersion 比较,防 cordis 等误报)、**残留物清点**(空 scope / 无 manifest 目录 / pnpm `*_tmp_*` / `*.bak-*`,全部零告警清单,「可见而非清理」)、**账实一致**(pin/实装/lock 三处核对,不一致=warning;lockfile 仅认 9.0 importers 形状,其余 unknown 不猜)。
|
|
22
|
+
- **边界纪律(ADR-0010)**:runtimeVersion 仅用 `readLauncherPackageVersion` 纯 FS 通路(CLI 进程下为 null → stale 判定整体降级 unknown,绝不 spawn);密钥红线只禁含密钥**配置文件**内容(包元数据 version 字段可读);布局判定 workspace 声明优先(本机「hoisted 声明 + 仅 lock.yaml 的残留 .pnpm」并存形态实证);双市场并存(dsh-m+dshmarket 同装)信息级呈现;doctor 永不修复,建议以文字给出。
|
|
23
|
+
- **验收结算(2026-10-04 装机实测)**:farmChecked=236(精确命中评审实测值)/ 悬空 0 / 8 空 scope + 12 bak 入清单 / 账实 13:0 / 双市场信息级 / errors=0 → exit=0 / `--json` 可解析。宿主 method 信封正确但 runtimeVersion=null——**发版日新发现**:本机宿主经 pnpm 全局 shim 启动(`node ~/.local/share/pnpm/dsh web`),`readLauncherPackageVersion` 从 shim 三级上溯落空,stale 判定双通路降级(底层事实人工核对成立:农场 dsh 链接 0.1.7-rc.2 vs 运行时 0.2.0-rc.2;版本第三源列下一批)。另录既有 bug:`.bin/dshm` 符号链接静默 no-op(后经考古为 v0.2.0 起潜在、本机 0.9.28 装机形态首次踩中,见 0.9.30 条目——`invokedDirectly` 比较 symlink 路径与 realpath 永假,bin 入口全命令静默 exit 0;修复列 0.9.30 候选,期间用 `node …/dsh-m/lib/cli.js` 直达)。
|
|
24
|
+
- **验证**:新增 `tests/doctor.test.mjs`(36 例:布局冲突并存/祖先链反空转/两级 targetVersion/降级路径/零告警清单/023 形态/link 协议/lock peer 后缀/CLI 子进程)+ `tests/doctor-api.test.mjs`(2 例:method 信封真跑 + 空 profile);全量 1076 pass / 0 fail / 0 skipped。
|
|
25
|
+
|
|
11
26
|
### 0.9.28 变更:页大小档位去上游化——32/64/96 取代 24/48/96,默认 32
|
|
12
27
|
|
|
13
28
|
- **动机**:24/48/96 是复刻 dsh-market 筛选面板(0.7.2)时带过来的上游血统数字。甄别后发现 96 早已被 dsh-m 内化为核心参数(`WITH_LATEST_MAX` 探测上限、精选区默认页、fast-open 快照判定基准),真正的上游痕迹只有 24——「砍 24、保 96」即去上游化与兼容性的交集。
|
|
@@ -283,6 +298,21 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
|
|
|
283
298
|
|
|
284
299
|
## English
|
|
285
300
|
|
|
301
|
+
### Fixed in 0.9.30 — bin symlink silent no-op (F2) + realpath third source for version resolution (F1)
|
|
302
|
+
|
|
303
|
+
- **Shared root cause**: two critical pnpm-ecosystem entry points are **symlinks** — `node_modules/.bin/dshm` (→ lib/cli.js) and the global shim `PNPM_HOME/dsh` (→ the real bin.js) — while both code sites made path-identity decisions before realpath.
|
|
304
|
+
- **F2 (latent since the bin entry shipped in v0.2.0; first hit and proven on this machine's 0.9.28 install shape; 🔴 every command via the bin entry silently exits 0)**: cli.ts `invokedDirectly` compared `argv[1]` (link path) against `import.meta.url` (node's realpath) — never equal, so `dshm <any command>` printed nothing and exited. Fix: realpath `argv[1]` before comparing. New `tests/cli-bin-symlink.test.mjs` (revival via symlinked --help and doctor --json; EPERM environments skip per repo precedent).
|
|
305
|
+
- **F1 (proven at 0.9.29 install acceptance)**: dsh-version.ts `readLauncherPackageVersion` walked up three levels from the shim location, landing in the pnpm home tree — pure-FS version resolution was unreachable under this host topology (`node PNPM_HOME/dsh web`), degrading doctor's stale judgment and forcing the ping chip onto the spawn fallback. Fix: realpath the entry after the shape check, then walk (locally verified resolving 0.2.0-rc.2). New shim-shape case in dsh-version.test.mjs.
|
|
306
|
+
- **Verification**: targeted 9/9 (two new cases); full suite 1079 pass / 0 fail / 0 skipped; typecheck clean. Post-install expectations: `dshm doctor` works via the bin entry; host method path reports runtimeVersion=0.2.0-rc.2 with stale judgment live (the farm's dsh link at 0.1.7-rc.2 is a ready-made stale form until healed).
|
|
307
|
+
|
|
308
|
+
### Added in 0.9.29 — profile Doctor: `dshm doctor` (read-only: farm liveness / residue inventory / manifest-reality consistency)
|
|
309
|
+
|
|
310
|
+
- **Motivation**: dsh-m had no diagnostics at all — know-how 014's "re-run farm liveness after every DSH upgrade" was the only standing periodic check (81 dangling links and a 230-line manual heal log), know-how 023 documented the manifest-reality split, and a live scan found 8 empty scope dirs + 12 `*.bak-*` files. Day1 subset adopting dsh-market check.ts's design disciplines (pure-FS boundary, three-tier severity, unknown≠broken, false-positive bookkeeping, repair responsibility shifted outward). See ADR-0010 and the comparison report (2026-10-04).
|
|
311
|
+
- **Added**: `src/core/doctor.ts` pure-function core (no processes, no network, no writes — safe to call any time) + new `/dshm` method `doctor` + CLI `dshm doctor [--json]` (exit 1 on error-tier findings; HELP synced). Three checks: **farm liveness** (ancestor-chain walk over `@deepseek-ai/*` symlinks; dangling = error; dsh umbrella pointing at an old runtime store = info-tier — non-umbrella versions are never compared against the runtime version, avoiding cordis-style false positives), **residue inventory** (empty scope dirs / no-manifest dirs / pnpm `*_tmp_*` / `*.bak-*`, all zero-alarm lists, "visible rather than cleaned"), **manifest-reality consistency** (pin vs installed vs lockfile; mismatch = warning; only lockfileVersion 9.0 importers shape is parsed, anything else stays unknown).
|
|
312
|
+
- **Boundary disciplines (ADR-0010)**: runtimeVersion comes only from the pure-FS `readLauncherPackageVersion` (null under the CLI → stale judgment degrades to unknown, never spawns); the secrets red line covers credential-bearing config files only (package metadata version fields are readable); layout detection is workspace-declaration-first (proven on the local "hoisted declaration + vestigial lock.yaml-only .pnpm" shape); dual-market coexistence (dsh-m + dshmarket installed together) surfaces as an informational fact; doctor never repairs — advice is text only.
|
|
313
|
+
- **Acceptance anchors (to verify after install)**: first local run expects farmChecked ≈236 (0 means the walk short-circuited), 0 dangling, 8 empty scopes + 12 bak files listed, dual market flagged.
|
|
314
|
+
- **Verification**: new `tests/doctor.test.mjs` (36 cases) + `tests/doctor-api.test.mjs` (2 cases); full suite 1076 pass / 0 fail / 0 skipped.
|
|
315
|
+
|
|
286
316
|
### Changed in 0.9.28 — page-size set de-upstreamed: 32/64/96 replaces 24/48/96, default 32
|
|
287
317
|
|
|
288
318
|
- **Motivation**: 24/48/96 came bundled with the dsh-market filter-panel replica (0.7.2). On inspection, 96 has long been internalized as a dsh-m core parameter (`WITH_LATEST_MAX` probe cap, primary-zone default page, fast-open snapshot baseline); the only real upstream trace is 24 — "drop 24, keep 96" is exactly the intersection of de-upstreaming and compatibility.
|
package/docs/DESIGN.md
CHANGED
|
@@ -175,6 +175,7 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
|
|
|
175
175
|
- 工具前缀 `dshm_`,共 7 个:`dshm_search` / `dshm_list` / `dshm_install` / `dshm_uninstall` / `dshm_outdated` / `dshm_upgrade` / `dshm_restart`。Agent tools 运行于 Host 进程,与 Web GUI **共用 `host` namespace 缓存与 active config**;`dshm_search` 走服务端过滤(metadata-only,`withLatest=false`、limit ≤80),返回不含本地路径的短 summary。
|
|
176
176
|
- 0.5.0:`dshm_search`/`dshm_install` 消费**合并市场**(§2.5);category 参数接受精选 5 分类 + 任意社区分类 slug;summary 增加社区清单状态(条数/来源/是否 stale);CLI 默认合并社区清单,`DSHM_COMMUNITY_CATALOG=0` 退出,缓存用 `cli/awesome/` 子目录。
|
|
177
177
|
- CLI bin `dshm`:同名同义命令集(`dshm list|search|install|uninstall|outdated|upgrade|restart`),固定 `cli` namespace。`registry`/`search`/`outdated` 在清单不可用时打印配置/实际生效地址并 **exit 1**;`list` 仍列出已装并标记不可用;本地终端可显示完整路径。
|
|
178
|
+
- 体检(Doctor,0.9.29):`/dshm` method `doctor` + CLI `dshm doctor [--json]`(**不新增第 9 个 agent 工具**——工具描述是宿主 agent 常驻上下文,行为面代价大于收益)。纯 FS 只读(无进程/无网络/无写入,清单不可用照常工作),三项检查(农场测活/残留物清点/账实一致)+ 三级严重度 + unknown≠broken + 双市场信息级;runtimeVersion 仅 `readLauncherPackageVersion` 纯 FS 通路(CLI 下降级 unknown,禁 spawn 回退)。决策全记载见 [ADR-0010](./adr/0010-doctor-read-only-diagnostics.md)。
|
|
178
179
|
- 本地 API:`POST /dshm` 单路由 method 分发;除 `ping` 外全部要求 JSON Content-Type + `trustedRestartRequest` host 等价同源防护;typed 错误映射 400/403/404/405/413/415/422/500;`registry-config-apply` 校验失败 422;清单不可用的 `registry`/`market` 仍返回 200 + 结构化状态。
|
|
179
180
|
- 实现顺序:**GUI 先行,CLI 收尾**(核心逻辑同一层,CLI 是薄封装)。
|
|
180
181
|
|
package/lib/cli.js
CHANGED
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { installFromRegistry, listInstalledWithMeta, listMarket, uninstallPlugin, upgradePlugin, InstallGuardError, } from './core/market.js';
|
|
9
9
|
import { togglePlugin as coreTogglePlugin } from './core/toggle.js';
|
|
10
|
+
import { runDoctor } from './core/doctor.js';
|
|
11
|
+
import { readLauncherPackageVersion } from './core/dsh-version.js';
|
|
12
|
+
import { webProfileDir } from './core/env.js';
|
|
10
13
|
import { loadRegistry } from './core/registry.js';
|
|
11
14
|
import { COMMUNITY_CATEGORY_LABELS } from './core/community.js';
|
|
12
15
|
import { scheduleRestart } from './core/restart.js';
|
|
13
16
|
import { upgradeEffectLine } from './core/activation.js';
|
|
14
17
|
import { pathToFileURL } from 'node:url';
|
|
15
|
-
import {
|
|
18
|
+
import { realpathSync } from 'node:fs';
|
|
16
19
|
/** 分类中文标签(0.7.0 Task 6;0.9.16 策展五桶):精选策展桶本地表 + 社区已知标签单一事实源;未知 slug 原样。 */
|
|
17
20
|
const CLI_CATEGORY_LABELS = {
|
|
18
21
|
essentials: '装机必备',
|
|
@@ -130,6 +133,7 @@ const HELP = `dshm — DSH Marketplace(DSH 插件市场:精选策展 + 社
|
|
|
130
133
|
dshm list 列出 web profile 已装插件(含市场标注/可升级)
|
|
131
134
|
dshm outdated 检查已装插件的最新版本
|
|
132
135
|
dshm registry 查看收录清单来源与条目
|
|
136
|
+
dshm doctor [--json] profile 体检(只读:农场测活 / 残留物清点 / 账实一致;error 级发现 exit 1)
|
|
133
137
|
|
|
134
138
|
变更命令(必须 --yes):
|
|
135
139
|
dshm install --id <收录id> [--version 1.2.3] [--force] (--force:确认兼容风险后跳过预检拦截)
|
|
@@ -401,6 +405,62 @@ async function runCliDispatch(argv, deps, io) {
|
|
|
401
405
|
out(`✅ 已请求重启(via ${res.via})。服务恢复后刷新页面即可。`);
|
|
402
406
|
return 0;
|
|
403
407
|
}
|
|
408
|
+
case 'doctor': {
|
|
409
|
+
// 体检(ADR-0010):纯 FS 只读、不依赖 registry(清单不可用照常工作)。
|
|
410
|
+
// runtimeVersion 仅用 readLauncherPackageVersion 纯 FS 通路(CLI 进程下通常为 null
|
|
411
|
+
// → stale 判定降级 unknown,绝不 spawn);errors>0 → exit 1。
|
|
412
|
+
const report = await runDoctor(webProfileDir(), readLauncherPackageVersion());
|
|
413
|
+
if (flags.json === true) {
|
|
414
|
+
out(JSON.stringify(report, null, 2));
|
|
415
|
+
return report.summary.errors > 0 ? 1 : 0;
|
|
416
|
+
}
|
|
417
|
+
const s = report.summary;
|
|
418
|
+
out(`profile 体检 · ${report.profileDir}`);
|
|
419
|
+
out(`布局 ${report.layout} · 运行时 ${report.runtimeVersion ?? '未解析(stale 判定降级 unknown,绝不 spawn)'} · 扫描于 ${report.scannedAt}`);
|
|
420
|
+
out(`农场 ${s.farmChecked}(悬空 ${s.farmDangling} · 指旧 ${s.farmStale}${report.runtimeVersion === null ? '(降级:未判定)' : ''})` +
|
|
421
|
+
` · 残留 ${s.residueCount} · 账实 ${s.accountChecked}(不一致 ${s.accountMismatched}) · errors ${s.errors} · warnings ${s.warnings}`);
|
|
422
|
+
const errs = report.findings.filter((f) => f.severity === 'error');
|
|
423
|
+
const warns = report.findings.filter((f) => f.severity === 'warning');
|
|
424
|
+
if (errs.length) {
|
|
425
|
+
out('\n❌ 错误(将阻止对应链接/包正常工作):');
|
|
426
|
+
for (const f of errs) {
|
|
427
|
+
out(` • ${f.title}`);
|
|
428
|
+
out(` ${f.detail}`);
|
|
429
|
+
out(` → ${f.hint}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (warns.length) {
|
|
433
|
+
out('\n⚠️ 警告(确认异常但不阻止启动):');
|
|
434
|
+
for (const f of warns) {
|
|
435
|
+
out(` • ${f.title}`);
|
|
436
|
+
out(` ${f.detail}`);
|
|
437
|
+
out(` → ${f.hint}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const stale = report.farm.filter((x) => x.state === 'stale-target').slice(0, 3);
|
|
441
|
+
if (stale.length) {
|
|
442
|
+
out('\n指向旧运行时 store(信息级,至多列 3 条,清单零告警):');
|
|
443
|
+
for (const x of stale)
|
|
444
|
+
out(` • ${x.name} → ${x.targetVersion}`);
|
|
445
|
+
}
|
|
446
|
+
if (s.residueCount > 0) {
|
|
447
|
+
const kindCounts = new Map();
|
|
448
|
+
for (const x of report.residue)
|
|
449
|
+
kindCounts.set(x.kind, (kindCounts.get(x.kind) ?? 0) + 1);
|
|
450
|
+
out(`\n残留物:${s.residueCount} 项只列不警(${[...kindCounts].map(([k, n]) => `${k} ${n}`).join(' · ')};可见而非清理,全量见 --json):`);
|
|
451
|
+
for (const x of report.residue.slice(0, 3))
|
|
452
|
+
out(` • [${x.kind}] ${x.path}${x.note ? `(${x.note})` : ''}`);
|
|
453
|
+
if (s.residueCount > 3)
|
|
454
|
+
out(' …');
|
|
455
|
+
}
|
|
456
|
+
for (const u of s.unknowns)
|
|
457
|
+
out(`ℹ️ ${u}`);
|
|
458
|
+
if (report.dualMarket)
|
|
459
|
+
out(`ℹ️ 双市场并存:${report.dualMarket.join(' + ')} 同时在装(写侧互不知晓,排查时留意)。`);
|
|
460
|
+
if (errs.length === 0)
|
|
461
|
+
out('\n未发现 error 级问题。');
|
|
462
|
+
return errs.length > 0 ? 1 : 0;
|
|
463
|
+
}
|
|
404
464
|
default: {
|
|
405
465
|
err(`未知命令:${cmd}\n`);
|
|
406
466
|
out(HELP);
|
|
@@ -414,7 +474,11 @@ const invokedDirectly = (() => {
|
|
|
414
474
|
if (!entry)
|
|
415
475
|
return false;
|
|
416
476
|
try {
|
|
417
|
-
|
|
477
|
+
// F2(0.9.28-0.9.29 实证):pnpm 的 node_modules/.bin/dshm 是指向 lib/cli.js 的
|
|
478
|
+
// 符号链接——node 对入口做 realpath,import.meta.url 是真实路径而 argv[1] 仍是
|
|
479
|
+
// 链接路径,直接比较永假 → bin 入口全命令静默 exit 0。比较前同样 realpath。
|
|
480
|
+
const realEntry = realpathSync(entry);
|
|
481
|
+
return import.meta.url === pathToFileURL(realEntry).href;
|
|
418
482
|
}
|
|
419
483
|
catch {
|
|
420
484
|
return false;
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 体检(Doctor,ADR-0010):对单个 profile 的只读健康检查。
|
|
3
|
+
*
|
|
4
|
+
* 纪律(照 dsh-market check.ts 学习所得,详见对比报告 §4):
|
|
5
|
+
* - 纯文件系统分析:无进程派生、无网络请求、无文件写入/删除——任意时刻可安全调用。
|
|
6
|
+
* 版本解析只用 readLauncherPackageVersion 纯 FS 通路;resolveDshVersion 的
|
|
7
|
+
* spawn `dsh --version` 回退为 doctor 禁区(CLI 下解析为 null 即降级,绝不 spawn)。
|
|
8
|
+
* - 三级严重度:error(断链或必然阻断启动)/ warning(确认异常但不阻止启动)/
|
|
9
|
+
* 结构化清单(只列不警,零告警渲染);unknown≠broken——看不见的对象显式标
|
|
10
|
+
* unknown 并沉默,不推断为损坏。
|
|
11
|
+
* - 每条 error/warning 必须含三要素:发生了什么(title)/ 为什么(detail)/ 现在怎么办(hint)。
|
|
12
|
+
* - 密钥红线:绝不读取或转储 cordis.patch.yml 等含密钥配置文件内容;报告只含
|
|
13
|
+
* 包名、路径、版本号等结构化事实(链接目标目录 package.json 的 version 字段
|
|
14
|
+
* 属包元数据,不在禁令内)。
|
|
15
|
+
* - 修复责任外移:doctor 永不写不删,修复建议以文字形态给出(ADR-0010 决定 5)。
|
|
16
|
+
*/
|
|
17
|
+
import { readdir, readFile, lstat, stat, readlink } from 'node:fs/promises';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { parseDocument } from 'yaml';
|
|
20
|
+
import { satisfies } from 'semver';
|
|
21
|
+
import { dshHome } from './env.js';
|
|
22
|
+
import { readProfileDeps, resolvePluginDir, readPkgJson } from './installed.js';
|
|
23
|
+
// ---------- 布局探测(Task 1) ----------
|
|
24
|
+
/**
|
|
25
|
+
* 判据与优先级(评审 R1.1 定案):**workspace 声明优先**——pnpm-workspace.yaml 声明
|
|
26
|
+
* `nodeLinker: hoisted` 即判 hoisted,残留的 `.pnpm` 目录(如仅含 lock.yaml 的本机
|
|
27
|
+
* 实况形态)不推翻声明;isolated = 无 hoisted 声明且 `node_modules/.pnpm` 存在且
|
|
28
|
+
* **含包目录**(仅 lock.yaml 的残留形态不算);其余 unknown。
|
|
29
|
+
* 任何读取/解析失败都走 unknown 分支,不抛异常(unknown≠broken)。
|
|
30
|
+
*/
|
|
31
|
+
export async function detectLayout(profileDir) {
|
|
32
|
+
const root = resolve(profileDir);
|
|
33
|
+
let wsText = null;
|
|
34
|
+
try {
|
|
35
|
+
wsText = await readFile(join(root, 'pnpm-workspace.yaml'), 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
wsText = null;
|
|
39
|
+
}
|
|
40
|
+
if (wsText !== null) {
|
|
41
|
+
try {
|
|
42
|
+
const doc = parseDocument(wsText);
|
|
43
|
+
const nodeLinker = doc.getIn(['nodeLinker'], true);
|
|
44
|
+
if (nodeLinker && typeof nodeLinker.toString === 'function' && nodeLinker.toString() === 'hoisted') {
|
|
45
|
+
return 'hoisted';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* 坏 YAML 不构成任何判据,落 .pnpm 存在性判定 */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = await readdir(join(root, 'node_modules', '.pnpm'));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return 'unknown';
|
|
58
|
+
}
|
|
59
|
+
const hasPackageDir = entries.some((e) => !e.startsWith('.') && e !== 'lock.yaml');
|
|
60
|
+
return hasPackageDir ? 'isolated' : 'unknown';
|
|
61
|
+
}
|
|
62
|
+
// ---------- 农场测活(Task 2,know-how 014 的工具化) ----------
|
|
63
|
+
/** pnpm 虚拟店目录名里的版本段:`@deepseek-ai+<pkg>@<ver>_<hash>`(targetVersion 第一级提取)。 */
|
|
64
|
+
const STORE_VERSION_RE = /@deepseek-ai\+[^/\\]+@([^_/\\]+)_[0-9a-f]+(?:[\\/]|$)/;
|
|
65
|
+
/** dsh 伞包:其版本即 DSH 运行时版本,是唯一可与 runtimeVersion 比较的农场成员。 */
|
|
66
|
+
const DSH_UMBRELLA = '@deepseek-ai/dsh';
|
|
67
|
+
/** 祖先链向上遍历的最大层数(防御病态深路径)。 */
|
|
68
|
+
const MAX_ANCESTOR_LEVELS = 16;
|
|
69
|
+
async function existsDir(path) {
|
|
70
|
+
try {
|
|
71
|
+
await stat(path);
|
|
72
|
+
return true; // stat 成功解析即存在(文件或目录皆算存活目标)
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** 从 targetDir 的 package.json 读 version(targetVersion 第二级提取;失败返回 null 不抛)。 */
|
|
79
|
+
async function readTargetPkgVersion(targetDir) {
|
|
80
|
+
try {
|
|
81
|
+
const raw = JSON.parse(await readFile(join(targetDir, 'package.json'), 'utf8'));
|
|
82
|
+
return typeof raw?.version === 'string' && raw.version ? raw.version : null;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 农场测活:遍历 profile 可见范围内 `@deepseek-ai/*` 符号链接,判定悬空 / 指向旧运行时 store / 健康。
|
|
90
|
+
*
|
|
91
|
+
* - 遍历规则(评审 R1.2 定案):从 profileDir 逐级向上至 DSH_HOME 边界,扫描每级
|
|
92
|
+
* `node_modules/@deepseek-ai`——本机实况农场在 profile **父目录**的共享 hoisted 店,
|
|
93
|
+
* 不得以「工作区根」为限(否则 0 命中空转,farmChecked=0 是验收失败信号)。
|
|
94
|
+
* - stale 判定只对 dsh 伞包生效:lockstep 店内各核心包(cordis 4.x、dsh-tools 0.1.x…)
|
|
95
|
+
* 与运行时版本**不在同一命名空间**,逐包比较会把 cordis@4.0.1 误标 stale——误报纪律
|
|
96
|
+
* (健康档误报代价 > 漏报)禁止;其余包 targetVersion 仅作信息呈现。stale-target 只进
|
|
97
|
+
* farm 清单不产生 finding(提示级,ADR-0010 决定 4)。
|
|
98
|
+
* - runtimeVersion 为 null(CLI 通路纯 FS 解析不可得,绝不 spawn)→ 全部不判 stale、
|
|
99
|
+
* unknowns 记一条聚合降级说明(unknown≠broken,评审 R1.3)。
|
|
100
|
+
* - targetVersion 两级提取(评审 R1.4):目录名版本段优先 → 目标 package.json version;
|
|
101
|
+
* 两级皆失败置 null 并聚合计入 unknowns。
|
|
102
|
+
*/
|
|
103
|
+
export async function analyzeFarm(profileDir, layout, runtimeVersion) {
|
|
104
|
+
const farm = [];
|
|
105
|
+
const findings = [];
|
|
106
|
+
const unknowns = [];
|
|
107
|
+
let unresolvedVersionCount = 0;
|
|
108
|
+
// 祖先链层级收集:profileDir 起向上,含 DSH_HOME 边界(boundary 是祖先时);越界或到根即止。
|
|
109
|
+
// 边界不可达的说明(执行评审 E1.6):两生产通路的 profileDir 恒在 dshHome() 之下
|
|
110
|
+
// (CLI=env.ts webProfileDir()、method=host ctx.profile.dir,均无用户可控的目录入参),
|
|
111
|
+
// boundary 不可达仅限误用场景;此时上行至根由 MAX_ANCESTOR_LEVELS=16 有界兜底,
|
|
112
|
+
// 不会无限扫描,也不存在「止于起点」会误伤的合法非 DSH_HOME 用例(当前不存在该通路)。
|
|
113
|
+
const boundary = resolve(dshHome());
|
|
114
|
+
const levels = [];
|
|
115
|
+
let cur = resolve(profileDir);
|
|
116
|
+
for (let i = 0; i < MAX_ANCESTOR_LEVELS; i++) {
|
|
117
|
+
levels.push(cur);
|
|
118
|
+
if (cur === boundary)
|
|
119
|
+
break;
|
|
120
|
+
const parent = dirname(cur);
|
|
121
|
+
if (parent === cur)
|
|
122
|
+
break;
|
|
123
|
+
cur = parent;
|
|
124
|
+
}
|
|
125
|
+
for (const level of levels) {
|
|
126
|
+
const scopeDir = join(level, 'node_modules', '@deepseek-ai');
|
|
127
|
+
let names;
|
|
128
|
+
try {
|
|
129
|
+
names = await readdir(scopeDir);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
continue; // 该级无 node_modules/@deepseek-ai,静默跳过(不是每一级都有)
|
|
133
|
+
}
|
|
134
|
+
for (const name of names) {
|
|
135
|
+
if (name.startsWith('.'))
|
|
136
|
+
continue;
|
|
137
|
+
const linkPath = join(scopeDir, name);
|
|
138
|
+
let isSymlink = false;
|
|
139
|
+
try {
|
|
140
|
+
isSymlink = (await lstat(linkPath)).isSymbolicLink();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!isSymlink)
|
|
146
|
+
continue; // 农场只关心符号链接;真实目录是物化安装,不在测活范围
|
|
147
|
+
const target = await readlinkSafe(linkPath);
|
|
148
|
+
if (target === null) {
|
|
149
|
+
// readlink 失败:链接本身不可读——按 unknown 计,不猜
|
|
150
|
+
unresolvedVersionCount += 1;
|
|
151
|
+
farm.push({ name, target: linkPath, state: 'healthy', targetVersion: null });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const resolvedTarget = resolve(dirname(linkPath), target);
|
|
155
|
+
const alive = await existsDir(resolvedTarget);
|
|
156
|
+
// targetVersion 两级提取
|
|
157
|
+
let targetVersion = null;
|
|
158
|
+
const m = STORE_VERSION_RE.exec(resolvedTarget);
|
|
159
|
+
if (m)
|
|
160
|
+
targetVersion = m[1];
|
|
161
|
+
if (targetVersion === null)
|
|
162
|
+
targetVersion = await readTargetPkgVersion(resolvedTarget);
|
|
163
|
+
if (targetVersion === null)
|
|
164
|
+
unresolvedVersionCount += 1;
|
|
165
|
+
let state = 'healthy';
|
|
166
|
+
if (!alive) {
|
|
167
|
+
state = 'dangling';
|
|
168
|
+
}
|
|
169
|
+
else if (runtimeVersion !== null &&
|
|
170
|
+
targetVersion !== null &&
|
|
171
|
+
`@deepseek-ai/${name}` === DSH_UMBRELLA &&
|
|
172
|
+
targetVersion !== runtimeVersion) {
|
|
173
|
+
state = 'stale-target';
|
|
174
|
+
}
|
|
175
|
+
farm.push({ name, target: resolvedTarget, state, targetVersion });
|
|
176
|
+
if (state === 'dangling') {
|
|
177
|
+
findings.push({
|
|
178
|
+
check: 'farm-liveness',
|
|
179
|
+
severity: 'error',
|
|
180
|
+
title: `@deepseek-ai/${name} 符号链接悬空`,
|
|
181
|
+
detail: `指向 ${resolvedTarget},目标不存在(DSH 升级后旧 store 目录被拆的 know-how 014 形态;本链接已不能引导任何加载)`,
|
|
182
|
+
hint: '重装该包(dshm upgrade / install),或按 know-how 014 的 heal 流程重指向新 store;体检不代执行',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (runtimeVersion === null) {
|
|
188
|
+
unknowns.push('运行时版本未解析(纯 FS 通路不可得,绝不 spawn)——stale 判定整体降级为 unknown');
|
|
189
|
+
}
|
|
190
|
+
if (unresolvedVersionCount > 0) {
|
|
191
|
+
unknowns.push(`${unresolvedVersionCount} 条链接目标版本无法解析(目录名无版本段且目标 package.json 不可读),详见 farm 清单`);
|
|
192
|
+
}
|
|
193
|
+
return { farm, findings, unknowns };
|
|
194
|
+
}
|
|
195
|
+
/** readlink 包装:失败返回 null(不抛)。 */
|
|
196
|
+
async function readlinkSafe(linkPath) {
|
|
197
|
+
try {
|
|
198
|
+
return await readlink(linkPath);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ---------- 残留物清点(Task 3,#663「可见而非清理」纪律) ----------
|
|
205
|
+
/** pnpm 暂存目录形态:`<name>_tmp_<pid>_<n>`(中断的更新留下的)。 */
|
|
206
|
+
const TMP_DIR_RE = /^(.+)_tmp_\d+_\w+$/;
|
|
207
|
+
/** profile 根备份文件形态:`*.bak-*`(本机实况 12 个累积)。 */
|
|
208
|
+
const BAK_FILE_RE = /\.bak-/;
|
|
209
|
+
/**
|
|
210
|
+
* 残留物清点:四类全部**结构化清单、零告警**(不产生任何 DoctorFinding——残留是
|
|
211
|
+
* 历史形态不是故障,告警只会训练用户无视清单;且删除正是常被进程句柄拒绝的操作,
|
|
212
|
+
* doctor 只清点不删除,ADR-0010 决定 5)。
|
|
213
|
+
*
|
|
214
|
+
* - `no-manifest` / `empty-scope`:node_modules 顶层(非点前缀)。空 scope 独立分类
|
|
215
|
+
* 不并入 no-manifest(本机实况 8 个;dsh-market 扫描器曾把它们误报 incomplete-package)。
|
|
216
|
+
* - `tmp-dir`:node_modules 顶层 + isolated 布局另扫 `.pnpm` 顶层**一层**(照 check.ts
|
|
217
|
+
* 有界策略不递归全店)。注意 .pnpm 内正常 store 目录(`foo@1.0.0`)本来就没有根级
|
|
218
|
+
* package.json——no-manifest 检查绝不扫进 .pnpm,否则全店误报。
|
|
219
|
+
* - hoisted 布局不做 .pnpm 扫描,unknowns 记中性事实(本机 hoisted 下存在仅含
|
|
220
|
+
* lock.yaml 的残留 .pnpm 目录属正常并存形态,不告警——评审 R1.9)。
|
|
221
|
+
* - `bak-file`:profile 根一层 `*.bak-*`(mtime 入 note)。
|
|
222
|
+
*/
|
|
223
|
+
export async function listResidue(profileDir, layout) {
|
|
224
|
+
const residue = [];
|
|
225
|
+
const unknowns = [];
|
|
226
|
+
const root = resolve(profileDir);
|
|
227
|
+
const nm = join(root, 'node_modules');
|
|
228
|
+
let top = [];
|
|
229
|
+
try {
|
|
230
|
+
top = await readdir(nm);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
top = []; // 无 node_modules → 无残留可清点(不算 unknown)
|
|
234
|
+
}
|
|
235
|
+
for (const name of top) {
|
|
236
|
+
if (name.startsWith('.'))
|
|
237
|
+
continue;
|
|
238
|
+
if (TMP_DIR_RE.test(name)) {
|
|
239
|
+
residue.push({ kind: 'tmp-dir', path: join(nm, name), note: null });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const entryPath = join(nm, name);
|
|
243
|
+
let isDir = false;
|
|
244
|
+
try {
|
|
245
|
+
isDir = (await stat(entryPath)).isDirectory();
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (!isDir)
|
|
251
|
+
continue;
|
|
252
|
+
if (name.startsWith('@')) {
|
|
253
|
+
let inner;
|
|
254
|
+
try {
|
|
255
|
+
inner = await readdir(entryPath);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (inner.length === 0)
|
|
261
|
+
residue.push({ kind: 'empty-scope', path: entryPath, note: null });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
await stat(join(entryPath, 'package.json'));
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
residue.push({ kind: 'no-manifest', path: entryPath, note: null });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (layout === 'isolated') {
|
|
272
|
+
let pnpmEntries = [];
|
|
273
|
+
try {
|
|
274
|
+
pnpmEntries = await readdir(join(nm, '.pnpm'));
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
pnpmEntries = [];
|
|
278
|
+
}
|
|
279
|
+
for (const name of pnpmEntries) {
|
|
280
|
+
if (TMP_DIR_RE.test(name)) {
|
|
281
|
+
residue.push({ kind: 'tmp-dir', path: join(nm, '.pnpm', name), note: null });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else if (layout === 'hoisted') {
|
|
286
|
+
unknowns.push('hoisted 布局不扫 .pnpm 店(仅含 lock.yaml 的残留 .pnpm 目录属正常并存形态,不告警)');
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
unknowns.push('布局未知,未扫 .pnpm 店');
|
|
290
|
+
}
|
|
291
|
+
let rootEntries = [];
|
|
292
|
+
try {
|
|
293
|
+
rootEntries = await readdir(root);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
rootEntries = [];
|
|
297
|
+
}
|
|
298
|
+
for (const name of rootEntries) {
|
|
299
|
+
if (!BAK_FILE_RE.test(name))
|
|
300
|
+
continue;
|
|
301
|
+
const filePath = join(root, name);
|
|
302
|
+
try {
|
|
303
|
+
const s = await stat(filePath);
|
|
304
|
+
if (!s.isFile())
|
|
305
|
+
continue;
|
|
306
|
+
residue.push({ kind: 'bak-file', path: filePath, note: `mtime: ${s.mtime.toISOString()}` });
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return { residue, unknowns };
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* 自建最小 lockfile 解析(评审 R1.8:仓内无可复用的 importers 解析导出——
|
|
316
|
+
* npm-integrity.ts 仅 integrity/overrides,profile-transaction 的 lock 解析为模块私有)。
|
|
317
|
+
* 仅认 lockfileVersion 9.0 实测形状 `importers.'.'.dependencies.{specifier,version}`;
|
|
318
|
+
* 其他版本 supported:false(全 null,unknown≠broken 不猜)。
|
|
319
|
+
*/
|
|
320
|
+
async function readLockImporters(lockPath) {
|
|
321
|
+
let text;
|
|
322
|
+
try {
|
|
323
|
+
text = await readFile(lockPath, 'utf8');
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return { map: new Map(), supported: false }; // 无 lockfile → 全 null(unknown≠broken)
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const doc = parseDocument(text);
|
|
330
|
+
const lv = doc.getIn(['lockfileVersion'], true);
|
|
331
|
+
const lvStr = lv === undefined || lv === null ? '' : String(lv);
|
|
332
|
+
if (!lvStr.startsWith('9.'))
|
|
333
|
+
return { map: new Map(), supported: false };
|
|
334
|
+
const js = doc.toJS();
|
|
335
|
+
const depsObj = js?.importers?.['.']?.dependencies ?? {};
|
|
336
|
+
const map = new Map();
|
|
337
|
+
for (const [name, value] of Object.entries(depsObj)) {
|
|
338
|
+
// link: 依赖在 lock 里是 { specifier, link: true } 无 version → 自然落空(按契约置 null)
|
|
339
|
+
if (value && typeof value === 'object' && typeof value.version === 'string' && value.version) {
|
|
340
|
+
// pnpm lock v9 的 version 可带 peer 后缀:`0.9.28(@deepseek-ai/schemastery@3.18.4)`
|
|
341
|
+
// ——剥去括号后缀再入账(实机预演 2026-10-04 曾因此把 4 条三处一致的依赖误报账实分裂)
|
|
342
|
+
map.set(name, value.version.replace(/\([^)]*\)$/, ''));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return { map, supported: true };
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return { map: new Map(), supported: false };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** semver.satisfies 安全包装:任一侧非法(dist-tag / workspace 协议等)→ null(不可判定,不猜)。 */
|
|
352
|
+
function satisfiesSafe(version, range) {
|
|
353
|
+
try {
|
|
354
|
+
return satisfies(version, range) ?? null;
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* 账实一致:对 profile 每个依赖核对三处记账——package.json pin / node_modules 实装 /
|
|
362
|
+
* pnpm-lock 解析。不一致 = warning(确认异常但不阻止启动,ADR-0010 决定 4)。
|
|
363
|
+
* - pin 含 range 按 semver 范围判定,不做字串相等;
|
|
364
|
+
* - lockfile 解析不出(无记录 / 非 9.x / 无 lockfile)置 null 且**不计为不一致**(unknown≠broken);
|
|
365
|
+
* - `link:` 依赖:pin 记原文、installed 取 link 目标 package.json version、lockfile 置 null,
|
|
366
|
+
* 一致性 = 目标可读(不比较 pin 文本与版本号)。
|
|
367
|
+
*/
|
|
368
|
+
export async function checkAccount(profileDir) {
|
|
369
|
+
const root = resolve(profileDir);
|
|
370
|
+
const deps = await readProfileDeps(root);
|
|
371
|
+
const lock = await readLockImporters(join(root, 'pnpm-lock.yaml'));
|
|
372
|
+
const account = [];
|
|
373
|
+
const findings = [];
|
|
374
|
+
for (const name of Object.keys(deps).sort()) {
|
|
375
|
+
const spec = deps[name];
|
|
376
|
+
const isLink = spec.startsWith('link:');
|
|
377
|
+
const dir = resolvePluginDir(root, name, spec);
|
|
378
|
+
let installed = null;
|
|
379
|
+
if (dir) {
|
|
380
|
+
const pkg = await readPkgJson(dir);
|
|
381
|
+
const v = pkg?.version;
|
|
382
|
+
if (typeof v === 'string' && v)
|
|
383
|
+
installed = v;
|
|
384
|
+
}
|
|
385
|
+
const lockfile = !isLink ? lock.map.get(name) ?? null : null;
|
|
386
|
+
let consistent = true;
|
|
387
|
+
if (isLink) {
|
|
388
|
+
consistent = installed !== null;
|
|
389
|
+
}
|
|
390
|
+
else if (installed === null) {
|
|
391
|
+
consistent = false; // pin 在而实装缺失(物化不完整)
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
const pinOk = satisfiesSafe(installed, spec);
|
|
395
|
+
if (pinOk === false)
|
|
396
|
+
consistent = false;
|
|
397
|
+
if (lockfile !== null && lockfile !== installed)
|
|
398
|
+
consistent = false;
|
|
399
|
+
}
|
|
400
|
+
account.push({ name, pin: spec, installed, lockfile, consistent });
|
|
401
|
+
if (!consistent) {
|
|
402
|
+
findings.push({
|
|
403
|
+
check: 'account-reality',
|
|
404
|
+
severity: 'warning',
|
|
405
|
+
title: `${name} 账实分裂`,
|
|
406
|
+
detail: `pin=${spec} · 实装=${installed ?? '缺失'} · lock=${lockfile ?? '—'}(know-how 023 §6.2 形态:pnpm 非零退出可拦住 pin 写入,三处记账从此各说各话)`,
|
|
407
|
+
hint: '核验最近一次升级/安装事务的结果,或按 know-how 023 §6.2 处置;体检不代执行',
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { account, findings };
|
|
412
|
+
}
|
|
413
|
+
// ---------- 聚合(Task 5) ----------
|
|
414
|
+
/**
|
|
415
|
+
* 体检聚合:三项检查 + 双市场信息级事实 → DoctorReport。
|
|
416
|
+
* runtimeVersion 由调用方注入(method 通路宿主内纯 FS 可得;CLI 通路 null 即降级),
|
|
417
|
+
* 原样透传进报告——两通路差异如实可见。布局 unknown 时显式标注扫描受限(不冒充健康)。
|
|
418
|
+
*/
|
|
419
|
+
export async function runDoctor(profileDir, runtimeVersion) {
|
|
420
|
+
const root = resolve(profileDir);
|
|
421
|
+
const layout = await detectLayout(root);
|
|
422
|
+
const farmR = await analyzeFarm(root, layout, runtimeVersion);
|
|
423
|
+
const residueR = await listResidue(root, layout);
|
|
424
|
+
const accountR = await checkAccount(root);
|
|
425
|
+
const unknowns = [...farmR.unknowns, ...residueR.unknowns];
|
|
426
|
+
if (layout === 'unknown')
|
|
427
|
+
unknowns.unshift('布局未知——扫描范围受限(unknown≠broken,不冒充健康)');
|
|
428
|
+
const deps = await readProfileDeps(root);
|
|
429
|
+
const dualMarket = Object.hasOwn(deps, 'dsh-m') && Object.hasOwn(deps, 'dshmarket') ? ['dsh-m', 'dshmarket'] : null;
|
|
430
|
+
const findings = [...farmR.findings, ...accountR.findings];
|
|
431
|
+
return {
|
|
432
|
+
schema: 'dsh-m/doctor/v1',
|
|
433
|
+
profileDir: root,
|
|
434
|
+
layout,
|
|
435
|
+
runtimeVersion,
|
|
436
|
+
scannedAt: new Date().toISOString(),
|
|
437
|
+
summary: {
|
|
438
|
+
errors: findings.filter((f) => f.severity === 'error').length,
|
|
439
|
+
warnings: findings.filter((f) => f.severity === 'warning').length,
|
|
440
|
+
farmChecked: farmR.farm.length,
|
|
441
|
+
farmDangling: farmR.farm.filter((x) => x.state === 'dangling').length,
|
|
442
|
+
farmStale: farmR.farm.filter((x) => x.state === 'stale-target').length,
|
|
443
|
+
residueCount: residueR.residue.length,
|
|
444
|
+
accountChecked: accountR.account.length,
|
|
445
|
+
accountMismatched: accountR.account.filter((a) => !a.consistent).length,
|
|
446
|
+
unknowns,
|
|
447
|
+
},
|
|
448
|
+
farm: farmR.farm,
|
|
449
|
+
residue: residueR.residue,
|
|
450
|
+
account: accountR.account,
|
|
451
|
+
findings,
|
|
452
|
+
dualMarket,
|
|
453
|
+
};
|
|
454
|
+
}
|
package/lib/core/dsh-version.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* .pnpm 目录名多版本残留无判据——均不走。
|
|
12
12
|
*/
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
|
-
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
16
|
import { dshArgv } from './dsh-cli.js';
|
|
17
17
|
/** 从 `dsh --version` 输出提取 semver:容忍 v 前缀与前后散文,取首个命中。 */
|
|
@@ -26,10 +26,14 @@ export function parseDshVersionOutput(text) {
|
|
|
26
26
|
export function readLauncherPackageVersion(input = {}) {
|
|
27
27
|
try {
|
|
28
28
|
const argv = dshArgv(input);
|
|
29
|
-
|
|
29
|
+
let entry = argv.args.length > 0 ? argv.args[argv.args.length - 1] : undefined;
|
|
30
30
|
// 与 dshArgv 同一判定:entry 必须长得像 launcher bin,否则是 CLI/PATH 模式
|
|
31
31
|
if (!entry || !/[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry))
|
|
32
32
|
return null;
|
|
33
|
+
// F1(0.9.29 装机验收实证):pnpm 全局 shim(如 PNPM_HOME/dsh)是指向真实 bin.js
|
|
34
|
+
// 的符号链接——realpath 后再向上找 package.json,否则会在 pnpm home 目录树上
|
|
35
|
+
// 空走三级,宿主内纯 FS 解析落空(ping chip 被迫吃 spawn 回退)。
|
|
36
|
+
entry = realpathSync(entry);
|
|
33
37
|
let dir = dirname(entry);
|
|
34
38
|
for (let up = 0; up < 3; up++) {
|
|
35
39
|
try {
|
package/lib/core/host-api.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* 顶层必须是非 null/非数组对象且有 method → typed method/业务错误映射 → 其他 500。
|
|
9
9
|
*/
|
|
10
10
|
import { BOOT_ID, publicInstallStatus } from './dsh-cli.js';
|
|
11
|
-
import { resolveDshVersion } from './dsh-version.js';
|
|
11
|
+
import { resolveDshVersion, readLauncherPackageVersion } from './dsh-version.js';
|
|
12
|
+
import { runDoctor } from './doctor.js';
|
|
12
13
|
import { listInstalledWithMeta, listMarket, installFromRegistry, selfUpgrade, uninstallPlugin, upgradePlugin, InstallGuardError, } from './market.js';
|
|
13
14
|
import { runProfileTransaction, TransactionError } from './profile-transaction.js';
|
|
14
15
|
import { readInstalledPluginReadme } from './installed.js';
|
|
@@ -557,6 +558,14 @@ export function createApiDispatcher(ctx) {
|
|
|
557
558
|
payload = { registryState: snap.loaded, check };
|
|
558
559
|
break;
|
|
559
560
|
}
|
|
561
|
+
case 'doctor': {
|
|
562
|
+
// 体检(ADR-0010):纯 FS 只读,不依赖 registry(清单不可用时照常工作)。
|
|
563
|
+
// runtimeVersion 只用 readLauncherPackageVersion 纯 FS 通路——resolveDshVersion 的
|
|
564
|
+
// spawn 回退是 doctor 禁区(无进程派生约束;null 即降级,测试覆盖分工见计划 Task 6)。
|
|
565
|
+
const report = await runDoctor(profile.dir, readLauncherPackageVersion());
|
|
566
|
+
payload = { report };
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
560
569
|
default:
|
|
561
570
|
throw new ApiProtocolError(404, `未知 method: ${method}`);
|
|
562
571
|
}
|