dsh-m 0.9.31 → 0.9.33

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 CHANGED
@@ -8,6 +8,24 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
8
8
 
9
9
  ## 中文
10
10
 
11
+ ### 0.9.33 变更:自研条目元数据 v1.1——`audience`/`decoupled` 两字段与三端标注
12
+
13
+ - **schema**:registry 条目增补可选 `decoupled?: true`(解耦条目,实操口径判定;与 `verified` 互斥——同条目共存报 error)与 `audience?: 'public' | 'internal'`(缺省 public 不产生键;`'team'` 值集预留未开放)。决策共识与五项裁决记录见 DESIGN §2.7,术语见 GLOSSARY「解耦条目 / 自用条目」。
14
+ - **三端标注**:GUI 卡片与 Modal 头新增「作者自用」「版本无关」徽章+Modal 受众/适配详情行;`dshm_search` 输出投影携带 `audience`/`decoupled`、条目行加 `[作者自用]` 标,工具描述写明推荐纪律(internal 勿向普通用户主动推荐,点名或内部推广场景除外);CLI `dshm search` 行内同标(新增 `tests/cli-search.test.mjs`)。已装视图与收藏页不打标——推荐发现链路才是纪律靶面。
15
+ - **CI**:validate-registry 新增软警告——decoupled 条目 description 含「已适配」句式即 warn(copy-guide §4 第四句式「版本无关,详见仓库」,空间不足用紧凑形「版本无关。」)。
16
+ - **registry 数据**:八个自研条目终态——surf / obmc-web / onetree-log 打 `decoupled`+`audience: internal`,quota-watch 打 `decoupled`;四条 `verified` 数组删除(历史归 git 与各仓库文档),兼容句换第四句式;四条 coupled 条目(dsh-m / dsh-skins / skip-browser-auth / copilot-auth)verified 原样保留。
17
+ - **测试**:净增 9 例(registry 字段校验 5、tools-search 标注 2、cli-search 2);全量 1133 tests,本机 1108 pass / 15 fail——15 项全部为 Windows 平台既有 symlink 语义用例(与基线集合逐项一致,零回归);typecheck 零错误。
18
+
19
+ ### 0.9.32 变更:npm registry 路由自适应——元数据预取换源/镜像自愈(ADR-0012)
20
+
21
+ - **动机**:2026-10-04 上海 Windows 桌面机实证——元数据预取写死直连 registry.npmjs.org,间歇性超时使升级在委派 pnpm 之前夭折(`fetch failed` / `The operation was aborted due to timeout`,且 `.plugin-manager/logs` 无对应操作日志),而 npmmirror 镜像早已同步目标版本;同期首尔腾讯云机 npmjs 直连良好。两台机器需要零配置各自可用。
22
+ - **读分类路由(L1/L3)**:检测读(`npmLatest` 升级探测 / `npmPackumentTimes` 发布时刻)恒以 npmjs 权威源优先、首腿超时收紧 ≤5s、失败降级生效源;履约读(`npmVersion` 精确版本元数据 / `npmPackument` 预热)走生效源优先。`npmPackument` 的 registry 参数此前被静默忽略,本版修复(显式参数开始生效,测试钉住)。
23
+ - **L0 传输层**:httpx 换 undici 自带 fetch + 自建 dispatcher(`EnvHttpProxyAgent` 显式交接代理解析)——元数据预取开始感知 `HTTP(S)_PROXY` / `npm_config_*` 代理,并规避宿主 undici 全局 dispatcher 污染(dsh-market net.ts #742 同族);`fetchLimited` 新增 PUT 方法(仅供 sync 原语);网络失败错误附 `via` 上下文,失败摘要仅在代理路径渲染「(经 <掩码代理>)」。
24
+ - **L2 镜像滞后自愈**:`NO_MATCHING_VERSION` 且 registry 字段指名 npmmirror(分类层结构化识别、完整文本上判定、上层零 regex)→ 按需同步镜像(`registry-direct.npmmirror.com/<pkg>/sync`,PUT)→ 既有退避重试(heal 记 `B3_NPMMIRROR_SYNC`);`npmVersion` 履约阶梯同步适用(404 → sync → 等待 10s → 同源重试 → npmjs 兜底);sync 受理后作废 latest 缓存。`DSHM_MIRROR_SYNC=0` 一键关闭。
25
+ - **新模块 `src/core/npm-route.ts`**:`DSHM_NPM_REGISTRY` 源覆盖(最高优先,设了跳过探测)> [.npmrc registry, npmjs, npmmirror];probe-once(2.5s 共享预算、single-flight、胜者须完整响应带 version);决策持久化 `<cacheDir>/npm-route.json`(删文件即重探);全候选失败仅内存回退 npmjs(60s TTL 后重探、不落盘)。生效源切换自动作废 latest / packument 两级缓存。
26
+ - **wire 测试缝隙迁移**:既有 mock `globalThis.fetch` 的测试缝隙迁至 `_setWireFetchForTests`(versions / npm-integrity / registry-check 三文件,断言与计数语义不变)。
27
+ - **测试**:净增 41 例(L0 代理解析/wire 转发/via/PUT、npm-route 12 例、读分类路由、registry 分类字段、B3 sync、L4 摘要、kill-switch 阶梯/集成、尾斜杠归一);全量 1128 tests,本机 1103 pass / 15 fail——15 项全部为 Windows 平台既有 symlink 语义用例(与改动前基线集合逐项一致,零回归);typecheck 零错误。真机验收(上海机 scoped 自研包窗口内升级 + 首尔机 `dshm outdated`)随发版执行。
28
+
11
29
  ### 0.9.31 变更:doctor 支持 desktop profile(CLI 例外开口 + farmChecked 语义修订,ADR-0011)
12
30
 
13
31
  - **动机**:Windows desktop 机实机报告证实 core 引擎本就 profileDir 参数化无 web 硬编码(desktop 物化布局判 hoisted、farm=0 属常态),但 CLI 全命令一刀切拒绝 `--profile desktop` 把只读体检连坐;desktop-only 机器无 flag 体检还会扫不存在的 web 目录得全 0 报告。
@@ -307,6 +325,24 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
307
325
 
308
326
  ## English
309
327
 
328
+ ### Added in 0.9.33 — self-dev entry metadata v1.1: `audience`/`decoupled` fields with three-surface marking
329
+
330
+ - **Schema**: registry entries gain optional `decoupled?: true` (decoupled entries, operational criterion; mutually exclusive with `verified` — co-occurrence is an error) and `audience?: 'public' | 'internal'` (defaults to public, no key emitted; `'team'` reserved, not accepted this release). The decision record and five rulings live in DESIGN §2.7; terms in GLOSSARY (Decoupled Entry / Internal Entry).
331
+ - **Three-surface marking**: GUI cards and the Modal head gain 「作者自用」 (Author's own) and 「版本无关」 (Version-independent) badges plus Modal audience/compat rows; `dshm_search` output projects `audience`/`decoupled` with an inline [作者自用] line marker, and the tool description states the recommendation discipline (never proactively recommend internal entries to general users unless named or for internal rollout); CLI `dshm search` marks lines identically (new `tests/cli-search.test.mjs`). Installed views and favorites stay unmarked — the discovery path is the discipline's target.
332
+ - **CI**: validate-registry gains a soft warning — a decoupled entry whose description contains the 「已适配」 pattern (copy-guide §4 fourth pattern 「版本无关,详见仓库」, compact form 「版本无关。」 when space is tight).
333
+ - **Registry data**: final state for the eight self-dev entries — surf / obmc-web / onetree-log gain `decoupled`+`audience: internal`, quota-watch gains `decoupled`; the four `verified` arrays are removed (history lives in git and per-repo docs) and compatibility sentences switch to the fourth pattern; the four coupled entries (dsh-m / dsh-skins / skip-browser-auth / copilot-auth) keep verified as-is.
334
+ - **Tests**: 9 net-new cases (registry validation 5, tools-search marking 2, cli-search 2); full suite 1133 tests, locally 1108 pass / 15 fail — all 15 are the pre-existing Windows symlink-semantics baseline (item-for-item identical, zero regression); typecheck clean.
335
+
336
+ ### Added in 0.9.32 — npm registry route adaptation: metadata prefetch source switching / mirror self-heal (ADR-0012)
337
+
338
+ - **Motivation**: on 2026-10-04 a Shanghai Windows desktop machine proved the metadata prefetch is hardcoded to registry.npmjs.org; intermittent timeouts aborted upgrades before pnpm was ever invoked (`fetch failed` / `The operation was aborted due to timeout`, with no operation log under `.plugin-manager/logs`), while npmmirror had already synced the target version. A Seoul (Tencent Cloud) box enjoys fast direct npmjs. Both machines must work with zero configuration.
339
+ - **Read-class routing (L1/L3)**: detection reads (`npmLatest` upgrade probe / `npmPackumentTimes` publish times) prefer the authoritative npmjs (first leg timeout tightened to ≤5s) and degrade to the effective source; fulfillment reads (`npmVersion` exact metadata / `npmPackument` warm) prefer the effective source. `npmPackument` previously ignored its registry parameter — fixed this release (explicit parameter now takes effect, pinned by tests).
340
+ - **L0 transport**: httpx now uses undici's own fetch with a self-built dispatcher (`EnvHttpProxyAgent` handed the resolved proxies explicitly) — metadata prefetch honours `HTTP(S)_PROXY` / `npm_config_*` proxies and is immune to host undici global-dispatcher pollution (dsh-market net.ts #742 family); `fetchLimited` gains a PUT method (sync primitive only); network failures carry a `via` context, and the failure digest renders 「(via <masked proxy>)」 only on proxy paths.
341
+ - **L2 mirror-lag self-heal**: on `NO_MATCHING_VERSION` whose registry field names npmmirror (structured recognition in the classification layer, judged on the full text — upper layers never regex) → on-demand mirror sync (`registry-direct.npmmirror.com/<pkg>/sync`, PUT) → the existing backoff retry (heal `B3_NPMMIRROR_SYNC`); the same ladder applies to `npmVersion` (404 → sync → wait 10s → same-source retry → npmjs fallback); latest cache is invalidated after a sync. `DSHM_MIRROR_SYNC=0` switches it all off.
342
+ - **New module `src/core/npm-route.ts`**: `DSHM_NPM_REGISTRY` override (highest priority, skips probing) > [.npmrc registry, npmjs, npmmirror]; probe-once (2.5s shared budget, single-flight, winner must return a complete body with version); decision persisted at `<cacheDir>/npm-route.json` (delete to re-probe); total failure falls back to npmjs in memory only (60s TTL then re-probe, never persisted). Effective-source switches invalidate both latest and packument caches.
343
+ - **Wire test seam migration**: the existing mock-`globalThis.fetch` seam moved to `_setWireFetchForTests` (versions / npm-integrity / registry-check; assertion and counting semantics unchanged).
344
+ - **Tests**: 41 new cases (L0 proxy parsing/wire forwarding/via/PUT, npm-route ×12, read-class routing, registry classification field, B3 sync, L4 digest, kill-switch ladder/integration, trailing-slash normalization); full suite 1128 tests — 1103 pass / 15 fail on this machine, all 15 being pre-existing Windows symlink-semantics cases (item-for-item identical to the pre-change baseline: zero regression); typecheck clean. Real-machine acceptance (in-window scoped self-published upgrade on the Shanghai box, `dshm outdated` on Seoul) ships with the release.
345
+
310
346
  ### Added in 0.9.31 — doctor desktop profile support (CLI exception + farmChecked semantics revision, ADR-0011)
311
347
 
312
348
  - **Motivation**: a Windows desktop machine's report confirmed the core engine is profileDir-parameterized with no web hardcoding (materialized desktop layout detects as hoisted with farm=0 — the norm, not a short-circuit), yet the CLI's blanket `--profile desktop` rejection swept up the read-only doctor; desktop-only machines scanning a nonexistent web dir got an all-zero report.
package/README.md CHANGED
@@ -121,6 +121,7 @@ dshm restart --yes
121
121
 
122
122
  - **数据获取**:dist-tags 探测最新版本 → jsDelivr 按精确版本直取 → npmmirror → unpkg 三线路兜底;版本未变不重拉正文,TTL 内跳过探测。「最新版本」探测结果为纯内存缓存:**重启 DSH 即失效**(发版后重启即可看到新版本),经 dsh-m 安装/升级/卸载成功后该条目缓存立即作废。失败回落 `<缓存目录>/awesome/` 运行时缓存先展示(先回缓存、后台自愈)——过期数据绝不冒充最新;缓存状态(缓存快照/目录版本/获取线路/更新时间)在**设置页**可见,浏览页不打扰。
123
123
  - **开关与锁定**:设置页 `communityCatalog` 开关(默认开,live 生效)、`communityCatalogPin` 可锁定目录版本(精确 semver);CLI 用 `DSHM_COMMUNITY_CATALOG=0` 退出、`DSHM_COMMUNITY_CATALOG_PIN` 锁版本。
124
+ - **npm 元数据路由(0.9.32,ADR-0012)**:安装/升级前的元数据预取按机器网络自适应选源——probe-once(2.5s 预算)探测 npmjs 与镜像谁先应答,决策持久化于 `<缓存目录>/npm-route.json`(删除即重探);检测读(升级探测/发布时刻)恒以 npmjs 权威源优先、镜像降级,履约读(精确版本元数据)走生效源、镜像未同步时自动按需同步后重试。环境变量:`DSHM_NPM_REGISTRY` 强制元数据读取源(最高优先,设了跳过探测);`DSHM_MIRROR_SYNC=0` 关闭镜像滞后自愈。**与 `DSHM_REGISTRY_URL`(收录清单源覆盖)是两回事**。调试回环 registry 时若本机开着系统代理,请配 `NO_PROXY=127.0.0.1,localhost`(代理语义跟随标准 env/npm config,不自动豁免 loopback)。
124
125
  - **分类与搜索**:社区分类是开放集(已知 23 个分类带中英双语标签:中文由包内单一事实源维护、英文取上游目录,按界面语言下发);搜索走相关性加权管线(中英文双匹配),且为**跨区全局**——任一分区的搜索框同时命中社区与精选(浏览保持分区,摘要行报两分区精确命中数、精选命中置顶分组);社区区排序可切(下载量/Star/收录日期 × 升降,默认下载量降序,无下载量 ≠ 0 下载)。
125
126
  - **安装语义**:社区条目与精选条目走同一安装链路(npm 锁精确版本 / GitHub 锁 commit SHA);市场浏览页只对 npm 条目做更新探测,GitHub 条目不做页面级探测(匿名配额 60 次/小时不可控)。
126
127
  - **已装页预算(best-effort)**:已装页对 GitHub 来源插件做更新检查,单次请求 ≤25 个、宿主进程滚动 1 小时 ≤50 个(仅被动检查;主动安装/升级不受限);超限条目如实标注「检查未完成」而不冒充「全部最新」。独立 CLI 进程不共享宿主内预算,并发场景不承诺 60 次/小时绝不耗尽。
package/docs/DESIGN.md CHANGED
@@ -18,7 +18,7 @@
18
18
  - **默认源**获取顺序:raw.githubusercontent `@main` → jsDelivr `@main` → 默认 TTL 缓存 → npm 包内快照兜底。jsDelivr 是 GitHub 内容的免费 CDN 镜像,仅作 raw 拉取失败时的**备用线路**(覆盖大陆可达性与 GitHub 故障;CDN 缓存可能滞后数小时,可用 purge.jsdelivr.net 手动清理)。收录更新与插件发版**解耦**。
19
19
  - **自定义覆盖源(单一地址,整体覆盖,不合并)**:`registryUrl` 为空 = 官方默认清单;非空 = 一个 HTTPS URL(或 loopback HTTP,仅本机管理员信任边界,不承诺 DNS rebinding 防护)或 DSH Web 主机上的本地普通文件(绝对路径 / `file://`,`realpath` + `O_NOFOLLOW` 同 fd 读取与复核,严格 UTF-8,2 MiB 原始字节上限)。自定义源失败只回退**该源自己的缓存**,绝不静默改用官方清单;无可缓存数据时返回空清单 + 不可用状态。
20
20
  - **「新增插件」流程**:设置页下载默认 `registry.json` → 用户自行编辑副本 → 填入副本地址「校验并应用」。副本是独立快照,不自动同步官方新条目。
21
- - **严格 v1 schema**:顶层只允许 `version/plugins`,条目只允许 `id/name/description/category/tags/source/npm/github/homepage/icon`;未知字段、非法 ID/npm/GitHub/URL、重复 ID/tag、字段超限、`plugins` 超过 1,000 条均拒绝整份清单(不截断、不部分加载);超过 200 条提示性能边界。
21
+ - **严格 v1 schema**:顶层只允许 `version/plugins`,条目只允许 `id/name/description/category/tags/source/npm/github/homepage/icon`(v1.1 增补可选 `verified`、`alsoCategories`、`decoupled`、`audience`,见 §2.7);未知字段、非法 ID/npm/GitHub/URL、重复 ID/tag、字段超限、`plugins` 超过 1,000 条均拒绝整份清单(不截断、不部分加载);超过 200 条提示性能边界。
22
22
  - **缓存模型**:cache 按 namespace 分目录(Host 与 Agent tools 用 `host/`,独立 CLI 用 `cli/`,互不删除),按带算法版本的 cacheKey 分文件(`CacheFile v2`:原子临时文件 + fsync + rename,0700/0600,symlink 拒写,同 key 进程内写锁)。**候选验证只写候选 cache,绝不 prune 旧源**;settings 写入成功后才 `commitActiveSource`:先原子写 accepted-source metadata(`host/active-source.json`,仅 Host),再清理非当前 custom cache;metadata 失败不 prune、prune 失败保留旧 cache,均只降级为 warning,不撤销已生效的配置。切换后只保留默认缓存与当前 custom 缓存。
23
23
  - **统一安全 HTTP**:所有 JSON/text/HEAD 请求共享同一 primitive——手动重定向(每跳校验协议、最多 3 跳、循环检测、signal 传播、返回最终 URL)+ 响应大小上限 + 超时。
24
24
  - **秒开缓存语义(0.9.14 SWR)**:`loadRegistry` 与社区获取链的 TTL 过期不再同步等网络——磁盘缓存存在即**先回 stale**(registry `source='default-cache'`、社区 `status='stale'` 既有契约;stale 状态由设置页社区卡承接,市场页不提示临时缓存状态——0.9.15 修订),后台**单飞**刷新写回 cache(同 key 进行中复用;不接调用者 signal,后台自愈不被请求 abort 腰斩;`force`/candidate/显式下载三路径不经 SWR)。默认双线路**粘性**:`loadDefaultChain` 读 cache 记录的上次成功 `source` 排序线路(稳定排序,cache 缺失 = 原序,force 同样生效)。社区 bg 自愈是 force flight——**不走同版本短路**(`if (!opts.force)` 守卫),每 TTL 至多全量重拉一次正文(同步路径的短路优化保留)。页条目 latest 探测缓存为 `latest-cache.ts` 的**纯内存 Map + TTL**(0.9.20 ADR-0006,推翻 0.9.14 的「磁盘信封 write-through + 懒 seed、跨服务重启存活」——2026-10-03 事故实证发版窗口内重启/重开面板全吃陈旧值,重启即失效回归为特性;install/upgrade/uninstall 成功点由 market.ts 按 itemId 定向作废该条目全部 registryKey 变体,只失效不回写;0.9.14 遗留 `<cacheRoot>/latest/<ns>.json` 惰性文件由探测段一次性 best-effort 清扫,`latest/` 在 nsDir 之外、不受 `pruneCaches` 清扫的不变量不变)。客户端默认首页响应存 localStorage(10min TTL、双 zone 分键、读侧再过 normalize 收敛)——面板打开先渲染快照后 background 换新。已装面板第二段探测为 TTL=0 例外(ADR-0008)。
@@ -37,11 +37,13 @@
37
37
  "version": 1,
38
38
  "plugins": [
39
39
  {
40
- "id": "dsh-skins", // slug,唯一
41
- "name": "DSH Skins",
40
+ "id": "dsh-example", // slug,唯一(schema 示例载体,非真实条目)
41
+ "name": "Example Plugin",
42
42
  "description": "中文描述", // v1 只有中文
43
43
  "category": "essentials", // 策展五桶:essentials|cui-picks|self-dev|tencent-lighthouse|watchlist 五选一(0.9.16 策展分类法;旧功能五分类退役)
44
44
  "tags": ["主题"],
45
+ "decoupled": true, // 可选;解耦条目(实操口径),与 verified 互斥
46
+ "audience": "internal", // 可选 public|internal;缺省 public;'team' 预留未开放
45
47
  "source": "github", // npm|github
46
48
  "npm": "可选;source=npm 时必填",
47
49
  "github": "owner/repo", // source=github 时必填;npm 条目也可附
@@ -96,6 +98,15 @@
96
98
  - **技术默认件**:图片三层懒加载(IntersectionObserver + `loading=lazy` + `fetchPriority=low`;缩略图本机直连原图,不引第三方代理服务);移除客户端重复排序(服务端单一排序源);`total>200` 性能提示随分区退役;host-api limit 上限对齐新页大小;空状态逐分区定制;错误态带具体原因 + 重试。
97
99
  - **Backlog(明确不做)**:UI 完整双语字典、时间窗过滤、浏览层宿主兼容徽章/过滤、组管理、个人备注、giscus 评论、静态官网。
98
100
 
101
+ ### 2.7 自研条目元数据:受众与解耦(grilling 定稿 2026-10-05)
102
+
103
+ registry 条目增补两个可选字段(schema v1.1,同一次变更上线;发布纪律:**先发 dsh-m 新版、再推 registry @main**,旧客户端拒收新清单回落缓存属「例行过渡」,0.9.17 alsoCategories 先例):
104
+
105
+ - `decoupled?: true`——**解耦条目**,实操口径判定(DSH 升级后大概率无需跟着发适配新版,价值源多在 DSH 之外);与 `verified` **互斥**(同条目共存报 error),不携带实测数组,展示层以「版本无关」徽章呈现,兼容声明用「版本无关,详见仓库」句式(copy-guide §4 第四句式)。
106
+ - `audience?: 'public' | 'internal'`——**受众标记**;缺省 `public` 不产生键;`internal` = 作者自用(GLOSSARY「自用条目」);`'team'` 值集预留未开放。
107
+
108
+ 五项裁决:①双具名字段(否决 `hostCoupling` 单值枚举——主观评级违背实测声明文化,与 `flags` 旗帜数组——弱类型难校验);②受众两档起步(否决三档,扩档是兼容变更);③载体为字段、策展五桶不动(否决拆 self-dev 桶与移出主清单——`alsoCategories` 先例:多维归属用字段不建桶;撤出主清单反致社区清单浮升);④三端照常返回与展示、**只标注不过滤**,推荐纪律走 `dshm_search` 工具描述约束(否决 agent 通道机械过滤——内部推广也依赖搜索);⑤decoupled 条目**删除** `verified` 数组、渲染「版本无关」徽章(否决保留数组改渲染——缺号不再构成负面信号才是病根)。标注落点:GUI 卡片/Modal 头「作者自用」「版本无关」徽章+Modal 详情行;agent 工具与 CLI 搜索输出 `[作者自用]` 行标;已装视图与收藏页不打标(推荐发现链路才是纪律靶面)。registry 数据侧:surf / obmc-web / onetree-log / quota-watch 四条 decoupled(前三者兼 internal),verified 删除、兼容句换第四句式。
109
+
99
110
  ## 3. 安装 / 卸载 / 升级 / 重启
100
111
 
101
112
  底层原语(本机实证):`dsh plugin --profile web add|remove|update`(转发 pnpm,作用于 `$DSH_HOME/profiles/web`)。**profile 的 `package.json` 就是唯一事实源**——不引入任何额外状态文件。
package/lib/cli.js CHANGED
@@ -147,7 +147,9 @@ profile 目标(0.9.0):CLI 恒作用于 web profile;--profile web 为显
147
147
  --profile desktop 会被拒绝——Desktop profile 的插件管理走官方 Desktop 插件管理页。
148
148
 
149
149
  环境变量:DSHM_REGISTRY_URL(registry 源覆盖)、DSHM_TIMEOUT_MS、DSHM_CACHE_TTL_MIN、DSHM_CACHE_DIR、
150
- DSHM_COMMUNITY_CATALOG=0(退出社区清单)、DSHM_COMMUNITY_CATALOG_PIN(锁定社区目录版本)
150
+ DSHM_COMMUNITY_CATALOG=0(退出社区清单)、DSHM_COMMUNITY_CATALOG_PIN(锁定社区目录版本)、
151
+ DSHM_NPM_REGISTRY(npm 元数据读取源覆盖,ADR-0012;与 DSHM_REGISTRY_URL 是两回事)、
152
+ DSHM_MIRROR_SYNC=0(关闭 npmmirror 按需同步自愈)
151
153
  `;
152
154
  export async function runCli(argv, deps = {}, io = {}) {
153
155
  const out = io.out ?? ((line) => console.log(line));
@@ -232,7 +234,7 @@ async function runCliDispatch(argv, deps, io) {
232
234
  for (const it of result.items) {
233
235
  const inst = it.installed ? ` [已安装 v${it.installedVersion || '?'}]` : '';
234
236
  const zone = it.community === true ? '[社区] ' : '';
235
- out(`• ${it.name} (${it.id})${zone}· ${categoryLabelOf(it.category)} · ${it.source}${inst}`);
237
+ out(`• ${it.name} (${it.id})${zone}${it.audience === 'internal' ? '[作者自用] ' : ''}· ${categoryLabelOf(it.category)} · ${it.source}${inst}`);
236
238
  out(` ${it.description}`);
237
239
  }
238
240
  const shown = result.items.length;
package/lib/client.js CHANGED
@@ -1279,6 +1279,9 @@ var ZH = {
1279
1279
  "badge.deprecated": "\u5DF2\u5F03\u7528",
1280
1280
  "sub.snapshot": "v{v}\uFF08\u76EE\u5F55\u5FEB\u7167\uFF09",
1281
1281
  "badge.verified": "\u5DF2\u5B9E\u6D4B",
1282
+ "badge.internal": "\u4F5C\u8005\u81EA\u7528",
1283
+ "badge.decoupled": "\u7248\u672C\u65E0\u5173",
1284
+ "badge.decoupled.tip": "DSH \u5347\u7EA7\u65E0\u9700\u8DDF\u968F\u9002\u914D",
1282
1285
  "modal.category": "\u5206\u7C7B",
1283
1286
  "modal.added": "\u6536\u5F55\u65E5\u671F",
1284
1287
  "modal.dlwindow": "\u4E0B\u8F7D\u91CF\uFF0830 \u5929\u7A97\u53E3\uFF09",
@@ -1290,6 +1293,10 @@ var ZH = {
1290
1293
  "modal.installcmd": "\u5B89\u88C5\u547D\u4EE4",
1291
1294
  "modal.copy": "\u590D\u5236",
1292
1295
  "modal.copied": "\u5DF2\u590D\u5236",
1296
+ "modal.audience.label": "\u53D7\u4F17",
1297
+ "modal.audience.value": "\u4F5C\u8005\u81EA\u7528\uFF08\u4E0D\u9762\u5411\u5927\u4F17\u63A8\u5E7F\uFF09",
1298
+ "modal.decoupled.label": "\u9002\u914D",
1299
+ "modal.decoupled.value": "\u7248\u672C\u65E0\u5173\uFF08DSH \u5347\u7EA7\u65E0\u9700\u8DDF\u968F\u9002\u914D\uFF09",
1293
1300
  "op.clear": "\u6E05\u9664\u5DF2\u7ED3\u675F",
1294
1301
  "op.clear.none": "\u6CA1\u6709\u53EF\u6E05\u9664\u7684\u5DF2\u7ED3\u675F\u8BB0\u5F55",
1295
1302
  "op.superseded.note": "{target} \u5DF2\u8DF3\u8FC7\uFF08\u524D\u63D0\u5DF2\u4E0D\u6210\u7ACB\u6216\u5DF2\u624B\u52A8\u5904\u7406\uFF09",
@@ -1542,6 +1549,9 @@ var EN = {
1542
1549
  "badge.deprecated": "Deprecated",
1543
1550
  "sub.snapshot": "v{v} (catalog snapshot)",
1544
1551
  "badge.verified": "Verified",
1552
+ "badge.internal": "Author's own",
1553
+ "badge.decoupled": "Version-independent",
1554
+ "badge.decoupled.tip": "Survives DSH upgrades without per-release adaptation",
1545
1555
  "modal.category": "Category",
1546
1556
  "modal.added": "Added",
1547
1557
  "modal.dlwindow": "Downloads (30-day window)",
@@ -1553,6 +1563,10 @@ var EN = {
1553
1563
  "modal.installcmd": "Install command",
1554
1564
  "modal.copy": "Copy",
1555
1565
  "modal.copied": "Copied",
1566
+ "modal.audience.label": "Audience",
1567
+ "modal.audience.value": "Author's own (not for general promotion)",
1568
+ "modal.decoupled.label": "Compat",
1569
+ "modal.decoupled.value": "Version-independent (no per-release adaptation)",
1556
1570
  "op.clear": "Clear ended",
1557
1571
  "op.clear.none": "Nothing finished to clear",
1558
1572
  "op.superseded.note": "{target} skipped (precondition gone or already handled)",
@@ -2541,6 +2555,8 @@ function DetailModal({ it, labels, busy, onClose, onInstall, profileKind, instal
2541
2555
  h("span", { className: "dshm-name" }, it.name),
2542
2556
  it.deprecated === true ? h("span", { className: "dshm-badge warn" }, lookup("badge.deprecated")) : null,
2543
2557
  it.community !== true && Array.isArray(it.verified) && it.verified.length ? h("span", { className: "dshm-badge", title: it.verified.join("\u3001") }, lookup("badge.verified")) : null,
2558
+ it.audience === "internal" ? h("span", { className: "dshm-badge" }, lookup("badge.internal")) : null,
2559
+ it.decoupled === true ? h("span", { className: "dshm-badge", title: lookup("badge.decoupled.tip") }, lookup("badge.decoupled")) : null,
2544
2560
  it.installed ? h("span", { className: "dshm-badge" }, lookup("badge.installed")) : null,
2545
2561
  it.community === true ? h("span", { className: "dshm-badge info" }, lookup("badge.community")) : null,
2546
2562
  h("span", { className: "dshm-badge info" }, it.source === "npm" ? "npm" : "github"),
@@ -2567,6 +2583,8 @@ function DetailModal({ it, labels, busy, onClose, onInstall, profileKind, instal
2567
2583
  kv(lookup("detail.latest"), it.latestVersion ? `v${it.latestVersion}` : it.latestTag ? it.latestTag : it.latestSha ? it.latestSha : it.latestError ? it.version ? lookup("sub.snapshot", { v: it.version }) : it.latestError : "\u2014"),
2568
2584
  kv(lookup("detail.installed"), it.installedPkg ? `${it.installedPkg} v${it.installedVersion || "?"}` : lookup("installed.none")),
2569
2585
  it.community !== true && Array.isArray(it.verified) && it.verified.length ? kv(lookup("modal.verified"), it.verified.join("\u3001")) : null,
2586
+ it.audience === "internal" ? kv(lookup("modal.audience.label"), lookup("modal.audience.value")) : null,
2587
+ it.decoupled === true ? kv(lookup("modal.decoupled.label"), lookup("modal.decoupled.value")) : null,
2570
2588
  (it.tags || []).length ? kv(lookup("modal.tags"), it.tags.join(", ")) : null,
2571
2589
  it.deprecated === true && it.replacement ? kv(lookup("modal.replacement"), it.replacement) : null
2572
2590
  ),
@@ -2885,6 +2903,8 @@ function MarketTab({ notify, markets, onMutation, ops, favorites, profileKind })
2885
2903
  ) : null,
2886
2904
  it.deprecated === true ? h("span", { className: "dshm-badge warn", key: "dep" }, lookup("badge.deprecated")) : null,
2887
2905
  it.community !== true && Array.isArray(it.verified) && it.verified.length ? h("span", { className: "dshm-badge", key: "v", title: it.verified.join("\u3001") }, lookup("badge.verified")) : null,
2906
+ it.audience === "internal" ? h("span", { className: "dshm-badge dshm-badge-internal", key: "internal" }, lookup("badge.internal")) : null,
2907
+ it.decoupled === true ? h("span", { className: "dshm-badge", key: "decoupled", title: lookup("badge.decoupled.tip") }, lookup("badge.decoupled")) : null,
2888
2908
  it.outdated ? h("span", { className: "dshm-badge warn", key: "u" }, lookup("badge.update")) : null,
2889
2909
  it.installed ? h("span", { className: "dshm-badge", key: "i" }, lookup("badge.installed")) : null,
2890
2910
  it.community === true ? h("span", { className: "dshm-badge info", key: "c" }, lookup("badge.community")) : null,
@@ -215,25 +215,32 @@ export const PNPM_OUTCOME_CODES = {
215
215
  /**
216
216
  * 对 pnpm/dsh 原始输出文本做六类归一解释。分类发生在任何文案改写之前;
217
217
  * 上层(事务/market)只消费 class + code 常量,永不 regex 原始输出。
218
+ * registry 识别(ADR-0012 L2)同样只在此处发生:tail-800 截断可能吃掉特征串,
219
+ * 因此必须在完整文本上判定后随结构化结果上交。
218
220
  */
219
221
  export function classifyPnpmError(text) {
220
222
  const raw = String(text ?? '');
223
+ const registry = raw.includes('registry-direct.npmmirror.com') || raw.includes('registry.npmmirror.com')
224
+ ? 'npmmirror'
225
+ : raw.includes('registry.npmjs.org')
226
+ ? 'npmjs'
227
+ : null;
221
228
  if (raw.includes(PNPM_OUTCOME_CODES.NO_MATCHING_VERSION)) {
222
- return { class: 'retryable-lag', code: PNPM_OUTCOME_CODES.NO_MATCHING_VERSION };
229
+ return { class: 'retryable-lag', code: PNPM_OUTCOME_CODES.NO_MATCHING_VERSION, registry };
223
230
  }
224
231
  if (raw.includes(PNPM_OUTCOME_CODES.CONFIG_MISMATCH)) {
225
- return { class: 'config-drift', code: PNPM_OUTCOME_CODES.CONFIG_MISMATCH };
232
+ return { class: 'config-drift', code: PNPM_OUTCOME_CODES.CONFIG_MISMATCH, registry };
226
233
  }
227
234
  if (raw.includes(PNPM_OUTCOME_CODES.OUTDATED_LOCKFILE)) {
228
- return { class: 'config-drift', code: PNPM_OUTCOME_CODES.OUTDATED_LOCKFILE };
235
+ return { class: 'config-drift', code: PNPM_OUTCOME_CODES.OUTDATED_LOCKFILE, registry };
229
236
  }
230
237
  if (raw.includes(PNPM_OUTCOME_CODES.UNUSED_PATCH)) {
231
- return { class: 'unused-patch', code: PNPM_OUTCOME_CODES.UNUSED_PATCH };
238
+ return { class: 'unused-patch', code: PNPM_OUTCOME_CODES.UNUSED_PATCH, registry };
232
239
  }
233
240
  if (isPrepareBlocked(raw))
234
- return { class: 'needs-builds' };
241
+ return { class: 'needs-builds', registry };
235
242
  const m = /(ERR_PNPM_[A-Z0-9_]+)/.exec(raw);
236
- return { class: 'hard-fail', code: m?.[1] };
243
+ return { class: 'hard-fail', code: m?.[1], registry };
237
244
  }
238
245
  /** 统一的命令取消错误形态(runCommand 调用前已取消与运行中取消同款)。 */
239
246
  function commandAbortError() {
package/lib/core/httpx.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * 循环检测、signal 传播、返回最终 URL;registry/homepage/icon 诊断不得另起一套。
5
5
  */
6
6
  import { Buffer } from 'node:buffer';
7
+ import { Agent, EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
7
8
  export class HttpError extends Error {
8
9
  status;
9
10
  /** 非 2xx 响应的 headers(供上层识别限流等场景);body-cap/协议类错误无 headers */
@@ -18,6 +19,54 @@ const MAX_DEFAULT = 2 * 1024 * 1024; // 2MB:registry/npm metadata 足够
18
19
  const MAX_REDIRECTS = 3;
19
20
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
20
21
  const USER_AGENT = 'dsh-m (personal marketplace)';
22
+ /**
23
+ * L0(ADR-0012):undici 自带 fetch + 自建 dispatcher——全局 fetch 不吃代理 env
24
+ * 且暴露于宿主 undici 全局 dispatcher 污染(dsh-market net.ts #742 实录)。
25
+ * 代理解析:标准 env 小写优先,npm_config_* 兜底——EnvHttpProxyAgent 自身只读
26
+ * http(s)_proxy,npm 命名空间的代理必须显式交接,否则静默直连而报错却声称走过代理。
27
+ */
28
+ export function resolveProxyConfig() {
29
+ const pick = (raw) => {
30
+ const value = raw?.trim();
31
+ if (value === undefined || value === '')
32
+ return null;
33
+ return /^[a-z][a-z\d+.-]*:\/\//iu.test(value) ? value : `http://${value}`;
34
+ };
35
+ const https = pick(process.env.https_proxy ?? process.env.HTTPS_PROXY) ?? pick(process.env.npm_config_https_proxy);
36
+ const http = pick(process.env.http_proxy ?? process.env.HTTP_PROXY) ?? pick(process.env.npm_config_proxy);
37
+ return { http, https };
38
+ }
39
+ /** 代理 URL 凭据掩码(via 附着与 describeFetchFailure 共用)。 */
40
+ export function maskProxy(url) {
41
+ return url.replace(/\/\/[^@]*@/u, '//***@');
42
+ }
43
+ let directAgent = null;
44
+ let proxyAgent = null;
45
+ /** 仅测试:env 变更后重建单例(单例只为连接复用,不承载语义)。 */
46
+ export function resetProxyAgentsForTests() {
47
+ directAgent = null;
48
+ proxyAgent = null;
49
+ }
50
+ function dispatcherFor() {
51
+ const { http, https } = resolveProxyConfig();
52
+ if (http === null && https === null) {
53
+ directAgent ??= new Agent();
54
+ return directAgent;
55
+ }
56
+ proxyAgent ??= new EnvHttpProxyAgent({ httpProxy: http ?? undefined, httpsProxy: https ?? undefined });
57
+ return proxyAgent;
58
+ }
59
+ /** wire 层出网引用:默认 undici 包自带 fetch(带 dispatcher)。 */
60
+ let wireFetch = (url, init) => undiciFetch(url, init);
61
+ /**
62
+ * 仅测试:替换 wire 层 fetch(本仓库既有 mock globalThis.fetch 测试缝隙的迁移口,
63
+ * 首参收字符串 url;传 null 恢复 undici 默认)。生产路径永不调用。
64
+ */
65
+ export function _setWireFetchForTests(fn) {
66
+ wireFetch = fn
67
+ ? (url, init) => fn(url.toString(), init)
68
+ : (url, init) => undiciFetch(url, init);
69
+ }
21
70
  export function assertSafeUrl(raw) {
22
71
  let url;
23
72
  try {
@@ -72,12 +121,26 @@ export async function fetchLimited(url, opts = {}) {
72
121
  const seen = new Set([current.toString()]);
73
122
  for (let hop = 0;; hop++) {
74
123
  opts.onRequest?.(current.toString());
75
- const res = await fetch(current, {
76
- redirect: 'manual',
77
- signal,
78
- method: opts.method ?? 'GET',
79
- headers: { 'user-agent': USER_AGENT, ...opts.headers },
80
- });
124
+ let res;
125
+ try {
126
+ // 实证(undici 7.30):redirect:'manual' 返回真实 3xx + Location(type=basic),
127
+ // 现有手动跳循环原样保留;每跳 assertSafeUrl / 上限 / 循环检测不动。
128
+ res = await wireFetch(current, {
129
+ redirect: 'manual',
130
+ signal,
131
+ method: opts.method ?? 'GET',
132
+ headers: { 'user-agent': USER_AGENT, ...opts.headers },
133
+ dispatcher: dispatcherFor(),
134
+ });
135
+ }
136
+ catch (err) {
137
+ if (!(err instanceof HttpError)) {
138
+ const { http, https } = resolveProxyConfig();
139
+ const proxyUrl = https ?? http;
140
+ err.via = proxyUrl === null ? 'direct' : maskProxy(proxyUrl);
141
+ }
142
+ throw err;
143
+ }
81
144
  if (REDIRECT_STATUSES.has(res.status)) {
82
145
  await res.body?.cancel().catch(() => undefined);
83
146
  if (hop >= MAX_REDIRECTS)
@@ -172,13 +235,18 @@ export function describeFetchFailure({ label, err, attempts, elapsedMs }) {
172
235
  else {
173
236
  reason = String(err);
174
237
  }
238
+ // L4(ADR-0012):仅代理路径渲染「(经 …)」;direct/缺失不渲染(无代理机器零噪音)。
239
+ const via = err instanceof Error && typeof err.via === 'string'
240
+ ? err.via
241
+ : null;
242
+ const viaText = via && via !== 'direct' ? `(经 ${maskProxy(via)})` : '';
175
243
  const cost = [];
176
244
  if (typeof attempts === 'number' && attempts > 0)
177
245
  cost.push(`${attempts} 次尝试`);
178
246
  if (typeof elapsedMs === 'number' && elapsedMs > 0)
179
247
  cost.push(`耗时 ${(elapsedMs / 1000).toFixed(1)}s`);
180
248
  const costText = cost.length > 0 ? `(${cost.join(',')})` : '';
181
- return `${label} 失败:${reason}${costText};可稍后重试或检查网络后重试`;
249
+ return `${label} 失败:${reason}${viaText}${costText};可稍后重试或检查网络后重试`;
182
250
  }
183
251
  /** 字节下载(社区目录正文用):非 2xx 抛 HttpError;bytes = 完整响应体。 */
184
252
  export async function fetchBytesLimited(url, opts = {}) {
@@ -14,6 +14,7 @@
14
14
  import { rm } from 'node:fs/promises';
15
15
  import { join } from 'node:path';
16
16
  import { WEB_PROFILE, cacheRoot } from './env.js';
17
+ import { onRouteSwitch } from './npm-route.js';
17
18
  const latestCache = new Map();
18
19
  const LATEST_CACHE_MAX = 5000;
19
20
  /** 缓存键第三段(itemId):浏览页键 / 已装页 matched 键 / npm-only 键共用同一尾段,定向失效按它匹配。 */
@@ -79,3 +80,8 @@ export function resetLatestCacheForTest() {
79
80
  latestCache.clear();
80
81
  swept.clear();
81
82
  }
83
+ /** L1(ADR-0012):npm 生效源切换 → 整体作废 latest 探测缓存(镜像的答案不是官方源的答案)。 */
84
+ export function clearAllLatestCache() {
85
+ latestCache.clear();
86
+ }
87
+ onRouteSwitch(() => clearAllLatestCache());
@@ -0,0 +1,201 @@
1
+ /**
2
+ * L1/L2(ADR-0012):npm registry 路由自适应。
3
+ * 候选 = `DSHM_NPM_REGISTRY` env(最高优先,设了跳过探测)> [.npmrc registry, npmjs, npmmirror] 去重;
4
+ * probe-once(Promise.any 探 `semver/latest`,2.5s 共享预算,胜者须合法 JSON 带 version)+ single-flight;
5
+ * 决策持久化 `<cacheDir()>/npm-route.json`;**全候选失败仅内存回退 npmjs(TTL 60s 过期重探,不落盘)**——
6
+ * 对官方 region-probe「持久化回退值」立场的显式偏离:GUI 长驻进程里坏决策不可见、恢复口不可发现。
7
+ * sync 原语:npmmirror 按需同步(know-how 020 §2.2/§3.1),`DSHM_MIRROR_SYNC=0` 全关。
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
13
+ import { cacheDir } from './env.js';
14
+ import { fetchJsonLimited, fetchLimited } from './httpx.js';
15
+ export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
16
+ export const NPM_MIRROR = 'https://registry.npmmirror.com';
17
+ const PROBE_BUDGET_MS = 2_500;
18
+ const FALLBACK_TTL_MS = 60_000;
19
+ const DECISION_FILE = 'npm-route.json';
20
+ /** 用户级 .npmrc 顶层 `registry=` 键(ini-lite;scope 键 v1 忽略;缺失/损坏 = null)。 */
21
+ export function readNpmrcRegistry(path = join(homedir(), '.npmrc')) {
22
+ try {
23
+ const text = readFileSync(path, 'utf8');
24
+ for (const rawLine of text.split(/\r?\n/)) {
25
+ const line = rawLine.trim();
26
+ if (line === '' || line.startsWith('#') || line.startsWith(';'))
27
+ continue;
28
+ const m = /^registry\s*=\s*(.+)$/.exec(line);
29
+ if (m) {
30
+ const base = m[1].trim().replace(/\/+$/, '');
31
+ return base === '' ? null : base;
32
+ }
33
+ }
34
+ }
35
+ catch {
36
+ // 文件缺失/不可读 = 无 .npmrc 源
37
+ }
38
+ return null;
39
+ }
40
+ /** 候选源(env 直采时恒单元素;否则 .npmrc 优先、官方源与镜像兜底,去重保序)。 */
41
+ export function candidates() {
42
+ const override = process.env.DSHM_NPM_REGISTRY?.trim();
43
+ if (override)
44
+ return [override.replace(/\/+$/, '')];
45
+ const out = [];
46
+ for (const c of [readNpmrcRegistry(), DEFAULT_NPM_REGISTRY, NPM_MIRROR]) {
47
+ if (c && !out.includes(c))
48
+ out.push(c);
49
+ }
50
+ return out;
51
+ }
52
+ const listeners = [];
53
+ export function onRouteSwitch(fn) {
54
+ listeners.push(fn);
55
+ }
56
+ /** 生效值变化才广播(含首次决议;listener 异常不阻断路由)。 */
57
+ let effective = null;
58
+ function setEffective(base) {
59
+ if (effective === base)
60
+ return;
61
+ effective = base;
62
+ for (const fn of listeners) {
63
+ try {
64
+ fn(base);
65
+ }
66
+ catch {
67
+ // listener 自身问题不阻断
68
+ }
69
+ }
70
+ }
71
+ function decisionPath() {
72
+ return join(cacheDir(), DECISION_FILE);
73
+ }
74
+ function readDecisionFile() {
75
+ try {
76
+ const doc = JSON.parse(readFileSync(decisionPath(), 'utf8'));
77
+ if (typeof doc.base === 'string' && /^https?:\/\//.test(doc.base))
78
+ return doc.base.replace(/\/+$/, '');
79
+ }
80
+ catch {
81
+ // 缺失/损坏 = 无决策(下次重探)
82
+ }
83
+ return null;
84
+ }
85
+ async function writeDecisionFile(base, cands) {
86
+ try {
87
+ const doc = { base, decidedAt: new Date().toISOString(), candidates: cands };
88
+ await writeFileAtomic(decisionPath(), JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
89
+ }
90
+ catch {
91
+ // 落盘失败不影响本次生效(内存已有)
92
+ }
93
+ }
94
+ async function defaultProbeFetch(base, signal) {
95
+ const doc = await fetchJsonLimited(`${base}/semver/latest`, { timeoutMs: PROBE_BUDGET_MS, signal });
96
+ return typeof doc?.version === 'string' ? doc.version : '';
97
+ }
98
+ let memBase = null;
99
+ let memIsFallback = false;
100
+ let fallbackAt = 0;
101
+ let inflight = null;
102
+ export function resetNpmRouteForTests() {
103
+ memBase = null;
104
+ memIsFallback = false;
105
+ fallbackAt = 0;
106
+ inflight = null;
107
+ effective = null;
108
+ }
109
+ /** 共享预算 + 读完整个响应才算赢(captive portal 只回 header 就挂的场景选不出赢家)。 */
110
+ async function probeOnce(cands, probeFetch) {
111
+ const ac = new AbortController();
112
+ const timer = setTimeout(() => ac.abort(new Error('路由探测超时')), PROBE_BUDGET_MS);
113
+ try {
114
+ const ask = async (base) => {
115
+ const version = await probeFetch(base, ac.signal);
116
+ if (typeof version !== 'string' || version === '')
117
+ throw new Error(`${base} 探测无有效 version`);
118
+ return base;
119
+ };
120
+ return await Promise.any(cands.map(ask));
121
+ }
122
+ finally {
123
+ clearTimeout(timer);
124
+ ac.abort(); // 停止败者:其答案已不能改变任何决定
125
+ }
126
+ }
127
+ export async function decideNpmRoute(deps) {
128
+ if (inflight)
129
+ return inflight;
130
+ inflight = (async () => {
131
+ // 1) env 最高优先:直采、不探测、不落盘(去尾斜杠,与镜像/官方源等值判断对齐——评审 R1-3)
132
+ const override = process.env.DSHM_NPM_REGISTRY?.trim();
133
+ if (override) {
134
+ const normalized = override.replace(/\/+$/, '');
135
+ setEffective(normalized);
136
+ return normalized;
137
+ }
138
+ // 2) 成功探测的内存决策稳定复用(决策文件才是持久真相)
139
+ if (memBase !== null && !memIsFallback)
140
+ return memBase;
141
+ // 3) 回退态:TTL 内复用,过期重探(R2-3)
142
+ if (memBase !== null && memIsFallback) {
143
+ if (Date.now() - fallbackAt < (deps?.fallbackTtlMs ?? FALLBACK_TTL_MS))
144
+ return memBase;
145
+ memBase = null;
146
+ memIsFallback = false;
147
+ }
148
+ // 4) 决策文件(机器级事实,跨重启稳定)
149
+ const fromFile = readDecisionFile();
150
+ if (fromFile) {
151
+ memBase = fromFile;
152
+ memIsFallback = false;
153
+ setEffective(fromFile);
154
+ return fromFile;
155
+ }
156
+ // 5) probe-once;全败 → 内存回退 DEFAULT(不落盘,R1-8 决策 a)
157
+ const cands = candidates();
158
+ try {
159
+ const winner = await probeOnce(cands, deps?.probeFetch ?? defaultProbeFetch);
160
+ memBase = winner;
161
+ memIsFallback = false;
162
+ setEffective(winner);
163
+ await writeDecisionFile(winner, cands);
164
+ return winner;
165
+ }
166
+ catch {
167
+ memBase = DEFAULT_NPM_REGISTRY;
168
+ memIsFallback = true;
169
+ fallbackAt = Date.now();
170
+ setEffective(DEFAULT_NPM_REGISTRY);
171
+ return DEFAULT_NPM_REGISTRY;
172
+ }
173
+ })();
174
+ try {
175
+ return await inflight;
176
+ }
177
+ finally {
178
+ inflight = null;
179
+ }
180
+ }
181
+ /** 当前生效 npm registry(decideNpmRoute 语义名)。 */
182
+ export async function activeNpmRegistry(deps) {
183
+ return decideNpmRoute(deps);
184
+ }
185
+ /**
186
+ * npmmirror 按需同步(know-how 020 §2.2/§3.1);2xx=受理。任何失败返回 false、不抛;
187
+ * `DSHM_MIRROR_SYNC=0` 一键关闭(不发请求)。
188
+ */
189
+ export async function syncNpmmirrorPackage(pkg, deps) {
190
+ if (process.env.DSHM_MIRROR_SYNC === '0')
191
+ return false;
192
+ const fetcher = deps?.fetcher ?? fetchLimited;
193
+ const url = `https://registry-direct.npmmirror.com/${encodeURIComponent(pkg)}/sync?sync_upstream=true`;
194
+ try {
195
+ const res = await fetcher(url, { method: 'PUT', timeoutMs: deps?.timeoutMs ?? 8_000 });
196
+ return res.ok;
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ }
@@ -38,12 +38,14 @@ import { webProfileDir } from './env.js';
38
38
  import { isSafePkgName, resolvePluginDir } from './installed.js';
39
39
  import { setLivePluginDisabled } from './live-plugin.js';
40
40
  import { npmPackument } from './versions.js';
41
+ import { syncNpmmirrorPackage } from './npm-route.js';
42
+ import { clearAllLatestCache } from './latest-cache.js';
41
43
  import { assertNpmIntegrity, atomicWriteFile, readPnpmLockIntegrity, readPnpmLockOverrides, restoreSnapshots, snapshotFiles, verifySnapshots, } from './npm-integrity.js';
42
44
  import { isSafePluginTarget, makeDshRunner, removePatchedDependencyEntries, PNPM_OUTCOME_CODES, } from './dsh-cli.js';
43
45
  export const TX_HEAL_CODES = [
44
46
  'RANGE_ANCHOR_ACCEPTED', 'B1_MANIFEST_KEYS_RESTORED', 'B1_FROZEN_REVERIFY_FAILED',
45
47
  'B2_OVERRIDES_ALIGNED', 'B2_FROZEN_REVERIFY_OK', 'B2_LOCKFILE_REBUILT',
46
- 'B3_LAG_RETRY', 'B3_PACKUMENT_WARMED', 'BUILDS_ALLOWED',
48
+ 'B3_LAG_RETRY', 'B3_NPMMIRROR_SYNC', 'B3_PACKUMENT_WARMED', 'BUILDS_ALLOWED',
47
49
  'ROLLBACK_BYTES_RESTORED', 'ROLLBACK_CONVERGED_FROZEN', 'ROLLBACK_FALLBACK_REMOVED',
48
50
  'ROLLBACK_VERIFY_FAILED', 'LIVE_DISABLED', 'LIVE_REENABLED', 'LIVE_REENABLE_FAILED',
49
51
  'PATCH_ENTRIES_STRIPPED', 'PATCH_MAPPING_RESTORED',
@@ -461,6 +463,8 @@ function resolveDeps(deps) {
461
463
  return {
462
464
  profileDir,
463
465
  warmPackument: deps?.warmPackument,
466
+ syncNpmmirror: deps?.syncNpmmirror ?? syncNpmmirrorPackage,
467
+ clearLatestCache: deps?.clearLatestCache ?? clearAllLatestCache,
464
468
  setLiveDisabled: deps?.setLiveDisabled ?? setLivePluginDisabled,
465
469
  stripPatchedEntries: deps?.stripPatchedEntries ?? removePatchedDependencyEntries,
466
470
  retryDelaysMs: deps?.retryDelaysMs ?? [5_000, 15_000],
@@ -479,6 +483,15 @@ async function addWithLagRetry(req, spec, d, heal) {
479
483
  const out = await d.runner.add(spec, req.signal);
480
484
  if (out.class !== 'retryable-lag')
481
485
  return out;
486
+ // L2②(ADR-0012):NO_MATCHING_VERSION 且 registry 字段指名 npmmirror → 按需同步镜像后重试;
487
+ // sync 失败静默(false = 回到既有退避语义);kill switch(DSHM_MIRROR_SYNC=0)在缺省实现内。
488
+ if (out.code === PNPM_OUTCOME_CODES.NO_MATCHING_VERSION && out.registry === 'npmmirror') {
489
+ const synced = await d.syncNpmmirror(req.pkg);
490
+ if (synced) {
491
+ d.clearLatestCache();
492
+ heal.push({ code: 'B3_NPMMIRROR_SYNC', note: 'NO_MATCHING_VERSION 来自 npmmirror,已按需同步镜像后重试(+npmmirror sync)' });
493
+ }
494
+ }
482
495
  if (attempt >= d.retryDelaysMs.length)
483
496
  return out;
484
497
  const delay = d.retryDelaysMs[attempt] ?? 0;
@@ -124,7 +124,7 @@ export function parseRegistryAddress(raw) {
124
124
  }
125
125
  // ---------- 严格 v1 校验 ----------
126
126
  const TOP_LEVEL_KEYS = new Set(['version', 'plugins']);
127
- const ENTRY_KEYS = new Set(['id', 'name', 'description', 'category', 'tags', 'source', 'npm', 'github', 'homepage', 'icon', 'verified', 'alsoCategories']);
127
+ const ENTRY_KEYS = new Set(['id', 'name', 'description', 'category', 'tags', 'source', 'npm', 'github', 'homepage', 'icon', 'verified', 'alsoCategories', 'decoupled', 'audience']);
128
128
  /** 精确 semver 判定(与 versions.ts EXACT_VERSION_RE 同语义;接受 prerelease/build,拒绝 range/前缀)。 */
129
129
  const EXACT_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
130
130
  function semverParse(value) {
@@ -338,6 +338,25 @@ export function validateRegistry(raw) {
338
338
  alsoCategories = list;
339
339
  }
340
340
  }
341
+ // decoupled(自研元数据 v1.1):只声明 true;与 verified 互斥(GLOSSARY「解耦条目」)
342
+ let entryDecoupled;
343
+ if (e.decoupled !== undefined) {
344
+ if (e.decoupled !== true)
345
+ errors.push(`${where}.decoupled: 只允许 true 或缺省`);
346
+ else
347
+ entryDecoupled = true;
348
+ }
349
+ // audience:'public' 为缺省不产生键;'team' 值集预留但本期不接受
350
+ let entryAudience;
351
+ if (e.audience !== undefined) {
352
+ if (e.audience === 'internal')
353
+ entryAudience = 'internal';
354
+ else if (e.audience !== 'public')
355
+ errors.push(`${where}.audience: 只允许 public/internal`);
356
+ }
357
+ if (entryDecoupled === true && verified !== undefined) {
358
+ errors.push(`${where}.decoupled 与 verified 互斥:解耦条目不携带实测版本数组(GLOSSARY「解耦条目」)`);
359
+ }
341
360
  plugins.push({
342
361
  id,
343
362
  name,
@@ -351,6 +370,8 @@ export function validateRegistry(raw) {
351
370
  ...(entryIcon !== undefined ? { icon: entryIcon } : {}),
352
371
  ...(verified !== undefined ? { verified } : {}),
353
372
  ...(alsoCategories !== undefined ? { alsoCategories } : {}),
373
+ ...(entryDecoupled === true ? { decoupled: true } : {}),
374
+ ...(entryAudience !== undefined ? { audience: entryAudience } : {}),
354
375
  });
355
376
  });
356
377
  return { ok: errors.length === 0, errors, registry: errors.length === 0 ? { version: 1, plugins } : null };
@@ -27,6 +27,7 @@ import { readFile } from 'node:fs/promises';
27
27
  import { join } from 'node:path';
28
28
  import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
29
29
  import { fetchJsonLimited } from './httpx.js';
30
+ import { DEFAULT_NPM_REGISTRY, activeNpmRegistry, onRouteSwitch } from './npm-route.js';
30
31
  /** pnpm 11.7 实机测得的策略窗口:24h(operation-l5fXEd 的 cutoff = 检查时刻 − 1440min)。 */
31
32
  export const DEFAULT_MINIMUM_RELEASE_AGE_MIN = 1440;
32
33
  function scalarNumber(value) {
@@ -147,20 +148,42 @@ export function describeReleaseAgeFailure(input) {
147
148
  lines.push(`预计 ${fmtLocal(maxDeadline)} 后可重试;等待期内也可由操作员把 pnpm-workspace.yaml 中对应的独立精确排除条目改为包名级或复合条目以解除拦截`);
148
149
  return { message: lines.join('\n'), violations, targetViolating: targetViol !== undefined };
149
150
  }
150
- /** registry packument 的 time 映射(与 npmPackument 同源;失败返回 null,不抛)。 */
151
- export async function npmPackumentTimes(pkg, timeoutMs = 20_000, signal) {
151
+ async function fetchPackumentTimes(base, pkg, timeoutMs, signal, fetcher) {
152
+ const data = await fetcher(`${base}/${encodeURIComponent(pkg)}`, {
153
+ timeoutMs,
154
+ signal,
155
+ maxBytes: 8 * 1024 * 1024,
156
+ });
157
+ if (!data || typeof data !== 'object' || data.time === null || typeof data.time !== 'object')
158
+ return null;
159
+ const out = {};
160
+ for (const [k, v] of Object.entries(data.time)) {
161
+ if (typeof v === 'string')
162
+ out[k] = v;
163
+ }
164
+ return out;
165
+ }
166
+ /**
167
+ * registry packument 的 time 映射(与 npmPackument 同源;失败返回 null,不抛)。
168
+ * L3 权威链(ADR-0012):发布时刻是完整性敏感读——npmjs 权威源优先(首腿超时收紧
169
+ * `Math.min(timeoutMs, 5_000)`),失败降级生效源;两腿皆败 → null(fail-open 口径不变)。
170
+ */
171
+ export async function npmPackumentTimes(pkg, timeoutMs = 20_000, signal, deps) {
152
172
  if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
153
173
  return null;
174
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
175
+ const firstLegMs = Math.min(timeoutMs, 5_000);
154
176
  try {
155
- const data = await fetchJsonLimited(`https://registry.npmjs.org/${encodeURIComponent(pkg)}`, { timeoutMs, signal, maxBytes: 8 * 1024 * 1024 });
156
- if (!data || typeof data !== 'object' || data.time === null || typeof data.time !== 'object')
157
- return null;
158
- const out = {};
159
- for (const [k, v] of Object.entries(data.time)) {
160
- if (typeof v === 'string')
161
- out[k] = v;
162
- }
163
- return out;
177
+ return await fetchPackumentTimes(DEFAULT_NPM_REGISTRY, pkg, firstLegMs, signal, fetcher);
178
+ }
179
+ catch {
180
+ // 权威源不可达 → 生效源降级
181
+ }
182
+ const alt = await activeNpmRegistry();
183
+ if (alt === DEFAULT_NPM_REGISTRY)
184
+ return null;
185
+ try {
186
+ return await fetchPackumentTimes(alt, pkg, timeoutMs, signal, fetcher);
164
187
  }
165
188
  catch {
166
189
  return null;
@@ -179,15 +202,20 @@ export async function readWorkspacePolicy(profileDir) {
179
202
  }
180
203
  const packumentCache = new Map();
181
204
  const PACKUMENT_CACHE_TTL_MS = 5 * 60_000;
205
+ /** L1(ADR-0012):npm 生效源切换 → 作废 packument 缓存(镜像的答案不是官方源的答案)。 */
206
+ export function clearPackumentCache() {
207
+ packumentCache.clear();
208
+ }
209
+ onRouteSwitch(() => clearPackumentCache());
182
210
  /**
183
211
  * 带进程内缓存的 packument time 探测(TTL 5min):预检与登记(exclude-governance)
184
212
  * 共用——同一操作里预检刚拉过的 packument 不二次拉取(8MiB 级全量)。
185
213
  */
186
- export async function cachedPackumentTimes(pkg, timeoutMs = 12_000, signal) {
214
+ export async function cachedPackumentTimes(pkg, timeoutMs = 12_000, signal, deps) {
187
215
  const hit = packumentCache.get(pkg);
188
216
  if (hit && Date.now() - hit.at < PACKUMENT_CACHE_TTL_MS)
189
217
  return hit.times;
190
- const times = await npmPackumentTimes(pkg, timeoutMs, signal);
218
+ const times = await npmPackumentTimes(pkg, timeoutMs, signal, deps);
191
219
  packumentCache.set(pkg, { at: Date.now(), times });
192
220
  return times;
193
221
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { gt } from 'semver';
8
8
  import { fetchJsonLimited, HttpError } from './httpx.js';
9
+ import { DEFAULT_NPM_REGISTRY, NPM_MIRROR, activeNpmRegistry, syncNpmmirrorPackage } from './npm-route.js';
9
10
  /** GitHub 匿名限额(60 次/小时/IP)用尽时返回可读提示(含重置等待分钟数),否则 null。 */
10
11
  function githubRateLimitMessage(err) {
11
12
  if (err instanceof HttpError && err.status === 403 && err.headers?.get('x-ratelimit-remaining') === '0') {
@@ -29,54 +30,123 @@ const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(
29
30
  export function isExactVersion(version) {
30
31
  return EXACT_VERSION_RE.test(version);
31
32
  }
32
- /** 读取该精确版本的 dist metadata(不使用 /latest endpoint);integrity 缺失由调用方拒绝安装。 */
33
- export async function npmVersion(pkg, version, timeoutMs = 20_000, signal, registry) {
33
+ function sleepAbortable(ms, signal) {
34
+ return new Promise((resolve) => {
35
+ const done = () => {
36
+ clearTimeout(t);
37
+ signal?.removeEventListener('abort', done);
38
+ resolve();
39
+ };
40
+ const t = setTimeout(done, ms);
41
+ signal?.addEventListener('abort', done, { once: true });
42
+ });
43
+ }
44
+ /**
45
+ * 精确版本元数据(兼容预检/integrity 锚定)。L2① 履约阶梯(ADR-0012):
46
+ * 显式 registry 参数 > 生效源 → 失败且生效源为镜像 → sync → 有界等待 10s → 同源重试一次
47
+ * → 仍败 → npmjs 兜底(sync+重试为尽力而为,npmjs 兜底是主安全网);
48
+ * 主源即 npmjs 时失败直接抛(权威源无镜像滞后可言)。
49
+ */
50
+ export async function npmVersion(pkg, version, timeoutMs = 20_000, signal, registry, deps) {
34
51
  if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
35
52
  throw new Error(`无效 npm 包名: ${pkg}`);
36
53
  if (!isExactVersion(version))
37
54
  throw new Error(`不是精确版本(拒绝 range/tag/前缀): ${version}`);
38
- const data = await fetchJsonLimited(`${registryBase(registry)}/${encodeURIComponent(pkg)}/${version}`, { timeoutMs, signal });
39
- const resolved = typeof data.version === 'string' ? data.version : '';
40
- if (!resolved)
41
- throw new Error(`npm 未返回版本: ${pkg}@${version}`);
42
- const peers = {};
43
- if (data.peerDependencies !== null && typeof data.peerDependencies === 'object') {
44
- for (const [name, range] of Object.entries(data.peerDependencies)) {
45
- if (typeof range === 'string')
46
- peers[name] = range;
55
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
56
+ const sync = deps?.syncNpmmirror ?? syncNpmmirrorPackage;
57
+ const wait = deps?.wait ?? sleepAbortable;
58
+ const leg = async (base) => {
59
+ const data = await fetcher(`${registryBase(base)}/${encodeURIComponent(pkg)}/${version}`, { timeoutMs, signal });
60
+ const resolved = typeof data.version === 'string' ? data.version : '';
61
+ if (!resolved)
62
+ throw new Error(`npm 未返回版本: ${pkg}@${version}`);
63
+ const peers = {};
64
+ if (data.peerDependencies !== null && typeof data.peerDependencies === 'object') {
65
+ for (const [name, range] of Object.entries(data.peerDependencies)) {
66
+ if (typeof range === 'string')
67
+ peers[name] = range;
68
+ }
47
69
  }
48
- }
49
- return {
50
- version: resolved,
51
- integrity: typeof data.dist?.integrity === 'string' ? data.dist.integrity : undefined,
52
- tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
53
- peers,
70
+ return {
71
+ version: resolved,
72
+ integrity: typeof data.dist?.integrity === 'string' ? data.dist.integrity : undefined,
73
+ tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
74
+ peers,
75
+ };
54
76
  };
77
+ const primary = registry?.trim() ? registry.trim() : await activeNpmRegistry();
78
+ try {
79
+ return await leg(primary);
80
+ }
81
+ catch (firstErr) {
82
+ // L2①(ADR-0012):仅 sync 真正受理(评审 R1-2:kill switch / 同步失败返回 false 时
83
+ // 不付 10s 等待成本、不发必 404 的同源重试——「风险与回退」承诺的行为回到现状)才重试镜像。
84
+ if (primary === NPM_MIRROR && (await sync(pkg))) {
85
+ await wait(10_000, signal);
86
+ try {
87
+ return await leg(primary);
88
+ }
89
+ catch {
90
+ // 同源重试仍败 → npmjs 兜底
91
+ }
92
+ }
93
+ if (primary !== DEFAULT_NPM_REGISTRY)
94
+ return leg(DEFAULT_NPM_REGISTRY);
95
+ throw firstErr;
96
+ }
55
97
  }
56
98
  /**
57
99
  * 拉取完整 packument(NO_MATCHING_VERSION 退避重试前的预热/校验原语)。
58
100
  * 返回该包已知的全部版本号;解析不出 versions 时返回空列表(不抛)。
101
+ * L1(ADR-0012/R1-6):registry 参数曾被忽略——现「显式参数 > 生效源」。
59
102
  */
60
- export async function npmPackument(pkg, timeoutMs = 20_000, signal, registry) {
103
+ export async function npmPackument(pkg, timeoutMs = 20_000, signal, registry, deps) {
61
104
  if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
62
105
  throw new Error(`无效 npm 包名: ${pkg}`);
63
- const data = await fetchJsonLimited(`https://registry.npmjs.org/${encodeURIComponent(pkg)}`, { timeoutMs, signal, maxBytes: 8 * 1024 * 1024 });
106
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
107
+ const base = registry?.trim() ? registry.trim() : await activeNpmRegistry();
108
+ const data = await fetcher(`${registryBase(base)}/${encodeURIComponent(pkg)}`, {
109
+ timeoutMs,
110
+ signal,
111
+ maxBytes: 8 * 1024 * 1024,
112
+ });
64
113
  const versions = data?.versions !== null && typeof data?.versions === 'object' ? Object.keys(data.versions) : [];
65
114
  return { versions };
66
115
  }
67
- export async function npmLatest(pkg, timeoutMs = 20_000, signal, registry) {
116
+ /**
117
+ * 升级探测(检测读)。L3 权威链(ADR-0012):新鲜度敏感——npmjs 权威源优先
118
+ * (首腿超时收紧 `Math.min(timeoutMs, 5_000)`),失败降级生效源(≠npmjs 时);
119
+ * 不设 sync 腿(两腿全败属网络型失败,sync 只救镜像滞后)。显式 registry 参数优先。
120
+ */
121
+ export async function npmLatest(pkg, timeoutMs = 20_000, signal, registry, deps) {
68
122
  // 允许 scoped 包名:@scope/name(isSafePkgName 同款字符集)
69
123
  if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
70
124
  throw new Error(`无效 npm 包名: ${pkg}`);
71
- const data = await fetchJsonLimited(`${registryBase(registry)}/${encodeURIComponent(pkg)}/latest`, { timeoutMs, signal });
72
- const version = typeof data.version === 'string' ? data.version : '';
73
- if (!version)
74
- throw new Error(`npm 未返回版本: ${pkg}`);
75
- return {
76
- version,
77
- integrity: typeof data.dist?.integrity === 'string' ? data.dist.integrity : undefined,
78
- tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
125
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
126
+ const leg = async (base, ms) => {
127
+ const data = await fetcher(`${registryBase(base)}/${encodeURIComponent(pkg)}/latest`, { timeoutMs: ms, signal });
128
+ const version = typeof data.version === 'string' ? data.version : '';
129
+ if (!version)
130
+ throw new Error(`npm 未返回版本: ${pkg}`);
131
+ return {
132
+ version,
133
+ integrity: typeof data.dist?.integrity === 'string' ? data.dist.integrity : undefined,
134
+ tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
135
+ };
79
136
  };
137
+ const explicit = registry?.trim();
138
+ if (explicit)
139
+ return leg(explicit, timeoutMs);
140
+ const firstLegMs = Math.min(timeoutMs, 5_000);
141
+ try {
142
+ return await leg(DEFAULT_NPM_REGISTRY, firstLegMs);
143
+ }
144
+ catch (firstErr) {
145
+ const alt = await activeNpmRegistry();
146
+ if (alt === DEFAULT_NPM_REGISTRY)
147
+ throw firstErr;
148
+ return leg(alt, timeoutMs);
149
+ }
80
150
  }
81
151
  export async function githubTagSha(repo, tag, timeoutMs = 20_000, signal, budget, apiBase = 'https://api.github.com') {
82
152
  // commits/{ref} 会自动解引用 annotated tag,返回的才是可用于 #sha 锁定的 commit
package/lib/tools.js CHANGED
@@ -78,7 +78,7 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
78
78
  const ensureService = deps.ensureService;
79
79
  ctx.tools.register(defineTool({
80
80
  name: 'dshm_search',
81
- description: 'Search the DSH plugin marketplace (dsh-m) and show clickable plugin cards. ALWAYS call this instead of web_search or bash when the user wants to find/recommend/browse DSH plugins (插件). Call EXACTLY ONCE per user message; extract a real keyword (主题, 搜索) rather than pasting the whole sentence. Dual catalog: ~22 hand-curated 精选 entries plus a 4,000+ community catalog — default searches all; pass source="community" or "primary" to zone in. Returns 10 cards by default; use offset (take nextOffset from the result) for more batches. After cards appear, reply with AT MOST one short sentence. Do not print install commands.',
81
+ description: 'Search the DSH plugin marketplace (dsh-m) and show clickable plugin cards. ALWAYS call this instead of web_search or bash when the user wants to find/recommend/browse DSH plugins (插件). Call EXACTLY ONCE per user message; extract a real keyword (主题, 搜索) rather than pasting the whole sentence. Dual catalog: ~22 hand-curated 精选 entries plus a 4,000+ community catalog — default searches all; pass source="community" or "primary" to zone in. Returns 10 cards by default; use offset (take nextOffset from the result) for more batches. Cards may carry audience="internal" (作者自用; do not proactively recommend to general users unless the user names one or asks about internal rollout) and decoupled=true (版本无关, survives DSH upgrades without per-release adaptation). After cards appear, reply with AT MOST one short sentence. Do not print install commands.',
82
82
  parameters: {
83
83
  query: { type: 'string', description: 'Main keyword, e.g. 主题 or 搜索. Optional; omit to browse.' },
84
84
  category: {
@@ -175,6 +175,8 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
175
175
  installedPkg: e.installedPkg,
176
176
  installedVersion: e.installedVersion,
177
177
  community: e.community === true,
178
+ ...(e.audience === 'internal' ? { audience: 'internal' } : {}),
179
+ ...(e.decoupled === true ? { decoupled: true } : {}),
178
180
  downloads: e.downloads ?? null,
179
181
  stars: e.stars ?? null,
180
182
  })),
@@ -496,7 +498,8 @@ function renderSearch(out) {
496
498
  const lines = out.items.map((it, i) => {
497
499
  const inst = it.installed ? `(已安装 v${it.installedVersion || '?'})` : '';
498
500
  const zone = it.community === true ? '[社区]' : '';
499
- return `${i + 1}. ${it.name} · ${it.id}${zone}${inst} · ${it.categoryLabel || it.category}`;
501
+ const own = it.audience === 'internal' ? '[作者自用]' : '';
502
+ return `${i + 1}. ${it.name} · ${it.id}${zone}${own}${inst} · ${it.categoryLabel || it.category}`;
500
503
  });
501
504
  // 翻页尾行(0.7.0 Task 5):nextOffset 为空 = 已到末尾
502
505
  const pager = typeof out.nextOffset === 'number'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.31",
3
+ "version": "0.9.33",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -84,6 +84,7 @@
84
84
  "dependencies": {
85
85
  "@deepseek-ai/dsh-atomic-write": "0.1.7-rc.2",
86
86
  "semver": "7.8.5",
87
+ "undici": "7.30.0",
87
88
  "yaml": "2.9.1"
88
89
  }
89
90
  }
package/registry.json CHANGED
@@ -111,10 +111,10 @@
111
111
  {
112
112
  "id": "dsh-quota-watch",
113
113
  "name": "DSH Quota Watch",
114
- "description": "GLM 与 Copilot 额度悬浮球:胶囊、详情面板与侧栏卡片,宿主解析凭据,适配 web 与 desktop。",
114
+ "description": "GLM 与 Copilot 额度悬浮球:胶囊、详情面板与侧栏卡片,宿主解析凭据,适配 web 与 desktop。版本无关,详见仓库。",
115
115
  "category": "self-dev",
116
116
  "tags": ["配额", "悬浮球", "GLM", "Copilot"],
117
- "verified": ["0.1.7-rc.2", "0.2.0-rc.2"],
117
+ "decoupled": true,
118
118
  "source": "npm",
119
119
  "npm": "@iasiv5/dsh-quota-watch",
120
120
  "github": "iasiv5/dsh-quota-watch",
@@ -123,10 +123,11 @@
123
123
  {
124
124
  "id": "dsh-surf",
125
125
  "name": "DSH Surf",
126
- "description": "网络冲浪入口:设置一级菜单一键打开自托管 Selkies/Chrome 远程桌面,视频流直连;需自建容器并挂认证反代。",
126
+ "description": "网络冲浪入口:设置一级菜单一键打开 Selkies/Chrome 远程桌面,视频流直连;需自建容器与认证反代。版本无关,详见仓库。",
127
127
  "category": "self-dev",
128
128
  "tags": ["浏览器", "远程桌面", "Selkies", "网络冲浪"],
129
- "verified": ["0.1.7-rc.2"],
129
+ "decoupled": true,
130
+ "audience": "internal",
130
131
  "source": "npm",
131
132
  "npm": "@iasiv5/dsh-surf",
132
133
  "github": "iasiv5/dsh-surf",
@@ -135,10 +136,11 @@
135
136
  {
136
137
  "id": "dsh-obmc-web",
137
138
  "name": "DSH OBMC Web",
138
- "description": "在 DSH 里打开 OpenBMC 控制台:同源代理走 SSH 隧道,设置面板一键开启,支持 IP 探测与隧道重定向;需 SSH 跳板可达 BMC。",
139
+ "description": "DSH 内打开 OpenBMC 控制台:同源 SSH 隧道代理,设置页一键开启,IP 探测与隧道重定向;需 SSH 跳板可达 BMC。版本无关。",
139
140
  "category": "self-dev",
140
141
  "tags": ["OpenBMC", "远程管理", "SSH 隧道", "KVM"],
141
- "verified": ["0.1.7-rc.1", "0.1.7-rc.2"],
142
+ "decoupled": true,
143
+ "audience": "internal",
142
144
  "source": "npm",
143
145
  "npm": "@iasiv5/dsh-obmc-web",
144
146
  "github": "iasiv5/dsh-obmc-web",
@@ -147,10 +149,11 @@
147
149
  {
148
150
  "id": "dsh-onetree-log",
149
151
  "name": "OneTree Log",
150
- "description": "OneTree/dreport 日志包上传解包,14 域清单与预览;可登记服务器本地路径;已适配 0.1.7-rc.2,详见仓库。",
152
+ "description": "OneTree/dreport 日志包上传解包,14 域清单与预览;可登记服务器本地路径;版本无关,详见仓库。",
151
153
  "category": "self-dev",
152
154
  "tags": ["日志", "OneTree", "诊断", "工具"],
153
- "verified": ["0.1.7-rc.2"],
155
+ "decoupled": true,
156
+ "audience": "internal",
154
157
  "source": "npm",
155
158
  "npm": "@inventec/dsh-onetree-log",
156
159
  "github": "iasiv5/dsh-onetree-log",