dsh-m 0.9.30 → 0.9.32
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 +39 -1
- package/README.md +1 -0
- package/docs/DESIGN.md +1 -1
- package/lib/cli.js +19 -7
- package/lib/core/doctor.js +2 -1
- package/lib/core/dsh-cli.js +13 -6
- package/lib/core/env.js +5 -0
- package/lib/core/httpx.js +75 -7
- package/lib/core/latest-cache.js +6 -0
- package/lib/core/npm-route.js +201 -0
- package/lib/core/profile-transaction.js +14 -1
- package/lib/core/release-age.js +41 -13
- package/lib/core/versions.js +98 -28
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,12 +8,31 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
|
|
|
8
8
|
|
|
9
9
|
## 中文
|
|
10
10
|
|
|
11
|
+
### 0.9.32 变更:npm registry 路由自适应——元数据预取换源/镜像自愈(ADR-0012)
|
|
12
|
+
|
|
13
|
+
- **动机**:2026-10-04 上海 Windows 桌面机实证——元数据预取写死直连 registry.npmjs.org,间歇性超时使升级在委派 pnpm 之前夭折(`fetch failed` / `The operation was aborted due to timeout`,且 `.plugin-manager/logs` 无对应操作日志),而 npmmirror 镜像早已同步目标版本;同期首尔腾讯云机 npmjs 直连良好。两台机器需要零配置各自可用。
|
|
14
|
+
- **读分类路由(L1/L3)**:检测读(`npmLatest` 升级探测 / `npmPackumentTimes` 发布时刻)恒以 npmjs 权威源优先、首腿超时收紧 ≤5s、失败降级生效源;履约读(`npmVersion` 精确版本元数据 / `npmPackument` 预热)走生效源优先。`npmPackument` 的 registry 参数此前被静默忽略,本版修复(显式参数开始生效,测试钉住)。
|
|
15
|
+
- **L0 传输层**:httpx 换 undici 自带 fetch + 自建 dispatcher(`EnvHttpProxyAgent` 显式交接代理解析)——元数据预取开始感知 `HTTP(S)_PROXY` / `npm_config_*` 代理,并规避宿主 undici 全局 dispatcher 污染(dsh-market net.ts #742 同族);`fetchLimited` 新增 PUT 方法(仅供 sync 原语);网络失败错误附 `via` 上下文,失败摘要仅在代理路径渲染「(经 <掩码代理>)」。
|
|
16
|
+
- **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` 一键关闭。
|
|
17
|
+
- **新模块 `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 两级缓存。
|
|
18
|
+
- **wire 测试缝隙迁移**:既有 mock `globalThis.fetch` 的测试缝隙迁至 `_setWireFetchForTests`(versions / npm-integrity / registry-check 三文件,断言与计数语义不变)。
|
|
19
|
+
- **测试**:净增 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`)随发版执行。
|
|
20
|
+
|
|
21
|
+
### 0.9.31 变更:doctor 支持 desktop profile(CLI 例外开口 + farmChecked 语义修订,ADR-0011)
|
|
22
|
+
|
|
23
|
+
- **动机**:Windows desktop 机实机报告证实 core 引擎本就 profileDir 参数化无 web 硬编码(desktop 物化布局判 hoisted、farm=0 属常态),但 CLI 全命令一刀切拒绝 `--profile desktop` 把只读体检连坐;desktop-only 机器无 flag 体检还会扫不存在的 web 目录得全 0 报告。
|
|
24
|
+
- **CLI 例外(仅 doctor)**:`dshm doctor --profile desktop` 允许并路由 `desktopProfileDir()`,输出首行加 `[web|desktop]` 标注;HELP 同步;**其余命令对 `--profile desktop` 的拒绝语义逐字不变**(回归钉子测试钉住),desktop 变更管理仍走官方 Desktop 插件页。
|
|
25
|
+
- **空目录提示**:目标 profile 目录不存在时提示「desktop-only 机器请加 --profile desktop」——判据为目录不存在(存在但为空的合法 profile 不误伤)。
|
|
26
|
+
- **宿主 method 零改动**:doctor case 本就 active-profile 无关,desktop 宿主免改生效(新增 desktop-kind 注入钉子测试);MCP 工具 `dshm_doctor` 维持 ADR-0010 决定 3 缓上;Windows Electron 宿主 runtimeVersion 可能仍降级(物化布局 farm=0,stale 无判定对象,实际影响为零,如实记录)。
|
|
27
|
+
- **farmChecked 语义修订**:「0=遍历空转」判定仅适用于存在符号链农场的形态;物化布局 0 为常态(ADR-0011)。
|
|
28
|
+
- **验证**:新增 8 例(desktop 路由/标注、他命令拒绝、非法值、空提示、正常不提示、显式 desktop 不出矛盾文案、显式 web 路由、method 钉子)+ 改写 1 例旧拒绝样本(净增 8,全量 1079→1087);全量 1087 pass / 0 fail / 0 skipped;typecheck 零错误。Windows 外机验收清单见实施计划(本机无法执行 Windows E2E)。
|
|
29
|
+
|
|
11
30
|
### 0.9.30 变更:修复 bin 符号链接静默 no-op(F2)+ 版本解析 realpath 第三源(F1)
|
|
12
31
|
|
|
13
32
|
- **根因同源**:pnpm 生态里两类关键入口都是**符号链接**——`node_modules/.bin/dshm`(指向 lib/cli.js)与全局 shim `PNPM_HOME/dsh`(指向真实 bin.js)——而两处代码都在 realpath 之前做了路径身份判定。
|
|
14
33
|
- **F2(自 v0.2.0 bin 入口引入以来潜在,本机 0.9.28 装机形态首次踩中并实证;🔴 bin 入口全命令静默 exit 0)**:cli.ts `invokedDirectly` 比较 `argv[1]`(链接路径)与 `import.meta.url`(node realpath 后的真实路径)永假,`dshm <任何命令>` 无输出直接退出。修复:比较前对 `argv[1]` 同样 realpath。新增 `tests/cli-bin-symlink.test.mjs`(经符号链接调用 --help 与 doctor --json 的复活验证,EPERM 环境照仓内先例 skip)。
|
|
15
34
|
- **F1(0.9.29 装机验收实证)**:dsh-version.ts `readLauncherPackageVersion` 从 shim 位置直接向上三级找 package.json,落在 pnpm home 目录树上空走——宿主内纯 FS 版本解析在本机拓扑(`node PNPM_HOME/dsh web`)不可得,doctor 的 stale 判定降级、ping chip 被迫吃 spawn 回退。修复:判定 entry 形态后先 realpath 再上溯(本机实测解析得 0.2.0-rc.2)。新增 dsh-version.test.mjs shim 形态用例。
|
|
16
|
-
- **验证**:专项 9/9(含两新用例);全量 1079 pass / 0 fail / 0 skipped;typecheck 零错误。装机后预期:`dshm doctor`(bin 入口)可用;宿主 method 通路 runtimeVersion=0.2.0-rc.2、stale 判定复活(装机时农场 dsh 链接 0.1.7-rc.2 为现成 stale 形态,heal
|
|
35
|
+
- **验证**:专项 9/9(含两新用例);全量 1079 pass / 0 fail / 0 skipped;typecheck 零错误。装机后预期:`dshm doctor`(bin 入口)可用;宿主 method 通路 runtimeVersion=0.2.0-rc.2、stale 判定复活(装机时农场 dsh 链接 0.1.7-rc.2 为现成 stale 形态,heal 后归零)。**装机实测(2026-10-04)**:预期逐项兑现——`.bin/dshm doctor` 经符号链接复活出报告;宿主 method `runtimeVersion=0.2.0-rc.2 / stale=1(dsh→0.1.7-rc.2 现场点名)`;同日按 know-how 014 heal 该链接并清理 20 项残留后,双通路终验 `farm 236 / dangling 0 / stale 0 / residue 0 / errors 0`。
|
|
17
36
|
|
|
18
37
|
### 0.9.29 变更:profile 体检 Doctor——`dshm doctor` 上线(只读:农场测活 / 残留物清点 / 账实一致)
|
|
19
38
|
|
|
@@ -298,6 +317,25 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
|
|
|
298
317
|
|
|
299
318
|
## English
|
|
300
319
|
|
|
320
|
+
### Added in 0.9.32 — npm registry route adaptation: metadata prefetch source switching / mirror self-heal (ADR-0012)
|
|
321
|
+
|
|
322
|
+
- **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.
|
|
323
|
+
- **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).
|
|
324
|
+
- **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.
|
|
325
|
+
- **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.
|
|
326
|
+
- **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.
|
|
327
|
+
- **Wire test seam migration**: the existing mock-`globalThis.fetch` seam moved to `_setWireFetchForTests` (versions / npm-integrity / registry-check; assertion and counting semantics unchanged).
|
|
328
|
+
- **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.
|
|
329
|
+
|
|
330
|
+
### Added in 0.9.31 — doctor desktop profile support (CLI exception + farmChecked semantics revision, ADR-0011)
|
|
331
|
+
|
|
332
|
+
- **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.
|
|
333
|
+
- **CLI exception (doctor only)**: `dshm doctor --profile desktop` is allowed and routed to `desktopProfileDir()`, with a `[web|desktop]` tag on the first output line; HELP synced; **every other command's rejection of `--profile desktop` stays byte-identical** (pinned by regression tests) — desktop mutation management remains with the official Desktop plugin page.
|
|
334
|
+
- **Missing-dir hint**: when the target profile dir does not exist, suggest `--profile desktop` (an existing-but-empty legit profile is not flagged).
|
|
335
|
+
- **Host method untouched**: the doctor case was already active-profile agnostic — desktop hosts get it for free (new desktop-kind injection pin test); the `dshm_doctor` MCP tool stays deferred per ADR-0010 decision 3; on Windows Electron hosts runtimeVersion may still degrade (materialized layout means farm=0, so stale has nothing to judge — zero practical impact, recorded honestly).
|
|
336
|
+
- **farmCount semantics revision**: "0 = walk short-circuited" applies only to shapes with a symlink farm; 0 is the norm for materialized layouts (ADR-0011).
|
|
337
|
+
- **Verification**: 8 new cases (desktop routing/tag, other-command rejection, invalid value, missing-dir hint, no-hint-on-normal, explicit-desktop no contradictory hint, explicit-web routing, method pin) + 1 rewritten legacy rejection sample (net +8, 1079→1087); full suite 1087 pass / 0 fail / 0 skipped; typecheck clean. The Windows on-machine acceptance checklist ships with the implementation plan (no Windows E2E possible on this machine).
|
|
338
|
+
|
|
301
339
|
### Fixed in 0.9.30 — bin symlink silent no-op (F2) + realpath third source for version resolution (F1)
|
|
302
340
|
|
|
303
341
|
- **Shared root cause**: two critical pnpm-ecosystem entry points are **symlinks** — `node_modules/.bin/dshm` (→ lib/cli.js) and the global shim `PNPM_HOME/dsh` (→ the real bin.js) — while both code sites made path-identity decisions before realpath.
|
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
|
@@ -175,7 +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
|
+
- 体检(Doctor,0.9.29;desktop 支持与 CLI 例外开口 0.9.31 / [ADR-0011](./adr/0011-doctor-desktop-profile.md)):`/dshm` method `doctor` + CLI `dshm doctor [--json] [--profile web|desktop]`(**不新增第 9 个 agent 工具**——工具描述是宿主 agent 常驻上下文,行为面代价大于收益)。纯 FS 只读(无进程/无网络/无写入,清单不可用照常工作),三项检查(农场测活/残留物清点/账实一致)+ 三级严重度 + unknown≠broken + 双市场信息级;runtimeVersion 仅 `readLauncherPackageVersion` 纯 FS 通路(CLI 下降级 unknown,禁 spawn 回退)。决策全记载见 [ADR-0010](./adr/0010-doctor-read-only-diagnostics.md)。
|
|
179
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 + 结构化状态。
|
|
180
180
|
- 实现顺序:**GUI 先行,CLI 收尾**(核心逻辑同一层,CLI 是薄封装)。
|
|
181
181
|
|
package/lib/cli.js
CHANGED
|
@@ -9,13 +9,13 @@ import { installFromRegistry, listInstalledWithMeta, listMarket, uninstallPlugin
|
|
|
9
9
|
import { togglePlugin as coreTogglePlugin } from './core/toggle.js';
|
|
10
10
|
import { runDoctor } from './core/doctor.js';
|
|
11
11
|
import { readLauncherPackageVersion } from './core/dsh-version.js';
|
|
12
|
-
import { webProfileDir } from './core/env.js';
|
|
12
|
+
import { webProfileDir, desktopProfileDir } from './core/env.js';
|
|
13
13
|
import { loadRegistry } from './core/registry.js';
|
|
14
14
|
import { COMMUNITY_CATEGORY_LABELS } from './core/community.js';
|
|
15
15
|
import { scheduleRestart } from './core/restart.js';
|
|
16
16
|
import { upgradeEffectLine } from './core/activation.js';
|
|
17
17
|
import { pathToFileURL } from 'node:url';
|
|
18
|
-
import { realpathSync } from 'node:fs';
|
|
18
|
+
import { realpathSync, existsSync } from 'node:fs';
|
|
19
19
|
/** 分类中文标签(0.7.0 Task 6;0.9.16 策展五桶):精选策展桶本地表 + 社区已知标签单一事实源;未知 slug 原样。 */
|
|
20
20
|
const CLI_CATEGORY_LABELS = {
|
|
21
21
|
essentials: '装机必备',
|
|
@@ -133,7 +133,7 @@ const HELP = `dshm — DSH Marketplace(DSH 插件市场:精选策展 + 社
|
|
|
133
133
|
dshm list 列出 web profile 已装插件(含市场标注/可升级)
|
|
134
134
|
dshm outdated 检查已装插件的最新版本
|
|
135
135
|
dshm registry 查看收录清单来源与条目
|
|
136
|
-
dshm doctor [--json]
|
|
136
|
+
dshm doctor [--json] [--profile web|desktop] profile 体检(只读:农场测活 / 残留物清点 / 账实一致;error 级发现 exit 1;--profile desktop 为 doctor 专属例外)
|
|
137
137
|
|
|
138
138
|
变更命令(必须 --yes):
|
|
139
139
|
dshm install --id <收录id> [--version 1.2.3] [--force] (--force:确认兼容风险后跳过预检拦截)
|
|
@@ -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));
|
|
@@ -178,7 +180,10 @@ async function runCliDispatch(argv, deps, io) {
|
|
|
178
180
|
// 目标恒为 web profile);--profile desktop / 其他非 web 值显式拒绝并指引官方入口,
|
|
179
181
|
// 绝不隐式回落 web(报告 §5.3「不能从不存在的 Desktop 模式推断」)。
|
|
180
182
|
const profileFlag = typeof flags.profile === 'string' ? flags.profile.trim() : '';
|
|
181
|
-
|
|
183
|
+
// 0.9.31(ADR-0011):doctor 是纯 FS 只读体检——CLI 上唯一例外允许 --profile desktop;
|
|
184
|
+
// 其余命令维持「CLI 恒 web profile」拒绝(desktop 的变更类管理仍走官方 Desktop 插件页)。
|
|
185
|
+
const doctorDesktop = cmd === 'doctor' && profileFlag === 'desktop';
|
|
186
|
+
if (profileFlag !== '' && profileFlag !== 'web' && !doctorDesktop) {
|
|
182
187
|
err(`错误:dshm CLI 仅作用于 web profile,不支持 --profile ${profileFlag}。`);
|
|
183
188
|
err('Desktop profile 的插件管理请使用官方 Desktop 的插件管理页(Settings → Plugins),或回到 DSH Web 端使用 dsh-m。');
|
|
184
189
|
return 1;
|
|
@@ -409,13 +414,16 @@ async function runCliDispatch(argv, deps, io) {
|
|
|
409
414
|
// 体检(ADR-0010):纯 FS 只读、不依赖 registry(清单不可用照常工作)。
|
|
410
415
|
// runtimeVersion 仅用 readLauncherPackageVersion 纯 FS 通路(CLI 进程下通常为 null
|
|
411
416
|
// → stale 判定降级 unknown,绝不 spawn);errors>0 → exit 1。
|
|
412
|
-
|
|
417
|
+
// 0.9.31(ADR-0011):--profile desktop 例外开口(只读,不与「desktop 管理走官方页」冲突)。
|
|
418
|
+
const profileName = profileFlag === 'desktop' ? 'desktop' : 'web';
|
|
419
|
+
const targetDir = profileName === 'desktop' ? desktopProfileDir() : webProfileDir();
|
|
420
|
+
const report = await runDoctor(targetDir, readLauncherPackageVersion());
|
|
413
421
|
if (flags.json === true) {
|
|
414
422
|
out(JSON.stringify(report, null, 2));
|
|
415
423
|
return report.summary.errors > 0 ? 1 : 0;
|
|
416
424
|
}
|
|
417
425
|
const s = report.summary;
|
|
418
|
-
out(`profile 体检 · ${report.profileDir}`);
|
|
426
|
+
out(`profile 体检 · [${profileName}] · ${report.profileDir}`);
|
|
419
427
|
out(`布局 ${report.layout} · 运行时 ${report.runtimeVersion ?? '未解析(stale 判定降级 unknown,绝不 spawn)'} · 扫描于 ${report.scannedAt}`);
|
|
420
428
|
out(`农场 ${s.farmChecked}(悬空 ${s.farmDangling} · 指旧 ${s.farmStale}${report.runtimeVersion === null ? '(降级:未判定)' : ''})` +
|
|
421
429
|
` · 残留 ${s.residueCount} · 账实 ${s.accountChecked}(不一致 ${s.accountMismatched}) · errors ${s.errors} · warnings ${s.warnings}`);
|
|
@@ -455,6 +463,10 @@ async function runCliDispatch(argv, deps, io) {
|
|
|
455
463
|
}
|
|
456
464
|
for (const u of s.unknowns)
|
|
457
465
|
out(`ℹ️ ${u}`);
|
|
466
|
+
if (!existsSync(targetDir)) {
|
|
467
|
+
// 第二子句只对默认/显式 web 目标出现——显式 --profile desktop 时再建议加 desktop 自相矛盾(评审 G5.2)
|
|
468
|
+
out(`ℹ️ 目标 profile 目录不存在(${targetDir})${profileName === 'web' ? ';desktop-only 机器请加 --profile desktop' : ''}。`);
|
|
469
|
+
}
|
|
458
470
|
if (report.dualMarket)
|
|
459
471
|
out(`ℹ️ 双市场并存:${report.dualMarket.join(' + ')} 同时在装(写侧互不知晓,排查时留意)。`);
|
|
460
472
|
if (errs.length === 0)
|
package/lib/core/doctor.js
CHANGED
|
@@ -90,7 +90,8 @@ async function readTargetPkgVersion(targetDir) {
|
|
|
90
90
|
*
|
|
91
91
|
* - 遍历规则(评审 R1.2 定案):从 profileDir 逐级向上至 DSH_HOME 边界,扫描每级
|
|
92
92
|
* `node_modules/@deepseek-ai`——本机实况农场在 profile **父目录**的共享 hoisted 店,
|
|
93
|
-
* 不得以「工作区根」为限(否则 0
|
|
93
|
+
* 不得以「工作区根」为限(否则 0 命中空转——该「farmChecked=0 回炉」判定仅适用于
|
|
94
|
+
* 存在符号链农场的形态;物化布局(典型 desktop)0 为常态,ADR-0011)。
|
|
94
95
|
* - stale 判定只对 dsh 伞包生效:lockstep 店内各核心包(cordis 4.x、dsh-tools 0.1.x…)
|
|
95
96
|
* 与运行时版本**不在同一命名空间**,逐包比较会把 cordis@4.0.1 误标 stale——误报纪律
|
|
96
97
|
* (健康档误报代价 > 漏报)禁止;其余包 targetVersion 仅作信息呈现。stale-target 只进
|
package/lib/core/dsh-cli.js
CHANGED
|
@@ -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/env.js
CHANGED
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
export const WEB_PROFILE = 'web';
|
|
5
|
+
export const DESKTOP_PROFILE = 'desktop';
|
|
5
6
|
export function dshHome() {
|
|
6
7
|
return process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
7
8
|
}
|
|
8
9
|
export function webProfileDir() {
|
|
9
10
|
return join(dshHome(), 'profiles', WEB_PROFILE);
|
|
10
11
|
}
|
|
12
|
+
/** 0.9.31(ADR-0011):doctor 只读体检的 desktop 目录解析(其余 CLI 命令仍恒 web)。 */
|
|
13
|
+
export function desktopProfileDir() {
|
|
14
|
+
return join(dshHome(), 'profiles', DESKTOP_PROFILE);
|
|
15
|
+
}
|
|
11
16
|
/**
|
|
12
17
|
* 缓存根(0.9.0 双 profile,ADR-0005):web 恒走旧路径(DSHM_CACHE_DIR 或
|
|
13
18
|
* `$DSH_HOME/dshm/cache`)——不迁移不清空;非 web profile 加同名段
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 = {}) {
|
package/lib/core/latest-cache.js
CHANGED
|
@@ -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;
|
package/lib/core/release-age.js
CHANGED
|
@@ -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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
}
|
package/lib/core/versions.js
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-m",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.32",
|
|
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
|
}
|