dsh-m 0.9.27 → 0.9.29
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 +32 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/docs/DESIGN.md +3 -2
- package/lib/cli.js +60 -0
- package/lib/client.js +2 -2
- package/lib/core/doctor.js +454 -0
- package/lib/core/host-api.js +13 -4
- package/lib/core/market.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,22 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
|
|
|
8
8
|
|
|
9
9
|
## 中文
|
|
10
10
|
|
|
11
|
+
### 0.9.29 变更:profile 体检 Doctor——`dshm doctor` 上线(只读:农场测活 / 残留物清点 / 账实一致)
|
|
12
|
+
|
|
13
|
+
- **动机**: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)。
|
|
14
|
+
- **新增**:`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 不猜)。
|
|
15
|
+
- **边界纪律(ADR-0010)**:runtimeVersion 仅用 `readLauncherPackageVersion` 纯 FS 通路(CLI 进程下为 null → stale 判定整体降级 unknown,绝不 spawn);密钥红线只禁含密钥**配置文件**内容(包元数据 version 字段可读);布局判定 workspace 声明优先(本机「hoisted 声明 + 仅 lock.yaml 的残留 .pnpm」并存形态实证);双市场并存(dsh-m+dshmarket 同装)信息级呈现;doctor 永不修复,建议以文字给出。
|
|
16
|
+
- **验收锚点(发版装机后核对)**:本机首跑 farmChecked ≈236(=0 即遍历空转)、悬空 0、8 空 scope + 12 bak 入清单、双市场信息级出现。
|
|
17
|
+
- **验证**:新增 `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。
|
|
18
|
+
|
|
19
|
+
### 0.9.28 变更:页大小档位去上游化——32/64/96 取代 24/48/96,默认 32
|
|
20
|
+
|
|
21
|
+
- **动机**:24/48/96 是复刻 dsh-market 筛选面板(0.7.2)时带过来的上游血统数字。甄别后发现 96 早已被 dsh-m 内化为核心参数(`WITH_LATEST_MAX` 探测上限、精选区默认页、fast-open 快照判定基准),真正的上游痕迹只有 24——「砍 24、保 96」即去上游化与兼容性的交集。
|
|
22
|
+
- **新档位**:`MARKET_PAGE_SIZES = [32, 64, 96]`(等差 +32,读序顺)、社区区默认 `DEFAULT_PAGE_SIZE = 32`(首页更满,仍低于 0.6.x 历史默认 50 的探测负载,Q46 预算姿态不变);精选区默认 96 与 core clamp 1..96 不动。
|
|
23
|
+
- **联动**:host-api GUI 通道兜底默认 24 → 32(三端一致,不留暗默认);探测预算注释同步(market.ts / host-api.ts);README×2 与 DESIGN.md 措辞同步;GLOSSARY「精选区」词条按代码实态锐化(单页无分页 → 常态单页直出 + 超限降级分页);测试断言与快照 fixture 同步(偏离档位断言改用组内值 64,覆盖「档位内但非默认仍拒写/判假」)。
|
|
24
|
+
- **已知一次性影响**:升级后社区区旧快照(limit=24)不再命中 fast-open 判定,首次打开市场多一次正常请求,快照按新默认重建后自愈;不加 legacy 兼容分支。
|
|
25
|
+
- **验证**:`npm run build` 成功;全量 1030 pass / 0 fail(8 skipped)。
|
|
26
|
+
|
|
11
27
|
### 0.9.27 变更:修复遮罩拖选误关——面板内按下、拖出释放不再关面板
|
|
12
28
|
|
|
13
29
|
- **问题(装机实测 2026-10-03)**:在市场搜索框内左键按下向左拖选(越过面板边界)释放,整个面板被关闭。非浏览器鼠标手势——面板遮罩是裸 `onClick: onClose`,而在面板内容里按下、拖到遮罩上释放时,浏览器把 `click` 派发到按下/释放目标的公共祖先(恰是遮罩),被误判为「点遮罩关面板」。详情 Modal、截图灯箱、兼容确认弹层同属该缺陷类。
|
|
@@ -275,6 +291,22 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
|
|
|
275
291
|
|
|
276
292
|
## English
|
|
277
293
|
|
|
294
|
+
### Added in 0.9.29 — profile Doctor: `dshm doctor` (read-only: farm liveness / residue inventory / manifest-reality consistency)
|
|
295
|
+
|
|
296
|
+
- **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).
|
|
297
|
+
- **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).
|
|
298
|
+
- **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.
|
|
299
|
+
- **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.
|
|
300
|
+
- **Verification**: new `tests/doctor.test.mjs` (36 cases) + `tests/doctor-api.test.mjs` (2 cases); full suite 1076 pass / 0 fail / 0 skipped.
|
|
301
|
+
|
|
302
|
+
### Changed in 0.9.28 — page-size set de-upstreamed: 32/64/96 replaces 24/48/96, default 32
|
|
303
|
+
|
|
304
|
+
- **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.
|
|
305
|
+
- **New set**: `MARKET_PAGE_SIZES = [32, 64, 96]` (arithmetic +32), community default `DEFAULT_PAGE_SIZE = 32` (fuller first page, still below the 0.6.x default of 50 in probe load; Q46 posture unchanged); primary default 96 and the core clamp 1..96 untouched.
|
|
306
|
+
- **Ripple**: host-api GUI fallback default 24 → 32 (three ends consistent, no hidden default); probe-budget comments updated (market.ts / host-api.ts); README×2 and DESIGN.md wording synced; GLOSSARY "Primary Zone" entry sharpened to match the code (single-page-no-pager → normally single page, degraded to the community pager beyond capacity); test assertions and snapshot fixtures synced (off-default assertions now use in-set value 64, covering "in set but not default is still rejected/false").
|
|
307
|
+
- **Known one-time impact**: after upgrading, a community snapshot with limit=24 no longer matches the fast-open check — the first market open makes one extra normal request, then the snapshot rebuilds on the new default; no legacy-compat branch added.
|
|
308
|
+
- **Verification**: `npm run build` clean; full suite 1030 pass / 0 fail (8 skipped).
|
|
309
|
+
|
|
278
310
|
### Changed in 0.9.27 — fix accidental drag-out close: press inside, release on backdrop no longer closes the panel
|
|
279
311
|
|
|
280
312
|
- **Problem (observed on install, 2026-10-03)**: pressing inside the market search box and dragging left past the panel edge closed the whole panel. Not a browser mouse gesture — the panel backdrop used a bare `onClick: onClose`; when a press inside the panel content drags onto the backdrop and releases, the browser dispatches `click` to the common ancestor of the press/release targets (the backdrop itself), misread as "clicked the backdrop". The detail modal, screenshot lightbox, and compat dialog shared the same defect class.
|
package/README.en.md
CHANGED
|
@@ -172,7 +172,7 @@ Desktop (the `desktop` profile) supports browsing the market, **installing new p
|
|
|
172
172
|
No. Only the package reference in the profile is removed (live UI disabled first), and suspected leftover paths are reported to you.
|
|
173
173
|
|
|
174
174
|
**3. Will a custom registry slow the market down?**
|
|
175
|
-
The market list is server-paginated (
|
|
175
|
+
The market list is server-paginated (32/64/96 per page); even a 1,000-entry registry queries latest versions for the current page only, so browsing stays fast.
|
|
176
176
|
|
|
177
177
|
**4. What if my custom source goes down?**
|
|
178
178
|
dsh-m serves its last successful cache for that source (the Settings tab's effective-source row labels it honestly); with no cache at all the market reports the listing unavailable while installed plugins stay manageable. Fix the address or restore the default anytime.
|
package/README.md
CHANGED
|
@@ -172,7 +172,7 @@ Desktop(desktop profile)支持浏览市场、**安装新包**和插件开关
|
|
|
172
172
|
不会。只移除 profile 中的包引用(卸载前先下线运行中的界面),并把疑似残留路径报告给你。
|
|
173
173
|
|
|
174
174
|
**3. 自定义清单会让市场变慢吗?**
|
|
175
|
-
市场列表是服务端分页(每页
|
|
175
|
+
市场列表是服务端分页(每页 32/64/96 可选),1,000 条清单第一页也只查询当前页的最新版本,浏览仍然流畅。
|
|
176
176
|
|
|
177
177
|
**4. 自定义源挂了怎么办?**
|
|
178
178
|
优先使用该源最近一次成功的缓存(设置页「生效来源」会如实标注);完全没有缓存时市场会提示清单不可用,已安装插件仍可正常管理。修正地址或恢复默认即可。
|
package/docs/DESIGN.md
CHANGED
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
- 分类 chips:两行折叠 + 实测裁剪(渲染后逐 chip 测量可容纳数)+ 收起时激活分类置前 + 滚动吸顶自动收缩(不改写用户开合选择);分类标签**单一事实源**(core 提供标签表,杀掉客户端内嵌副本)。
|
|
87
87
|
- 排序:`downloads | stars | added × asc/desc`,默认 `downloads-desc`;**无下载量(github-only)≠ 0 下载**(永远排在有真实计数条目之后,彼此按 stars 排);时间窗过滤明确不做(`added` 排序覆盖「找最新」诉求)。
|
|
88
88
|
- 卡片 byline:`owner / downloads / ★stars`(旁路数据已透传,Tooltip 给精确数);`deprecated` 徽章 + 详情内 replacement 替代链接(适配层补透传原生字段)。
|
|
89
|
-
- 分页:页码窗口化(`1 … n-1 n n+1 … total`,≤7 页全显)+ 页大小
|
|
89
|
+
- 分页:页码窗口化(`1 … n-1 n n+1 … total`,≤7 页全显)+ 页大小 32/64/96 + 筛选变化重置页 1 + 翻页回顶;仅 prev/next 的旧分页退役。
|
|
90
90
|
- 搜索:相关性加权管线——NFKC 归一化 + 中西文边界插空格 + 字段权重(name/npm > owner > 描述 > 分类)+ 命中类型加分 + 按条目缓存归一化结果;输入 250ms debounce + IME composition 全程处理 + draft/已提交 query 分离。**0.9.25 起搜索跨区全局**(两分区搜索框同语义):摘要行报两分区精确命中数(`sourceCounts`),结果页内按精选段置顶分组,分类 chips 搜索态隐藏、「筛选」按钮随摘要行保留(页大小组)。**0.9.26 补充**:GUI 跨区搜索通道精选命中稳定前置(`curatedFirst`,分区内相关序不变;tools/CLI 不传,三端同序不变)——摘要行全局计数与首页所见一致,弱命中精选不被 downloads tie-break 埋进后页。
|
|
91
91
|
- 描述 5 行钳制(真实溢出才显示展开钮);**数据层双语**——描述按 UI 语言取 zh/en(数据现成),UI 完整双语字典列 backlog。
|
|
92
92
|
- **详情 = Modal 且为卡片超集**(「detail 显示少于摘要就是倒退」):byline / 分类 / 收录日期 / 下载量窗口三要素(计数 + 窗口 + 核对时间)/ 描述全文 / 截图灯箱(←→/Esc、**禁自动轮播**)/ 能力披露 + 红线(**默认收起**)/ 安装命令折叠行(0.9.14 起按上下文显隐:desktop 上下文与已安装条目整行隐藏——命令两来源恒为 `--profile web` 语义,desktop 装机走弹窗内安装按钮)/ deprecated 替代链接;精选条目超集另加 verified 与 tags。**安装确认走 Modal**。安装信息就地显示(0.9.15):进行中进度行与终态结果行挂 Modal 内、底层同源进度行让位——展示派生自全局操作记录,§2.6「状态不挂卡片」所有权模型不变。卡片瘦身:能力披露、截图、安装命令全部迁出卡片(Q44 精神「只在详情层、默认收起」不变,载体升级)。
|
|
@@ -163,7 +163,7 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
|
|
|
163
163
|
|
|
164
164
|
3 个视图,**中文优先**,跟随 DSH Web 深色主题(0.7.0:卡片展开式详情已由 Modal 取代):
|
|
165
165
|
|
|
166
|
-
1. **市场页**(默认):**分区制**(0.7.0 §2.6)——社区/精选/收藏三分区,各分区独立分类/排序/分页状态(**搜索自 0.9.25 起为跨区全局**,社区 + 精选一并命中);搜索/分类/排序为**服务端处理**(Host 强制 `withLatest=true`、limit clamp 1..96 缺省
|
|
166
|
+
1. **市场页**(默认):**分区制**(0.7.0 §2.6)——社区/精选/收藏三分区,各分区独立分类/排序/分页状态(**搜索自 0.9.25 起为跨区全局**,社区 + 精选一并命中);搜索/分类/排序为**服务端处理**(Host 强制 `withLatest=true`、limit clamp 1..96 缺省 32,探测仅当前页);`MarketPanel` 是市场/已装数据唯一 owner(请求 generation + AbortController 丢弃旧响应);安装/卸载/升级完成后通过统一协调器同时刷新市场与已装快照,不要求关闭并重新打开面板;页码窗口化分页(32/64/96)+ 默认/自定义/缓存/不可用短提示(不含本地路径;0.5.0 的「超过 200 条性能提示」随分区退役);详情 Modal 保留 README markdown 预览与 npm/GitHub 官方外链。
|
|
167
167
|
2. **已装页**:profile 实际安装列表,标注来源(市场/非市场/未知);registry 不可用时仍列出已装并标记;「可升级 → x.y.z」徽标 +「升级」;「卸载」。两段加载(ADR-0008):列表不等待探测(`probe:false`),更新提示由 `installedUpdates`(TTL=0)就地补上。
|
|
168
168
|
3. **设置页**:registry 地址草稿 +「校验并应用」(先校验候选再写 settings,失败不落盘)/「恢复默认」/「下载默认 registry.json」(不改当前配置)/「检查条目可达性」(probe 统计 + 最多 100 条 issue,只读不改配置);同时展示配置地址、当前生效配置、生效来源与 configStatus(含 rejected/回滚原因与维护性 warnings);完整本地路径仅在此页显示。registry 配置为 **live 生效,不出现安装类重启横幅**;安装/卸载/升级仍保留重启横幅。
|
|
169
169
|
|
|
@@ -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,6 +7,9 @@
|
|
|
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';
|
|
@@ -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);
|
package/lib/client.js
CHANGED
|
@@ -213,8 +213,8 @@ var MARKET_PAGE_SIZES, DEFAULT_PAGE_SIZE, CURATED_ORDER, CURATED_LABELS, CURATED
|
|
|
213
213
|
var init_market_state = __esm({
|
|
214
214
|
"src/client/market-state.js"() {
|
|
215
215
|
"use strict";
|
|
216
|
-
MARKET_PAGE_SIZES = [
|
|
217
|
-
DEFAULT_PAGE_SIZE =
|
|
216
|
+
MARKET_PAGE_SIZES = [32, 64, 96];
|
|
217
|
+
DEFAULT_PAGE_SIZE = 32;
|
|
218
218
|
CURATED_ORDER = ["essentials", "cui-picks", "self-dev", "tencent-lighthouse", "watchlist"];
|
|
219
219
|
CURATED_LABELS = {
|
|
220
220
|
essentials: "\u88C5\u673A\u5FC5\u5907",
|
|
@@ -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/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';
|
|
@@ -323,11 +324,11 @@ export function createApiDispatcher(ctx) {
|
|
|
323
324
|
}
|
|
324
325
|
case 'market': {
|
|
325
326
|
await ctx.controller.ensureReady();
|
|
326
|
-
// GUI policy:忽略客户端 withLatest,固定 true;limit clamp 1..96、缺省
|
|
327
|
-
// (0.7.0 Task 7 探测预算决策:96 为 opt-in
|
|
327
|
+
// GUI policy:忽略客户端 withLatest,固定 true;limit clamp 1..96、缺省 32
|
|
328
|
+
// (0.7.0 Task 7 探测预算决策:96 为 opt-in 页大小,0.9.28 起默认 32 仍低于 0.6.x 默认 50 的探测负载;
|
|
328
329
|
// 最坏情况被 core 60s deadline 框死为 latestError,不阻塞列表,Q46 不动)
|
|
329
330
|
const limitRaw = Number(body.limit);
|
|
330
|
-
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(96, Math.max(1, Math.floor(limitRaw))) :
|
|
331
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(96, Math.max(1, Math.floor(limitRaw))) : 32;
|
|
331
332
|
const offsetRaw = Number(body.offset);
|
|
332
333
|
const offset = Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0;
|
|
333
334
|
// category:策展五桶 + 社区开放 slug(非法 slug → 400,不静默吞)
|
|
@@ -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
|
}
|
package/lib/core/market.js
CHANGED
|
@@ -62,7 +62,7 @@ function loaderEntriesOrNull() {
|
|
|
62
62
|
}
|
|
63
63
|
// ---------- 通用工具 ----------
|
|
64
64
|
const DEFAULT_DEADLINE_MS = 60_000;
|
|
65
|
-
/** withLatest 上限 = 96(0.7.0 Task 7:50→96,opt-in 页大小;默认页 24
|
|
65
|
+
/** withLatest 上限 = 96(0.7.0 Task 7:50→96,opt-in 页大小;默认页 24、0.9.28 起 32,探测负载仍低于 0.6.x 默认 50——
|
|
66
66
|
* 96/页冷缓存最坏情况被 60s deadline 框死为 latestError 不阻塞列表,Q46「探测对象=页面条目」不动;
|
|
67
67
|
* 人工验收阈值:96/页冷缓存 latestError > 20% 即回退默认页大小并重议)。 */
|
|
68
68
|
const WITH_LATEST_MAX = 96;
|