dsh-m 0.9.31 → 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 CHANGED
@@ -8,6 +8,16 @@ 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
+
11
21
  ### 0.9.31 变更:doctor 支持 desktop profile(CLI 例外开口 + farmChecked 语义修订,ADR-0011)
12
22
 
13
23
  - **动机**:Windows desktop 机实机报告证实 core 引擎本就 profileDir 参数化无 web 硬编码(desktop 物化布局判 hoisted、farm=0 属常态),但 CLI 全命令一刀切拒绝 `--profile desktop` 把只读体检连坐;desktop-only 机器无 flag 体检还会扫不存在的 web 目录得全 0 报告。
@@ -307,6 +317,16 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
307
317
 
308
318
  ## English
309
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
+
310
330
  ### Added in 0.9.31 — doctor desktop profile support (CLI exception + farmChecked semantics revision, ADR-0011)
311
331
 
312
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.
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/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));
@@ -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;
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.31",
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
  }