dsh-m 0.9.83 → 0.9.86

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.86 性能:packument 访问器——registry 元数据属主模块,登记网络腿出锁(钢人 C2 / ADR-0019)
12
+
13
+ - **问题(钢人报告 C2 节)**:「registry 元数据」没有 owning module——登记(Register)只为读一个发布时戳,却在官方文件锁内发起 8MiB 级全量 packument 网络取(npmjs 首腿 5s 超时 + 生效源备腿至 12s),镜像机器装机的白等直接计入关键路径;B3 预热再经无缓存通道把同一全量取付第二次;三 adapter 各自取、各自缓存(或没有)。
14
+ - **修复(ADR-0019)**:新属主模块 `src/core/packument.ts`——一次取回派生视图包 `{times, versions, distTags, readme, repo}`,按 (pkg, 生效源) 键缓存(TTL 5min / single-flight / cap=4 / 失败与 regex-invalid 负缓存同 TTL / onRouteSwitch 作废 + 生效源入键双保险)。登记 times **出锁**:发布时戳是不可变历史事实,锁外预取零 TOCTOU,锁体回归纯 fs(锁前存在性预读保「文件缺席零探测」钉子);预检/登记共享 single-flight;B3 预热改 `warmPackumentFor`(bypass 缓存 + 生效源单腿真发暖 CDN + 回填);readme 兜底腿同款单腿带缓存面(终末错误透传保全)。latest 小端点与 ADR-0006 制度不并入;四函数 + `extractGithubRepo` 迁入属主、versions.ts 原名 re-export(import 位零改动)。
15
+ - **验证**:1462 项测试全绿(访问器 18 例新增:两腿超时收紧断言/single-flight 哨兵/负缓存/cap/路由作废/warm 单腿 bypass+回填/Active 面负缓存视 miss/readme 阶梯三态含 URL host 锚/锁内零网络时序断言)、typecheck 零错误;真机冒烟:访问器冷 143–269ms / 热 0ms / warm bypass 真发 29ms,登记真实 wire(临时 profile + 真实 young 版本)applied:true 111ms + 幂等 up-to-date 3ms;实施计划经独立评审三轮(13 条)+ 执行评审两轮(4 条建议全收口)。
16
+
17
+ ### 0.9.85 优化:版本号迁「关于」链接行尾 + 头部自升级入本地操作泵(两则真机反馈)
18
+
19
+ - **① 版本号迁位(主人定稿)**:0.9.84 把插件版本号挂在「关于」标题行 sub(弱化灰字),真机对照后主人拍板迁至**链接行尾**(GitHub · 问题反馈 · npm · v0.9.85)——与外向元数据同组更协调,标题行回归纯净。缺失(ping 失败 → null)时整段不渲染,不留悬空分隔点;新增 `.dshm-links-ver` 弱化样式(label-secondary + tabular-nums)。不放升级入口的裁决不变。
20
+ - **② 头部自升级漏记根治(主人真机反馈:⬆ 升级在操作日志无记录)**:根因是渠道两不沾——host-api `self-upgrade` 显式 `channel: 'panel'`(ADR-0018「面板通道零外部日志」不变量,防漏标落 external),而头部 chip 的 `doUpgrade` 直接 `api("self-upgrade")` 绕过本地操作泵,两头都不记。修复:chip 接线 `ops`(面板操作账本)并入泵 `ops.runOp("upgrade", "dsh-m", …)`,执行器回传 `opMeta {from,to}` 落账 → 已结束列表渲染「升级 dsh-m vX→vY」;SSR/探针无 `ops` 时回退直连 api。`stillApplies` 语义安全:dsh-m 恒已装 → 恒 applies,不触发 superseded。全家族盘点:安装/升级/卸载/开关/清单源/社区开关/「全部更新」(逐条 runOp 入队)均无漏记,自升级是唯一缺口。
21
+ - **验证**:1443 项测试全绿(0.9.84 基线 1440 + 新增源锚:about-version 迁位改写与 render-smoke 自升级入泵)、typecheck 零错误;F1 族就绪门连续三跑全量稳定(曾现一次 657/42 异常未再复现,归因共享机瞬时载荷,已留日志)。
22
+
23
+ ### 0.9.84 优化:设置页「关于」标题行展示插件版本号
24
+
25
+ - **问题(主人真机反馈)**:0.9.83 起窄容器下头部版本 chip 常态隐藏(共识 Q7——手机头部宽度让位给还原/关闭),插件自身版本号一度无处可查(设置页只有社区清单的「目录版本」)。
26
+ - **方案(零视觉噪声)**:设置页「关于」卡标题行右侧既有 sub 槽(`.dshm-section-sub` 弱化灰字)挂裸版本号 `v{version}`——零新增行/块/CSS/i18n;`pluginVersion`(ping 单一事实源:挂载/回前台/重启确认后重取)以 prop 接入 SettingsTab,缺失(ping 失败 → null)时槽位自然为空(Section 兼容逻辑天然处理,与头部 chip「失败不留占位」同语义);不放升级入口——头部 ⬆ + 市场卡片/CLI 所有权不变(尊重「dsh-m 自身卡已移除」裁决)。
27
+ - **验证**:1442 项测试全绿(0.9.83 基线 1440 + 新增源锚 2 组于 client-about-version.test.mjs)、typecheck 零错误。
28
+
11
29
  ### 0.9.83 功能:手机窄屏自适应——面板头部两行重排 + 窄视口默认全屏
12
30
 
13
31
  - **问题(主人手机实拍)**:`.dshm-head` 是单行 no-wrap flex,最小内容宽 ~440-470px,手机面板内容宽仅 330-365px → 行尾溢出被 `.dshm-panel` overflow:hidden 裁掉:还原/关闭按钮组不可见不可点(关面板只剩遮罩点击)、版本 chip 拦腰截断、标题逐词换行、seg 键逐字竖排。移动端问题第三次以「用户手机截图反馈」方式暴露(0.9.66 触屏滑动方向、0.9.67 字号失控之后)。
@@ -591,6 +609,24 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
591
609
 
592
610
  ## English
593
611
 
612
+ ### Performance in 0.9.86 — packument accessor: registry metadata gets an owning module, register's network leg leaves the lock (steelman C2 / ADR-0019)
613
+
614
+ - **Problem (steelman report, card C2)**: "registry metadata" had no owning module - Register fetched an 8MiB-scale full packument INSIDE the official file lock just to read one publish timestamp (npmjs first leg 5s timeout + effective-source leg up to 12s), putting mirror machines' white-wait directly on the install critical path; the B3 prewarm paid the same full fetch a second time through an uncached channel; three adapters each fetched, interpreted and cached (or not) independently.
615
+ - **Fix (ADR-0019)**: new owning module `src/core/packument.ts` - one fetch derives a view bundle `{times, versions, distTags, readme, repo}`, cached under (pkg, effective registry) keys (TTL 5min / single-flight / cap=4 / failures and regex-invalids negative-cached for the same TTL / onRouteSwitch invalidation + registry-in-key double insurance). Register's timestamp fetch **leaves the lock**: publish timestamps are immutable historical facts (zero TOCTOU), the lock body is pure fs again (a cheap pre-lock existence read preserves the "local check first" pin). Precheck and register share single-flight; B3 prewarm uses `warmPackumentFor` (cache-bypassing + effective-source single-leg real request to warm the CDN + backfill); the readme fallback leg shares the same single-leg cached face (final-leg error passthrough preserved). The latest small endpoint and ADR-0006 stay out of scope; four functions + `extractGithubRepo` moved into the owner, versions.ts re-exports under the original names (zero import-site changes).
616
+ - **Verification**: 1462 tests green (18 accessor cases added: two-leg timeout-tightening assertions / single-flight sentinel / negative cache / cap / route invalidation / warm single-leg bypass+backfill / Active-face negative-cache-as-miss / readme ladder three states with URL host anchors / in-lock zero-network timing assertion), typecheck clean; live smoke: accessor cold 143-269ms / warm 0ms / warm bypass real request 29ms, register real-wire (temp profile + genuinely young version) applied:true in 111ms + idempotent up-to-date in 3ms; the plan closed over three independent review rounds (13 findings) + two execution review rounds (4 suggestions).
617
+
618
+ ### 0.9.85 Enhancement — version moves to the About links row + head self-upgrade enters the local ops pump (two real-device reports)
619
+
620
+ - **① Version relocation (owner's call)**: 0.9.84 hung the plugin version on the "About" title-row sub (muted small text); after on-device comparison the owner decided to move it to the **links row tail** (GitHub · Issues · npm · v0.9.85) — grouped with the outward metadata, the title row stays clean. Missing version (ping failure → null) renders nothing, no dangling separator; new `.dshm-links-ver` muted style (label-secondary + tabular-nums). The no-upgrade-entry ruling stands.
621
+ - **② Head self-upgrade log gap cured (owner's real-device report: ⬆ upgrades were absent from the operations log)**: the root cause fell between two ledgers — host-api `self-upgrade` explicitly passes `channel: 'panel'` (ADR-0018 invariant "panel channel writes zero external records", guarding against mislabelled external rows), while the head chip's `doUpgrade` called `api("self-upgrade")` directly, bypassing the local ops pump — so neither recorded it. Fix: the chip receives `ops` (the panel's ledger) and goes through `ops.runOp("upgrade", "dsh-m", …)`; the executor returns `opMeta {from,to}` so the ended list renders "升级 dsh-m vX→vY"; SSR/probes without `ops` fall back to the direct api call. `stillApplies` is semantically safe: dsh-m is always installed → always applies, no superseded. Family audit: install/upgrade/uninstall/toggle/registry-source/community-toggle and "Update all" (per-item runOp enqueue) all record correctly — self-upgrade was the only gap.
622
+ - **Verification**: 1443 tests green (0.9.84 baseline 1440 + new source anchors: about-version relocation rewrite and render-smoke self-upgrade pump), typecheck clean; the F1-family readiness gate held stable across three consecutive full runs (one anomalous 657/42 run never reproduced — attributed to transient load on this shared box, logs archived).
623
+
624
+ ### 0.9.84 Enhancement — plugin version shown on the Settings "About" title row
625
+
626
+ - **Problem (owner's real-device feedback)**: since 0.9.83 the head version chip stays hidden in its idle state on narrow containers (consensus Q7 — phone head width goes to the restore/close buttons), leaving the plugin's own version nowhere to check (the settings page only had the community catalog's 目录版本).
627
+ - **Fix (zero visual noise)**: the "About" card's title row reuses its existing `sub` slot (`.dshm-section-sub`, muted small text on the right) to show the bare `v{version}` — zero new rows/blocks/CSS/i18n; `pluginVersion` (ping single source of truth: mount / foreground-return / post-restart re-check) reaches SettingsTab as a prop, and a missing version (ping failure → null) leaves the slot naturally empty (Section's compat logic handles it — same "no placeholder on failure" semantics as the head chip); no upgrade entry here — the head ⬆ plus marketplace card/CLI keep that ownership (respecting the removed dsh-m self card ruling).
628
+ - **Verification**: 1442 tests green (0.9.83 baseline 1440 + 2 new source-anchor groups in client-about-version.test.mjs), typecheck clean.
629
+
594
630
  ### 0.9.83 Feature — mobile adaptive layout: two-row panel head + fullscreen-by-default on narrow viewports
595
631
 
596
632
  - **Problem (user phone screenshot)**: `.dshm-head` is a single-line no-wrap flex whose minimum content width is ~440-470px, while a phone panel offers only 330-365px — the row-end overflow gets clipped by `.dshm-panel`'s `overflow:hidden`: the restore/close button group becomes invisible and untappable (closing the panel is left to backdrop taps only), the version chip is cut mid-glyph, the title wraps word-by-word into a tower, and the seg tabs stack their CJK labels one character per line. This is the third mobile issue surfaced via "user phone screenshot" (after 0.9.66 touch-swipe direction and 0.9.67 font blow-up).
package/lib/client.js CHANGED
@@ -2913,6 +2913,7 @@ button.dshm-badge:hover{filter:brightness(.95)}
2913
2913
  .dshm-links{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:7px;padding-top:6px;border-top:1px solid var(--dsw-alias-border-l2,#e5e7eb);font-size:11px;color:var(--dsw-alias-label-caption,#6b7280)}
2914
2914
  .dshm-links-k,.dshm-links-sep{color:var(--dsw-alias-label-caption,#9ca3af)}
2915
2915
  .dshm-links a{color:var(--dsw-alias-state-business-primary,#4d6bfe);text-decoration:none;font-weight:500}
2916
+ .dshm-links-ver{font-weight:500;color:var(--dsw-alias-label-secondary,#4b5563);font-variant-numeric:tabular-nums}
2916
2917
  .dshm-links a:hover{text-decoration:underline}
2917
2918
  .dshm-prog{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:8px;font-size:12px;color:var(--dsw-alias-label-secondary,#4b5563)}
2918
2919
  .dshm-prog .bar{flex:1;height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-2,rgba(38,49,72,.08));overflow:hidden;min-width:80px}
@@ -4594,7 +4595,7 @@ function regSourceLabel(data) {
4594
4595
  function configStatusLabel(status) {
4595
4596
  return lookup(`settings.status.${status || "loading"}` || "settings.status.loading");
4596
4597
  }
4597
- function SettingsTab({ notify, onRegistryChanged, onForceMarket, opsRecords, onOpsClearFinished, onOpsRemove }) {
4598
+ function SettingsTab({ notify, onRegistryChanged, onForceMarket, opsRecords, onOpsClearFinished, onOpsRemove, version }) {
4598
4599
  const reg = useAsync((force) => api("registry", force ? { force: true } : {}), []);
4599
4600
  const cfgState = useAsync(() => api("registry-config"), []);
4600
4601
  const [busy, setBusy] = useState(false);
@@ -4798,7 +4799,10 @@ function SettingsTab({ notify, onRegistryChanged, onForceMarket, opsRecords, onO
4798
4799
  snap.warnings && snap.warnings.length ? h("div", { className: "dshm-note warn" }, `${lookup("settings.warnings")}\uFF1A${snap.warnings.join("\uFF1B")}`) : null,
4799
4800
  state && state.errors && state.errors.length ? h("div", { className: "dshm-err" }, `${lookup("settings.remotehint")}\uFF1A${state.errors.slice(0, 5).join("\uFF1B")}`) : null
4800
4801
  ),
4801
- // ③ 关于:当前定位 + 仓库/反馈链接(dsh-m 自身卡已按主人要求移除——升级走市场卡片/CLI,self-check/self-upgrade API 保留)
4802
+ // ③ 关于:当前定位 + 仓库/反馈链接(dsh-m 自身卡已按主人要求移除——升级走市场卡片/CLI,self-check/self-upgrade API 保留)。
4803
+ // 版本号挂链接行尾(0.9.85 主人定稿:0.9.84 曾挂标题 sub,真机对照后迁此——与 GitHub/npm 等
4804
+ // 外向元数据同组更协调);缺失(ping 失败 → null)时整段不渲染,不留悬空分隔点;
4805
+ // 不放升级入口——头部 ⬆ + 市场卡片/CLI 所有权不变。
4802
4806
  Section(
4803
4807
  lookup("settings.about"),
4804
4808
  h("div", { className: "dshm-hint" }, lookup("settings.about.text")),
@@ -4809,7 +4813,11 @@ function SettingsTab({ notify, onRegistryChanged, onForceMarket, opsRecords, onO
4809
4813
  h("span", { className: "dshm-links-sep" }, "\xB7"),
4810
4814
  h(ExtLink, { href: "https://github.com/iasiv5/dsh-m/issues" }, lookup("settings.about.issues")),
4811
4815
  h("span", { className: "dshm-links-sep" }, "\xB7"),
4812
- h(ExtLink, { href: "https://www.npmjs.com/package/dsh-m" }, lookup("settings.about.npm"))
4816
+ h(ExtLink, { href: "https://www.npmjs.com/package/dsh-m" }, lookup("settings.about.npm")),
4817
+ version ? [
4818
+ h("span", { className: "dshm-links-sep" }, "\xB7"),
4819
+ h("span", { className: "dshm-links-ver" }, `v${version}`)
4820
+ ] : null
4813
4821
  )
4814
4822
  ),
4815
4823
  // ④ 操作记录:恒通宽卡(第二批搬迁),进行中常驻 + 已结束滚动区,空态 hint 导览
@@ -5077,7 +5085,7 @@ var TABS = [
5077
5085
  ["installed", "tab.installed", "installed"],
5078
5086
  ["settings", "tab.settings", null]
5079
5087
  ];
5080
- function DshmVersionChip({ version, notify, initialCheck }) {
5088
+ function DshmVersionChip({ version, notify, initialCheck, ops }) {
5081
5089
  const [check, setCheck] = useState(
5082
5090
  () => initialCheck !== void 0 ? initialCheck : typeof window !== "undefined" && window.localStorage ? readSelfCheckCache2(window.localStorage, { version }) : null
5083
5091
  );
@@ -5101,7 +5109,10 @@ function DshmVersionChip({ version, notify, initialCheck }) {
5101
5109
  if (busy) return;
5102
5110
  setBusy(true);
5103
5111
  try {
5104
- const res = await api("self-upgrade");
5112
+ const res = await (ops ? ops.runOp("upgrade", "dsh-m", async () => {
5113
+ const r = await api("self-upgrade");
5114
+ return { ...r, opMeta: { from: version, to: r.version || "latest" } };
5115
+ }, { npm: "dsh-m" }) : api("self-upgrade"));
5105
5116
  clearSelfCheckCache2(typeof window !== "undefined" && window.localStorage ? window.localStorage : null);
5106
5117
  setCheck({ current: version, latest: null, outdated: false, ahead: false });
5107
5118
  notify({
@@ -5584,7 +5595,7 @@ function MarketPanel({ onClose }) {
5584
5595
  )
5585
5596
  ),
5586
5597
  h("span", { className: "dshm-spacer" }),
5587
- pluginVersion ? h(DshmVersionChip, { version: pluginVersion, notify }) : null,
5598
+ pluginVersion ? h(DshmVersionChip, { version: pluginVersion, notify, ops }) : null,
5588
5599
  profile && profile.kind !== "web" ? h("span", { className: "dshm-dshchip", title: lookup("profile.chipTitle", { name: profile.name }) }, profile.name) : null,
5589
5600
  // 窗口控制组(0.7.7):最大化/还原 + 关闭,连体设计(系统化窗口按钮)
5590
5601
  h(
@@ -5612,6 +5623,7 @@ function MarketPanel({ onClose }) {
5612
5623
  tab === "installed" ? h(InstalledTab, { notify, installed, updates, onMutation: refreshViews, ops }) : null,
5613
5624
  tab === "settings" ? h(SettingsTab, {
5614
5625
  notify,
5626
+ version: pluginVersion,
5615
5627
  onRegistryChanged,
5616
5628
  onForceMarket: () => marketReloadAll(true),
5617
5629
  opsRecords: opRecords,
@@ -231,16 +231,26 @@ export async function registerExclusion(profileDir, target, deps = {}) {
231
231
  });
232
232
  const file = join(profileDir, WS_FILE);
233
233
  try {
234
+ // 锁前廉价存在性预读(ADR-0019,评审 G1-1):文件缺席零探测直返 no-file——
235
+ // 「本地检查先行」契约(钉子测试 + 缺省绑定下测试离线可跑)双保。
236
+ try {
237
+ await doRead(file);
238
+ }
239
+ catch {
240
+ return { applied: false, reason: 'no-file' };
241
+ }
242
+ // times 出锁(ADR-0019):发布时戳是不可变历史事实,锁外预取零 TOCTOU——镜像机器
243
+ // 装机不再在官方文件锁内白等网络腿(最多 5s 首腿 + 12s 备腿);取数失败 → not-young
244
+ // (fail-open 口径不变,ADR-0009 判定分支逐字未动)。
245
+ const times = await (deps.packumentTimes ?? cachedPackumentTimes)(target.pkg, 12_000);
234
246
  return await withFileLock(join(profileDir, 'package.json'), async () => {
235
- // 本地检查先行:文件缺席直接 no-file(不发 registry 探测——缺省绑定下测试离线可跑)
236
247
  let yaml;
237
248
  try {
238
- yaml = await doRead(file);
249
+ yaml = await doRead(file); // 锁内重读续走:锁保护的是写,读的 TOCTOU 无害
239
250
  }
240
251
  catch {
241
252
  return { applied: false, reason: 'no-file' };
242
253
  }
243
- const times = await (deps.packumentTimes ?? cachedPackumentTimes)(target.pkg, 12_000);
244
254
  const now = deps.nowMs ?? Date.now();
245
255
  const publishedAt = times ? Date.parse(times[target.version] ?? '') : NaN;
246
256
  if (!Number.isFinite(publishedAt))
@@ -0,0 +1,362 @@
1
+ /**
2
+ * packument 访问器(ADR-0019):npm 全量 packument 取数的唯一属主模块(GLOSSARY 在案)。
3
+ * 一次取回按需派生视图(times/versions/distTags/readme/repo),按(pkg, 生效 registry)
4
+ * 记忆化共享给登记、预检与 B3 预热;latest 小端点探测与其缓存制度(ADR-0006)不在辖区。
5
+ *
6
+ * 取数双面:fetchDocThrowing(strict——两腿阶梯,npmjs 首腿 `min(timeoutMs,5s)` 收紧 →
7
+ * 生效源备腿,终末失败抛原始错误,供 readme 阶梯错误透传);fetchPackumentDoc(lenient——
8
+ * 同阶梯 catch → null,times 的 fail-open 口径)。发布时戳是不可变历史事实,TTL 5min 只防
9
+ * 内存滞留过期内容,不作废旧时戳判定;失败与 regex-invalid 的确定性 null 同按 TTL 负缓存
10
+ * (故障窗口内预检/登记不二次打网)。
11
+ *
12
+ * 腿策略(ADR-0012 消费端):times 走两腿(完整性敏感读,npmjs 权威优先);warm 与 readme
13
+ * 兜底腿走「生效源单腿」(warm 暖的正是 B3 重试要打的 CDN,两腿会让镜像机先付 npmjs 5s
14
+ * 且暖错对象——评审 G1-3/G2-2 裁决)。onRouteSwitch 作废 + 缓存键含生效源双保险;本模块是
15
+ * 唯一注册点(release-age 旧注册已随其本地缓存退役)。
16
+ */
17
+ import { fetchJsonLimited } from './httpx.js';
18
+ import { DEFAULT_NPM_REGISTRY, NPM_MIRROR, activeNpmRegistry, onRouteSwitch, syncNpmmirrorPackage } from './npm-route.js';
19
+ /** npm 包名形状(与既有四函数同一字符集,scoped 允许)。 */
20
+ const PKG_RE = /^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/;
21
+ /** registry base 归一(去尾斜杠;空/缺省 → 官方 npmjs)。 */
22
+ function registryBase(registry) {
23
+ const base = typeof registry === 'string' && registry.trim() !== '' ? registry.trim() : DEFAULT_NPM_REGISTRY;
24
+ return base.replace(/\/+$/, '');
25
+ }
26
+ /** 精确 semver:接受 prerelease/build metadata,拒绝 v 前缀、range、tag 与脏尾缀。 */
27
+ const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
28
+ export function isExactVersion(version) {
29
+ return EXACT_VERSION_RE.test(version);
30
+ }
31
+ // ---------- 取数层(strict / lenient 双面) ----------
32
+ /**
33
+ * strict 面:两腿阶梯取全量 packument,终末失败抛原始错误。
34
+ * L3 权威链(ADR-0012):npmjs 首腿超时收紧 `Math.min(timeoutMs, 5_000)`;生效源即 npmjs
35
+ * 时失败直接抛(权威源无备腿可降);两腿皆败抛终末腿错误(readme 阶梯的错误透传依赖此约定)。
36
+ */
37
+ export async function fetchDocThrowing(pkg, timeoutMs = 20_000, signal, deps) {
38
+ if (!PKG_RE.test(pkg))
39
+ throw new Error(`无效 npm 包名: ${pkg}`);
40
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
41
+ const firstLegMs = Math.min(timeoutMs, 5_000);
42
+ try {
43
+ return (await fetcher(`${registryBase(DEFAULT_NPM_REGISTRY)}/${encodeURIComponent(pkg)}`, {
44
+ timeoutMs: firstLegMs,
45
+ signal,
46
+ maxBytes: 8 * 1024 * 1024,
47
+ }));
48
+ }
49
+ catch (firstErr) {
50
+ const alt = await activeNpmRegistry();
51
+ if (alt === DEFAULT_NPM_REGISTRY)
52
+ throw firstErr;
53
+ return (await fetcher(`${registryBase(alt)}/${encodeURIComponent(pkg)}`, {
54
+ timeoutMs,
55
+ signal,
56
+ maxBytes: 8 * 1024 * 1024,
57
+ }));
58
+ }
59
+ }
60
+ /** lenient 面:同阶梯 catch → null(times 的 fail-open 口径;regex-invalid 亦 null)。 */
61
+ export async function fetchPackumentDoc(pkg, timeoutMs = 20_000, signal, deps) {
62
+ try {
63
+ return await fetchDocThrowing(pkg, timeoutMs, signal, deps);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /** 生效源单腿 strict 取数(warm 与 readme 兜底腿的取数原语;失败抛原始错误)。返回 doc 与取数所用 base——调用方回填键必须用同一 base(评审 R1-1:一次生效源判定贯穿取数与回填,杜绝路由切换窗口的键错配)。 */
70
+ async function fetchDocViaActiveThrowing(pkg, timeoutMs, signal, deps) {
71
+ if (!PKG_RE.test(pkg))
72
+ throw new Error(`无效 npm 包名: ${pkg}`);
73
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
74
+ const base = await activeNpmRegistry();
75
+ const doc = (await fetcher(`${registryBase(base)}/${encodeURIComponent(pkg)}`, {
76
+ timeoutMs,
77
+ signal,
78
+ maxBytes: 8 * 1024 * 1024,
79
+ }));
80
+ return { doc, base };
81
+ }
82
+ // ---------- 视图层 ----------
83
+ /** packument repository → "owner/repo"(仅 github.com;git+https / git@ / .git / #frag 归一;非 GitHub 返回空串)。 */
84
+ export function extractGithubRepo(v) {
85
+ const url = v && typeof v === 'object' && !Array.isArray(v) && typeof v.url === 'string'
86
+ ? v.url
87
+ : typeof v === 'string'
88
+ ? v
89
+ : '';
90
+ if (!url)
91
+ return '';
92
+ const m = /github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?(?:[/?#]|$)/i.exec(url);
93
+ return m ? `${m[1]}/${m[2]}` : '';
94
+ }
95
+ /** 全量 packument → 派生视图包(纯函数;畸形字段一律回落默认值,不抛)。 */
96
+ export function packumentViews(doc) {
97
+ const d = doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : {};
98
+ const times = {};
99
+ const t = d.time;
100
+ if (t && typeof t === 'object' && !Array.isArray(t)) {
101
+ for (const [k, v] of Object.entries(t)) {
102
+ if (typeof v === 'string' && v !== '')
103
+ times[k] = v;
104
+ }
105
+ }
106
+ const versions = d.versions && typeof d.versions === 'object' && !Array.isArray(d.versions) ? Object.keys(d.versions) : [];
107
+ const distTags = d['dist-tags'] && typeof d['dist-tags'] === 'object' && !Array.isArray(d['dist-tags'])
108
+ ? d['dist-tags']
109
+ : {};
110
+ const readme = typeof d.readme === 'string' && d.readme.trim() !== '' ? d.readme : '';
111
+ return { times, versions, distTags, readme, repo: extractGithubRepo(d.repository) };
112
+ }
113
+ const packumentCache = new Map();
114
+ const packumentInflight = new Map();
115
+ const PACKUMENT_TTL_MS = 5 * 60_000;
116
+ const PACKUMENT_CAP = 4;
117
+ function packumentKey(pkg, registry) {
118
+ return `${pkg}\n${registry}`;
119
+ }
120
+ function backfill(key, views) {
121
+ if (packumentCache.has(key))
122
+ packumentCache.delete(key); // 重写移尾:淘汰语义 = 最旧写入
123
+ packumentCache.set(key, { at: Date.now(), views });
124
+ if (packumentCache.size > PACKUMENT_CAP) {
125
+ const oldest = packumentCache.keys().next().value;
126
+ if (oldest !== undefined)
127
+ packumentCache.delete(oldest);
128
+ }
129
+ }
130
+ /** 路由作废(ADR-0012 L1:生效源切换 → 缓存整体作废);亦是测试隔离钩子。 */
131
+ export function clearPackumentAccessorCache() {
132
+ packumentCache.clear();
133
+ packumentInflight.clear();
134
+ }
135
+ onRouteSwitch(() => clearPackumentAccessorCache());
136
+ /** lenient 带缓存取数(times/全视图共用;single-flight;失败/非法 → null 负缓存)。 */
137
+ async function entryLenient(pkg, timeoutMs, signal, deps) {
138
+ const key = packumentKey(pkg, await activeNpmRegistry());
139
+ const hit = packumentCache.get(key);
140
+ if (hit && Date.now() - hit.at < PACKUMENT_TTL_MS)
141
+ return hit.views;
142
+ const existing = packumentInflight.get(key);
143
+ if (existing)
144
+ return existing;
145
+ const p = (async () => {
146
+ let views = null;
147
+ try {
148
+ views = packumentViews(await fetchDocThrowing(pkg, timeoutMs, signal, deps));
149
+ }
150
+ catch {
151
+ views = null;
152
+ }
153
+ backfill(key, views);
154
+ return views;
155
+ })().finally(() => {
156
+ packumentInflight.delete(key);
157
+ });
158
+ packumentInflight.set(key, p);
159
+ return p;
160
+ }
161
+ /** 带缓存发布时戳表(预检/登记消费;语义 = 旧 cachedPackumentTimes:失败 null 不抛)。 */
162
+ export async function packumentTimes(pkg, timeoutMs = 12_000, signal, deps) {
163
+ const views = await entryLenient(pkg, timeoutMs, signal, deps);
164
+ return views ? views.times : null;
165
+ }
166
+ /** 带缓存全视图(npmPackument 隐式 registry 路径消费)。 */
167
+ export async function packumentDocViews(pkg, timeoutMs = 20_000, signal, deps) {
168
+ return entryLenient(pkg, timeoutMs, signal, deps);
169
+ }
170
+ /**
171
+ * 生效源单腿带缓存全视图(readme 隐式 registry 兜底腿专用,G2-2 方案 a):命中(且非
172
+ * 负缓存)直回;miss/负缓存 → 单腿 strict 重取(失败抛原始错误)+ 回填。负缓存条目仅
173
+ * lenient 面消费——strict 面视 null 为 miss,保终末错误透传(≈现状无缓存每次尝试)。
174
+ */
175
+ export async function packumentDocViewsActive(pkg, timeoutMs = 20_000, signal, deps) {
176
+ const key = packumentKey(pkg, await activeNpmRegistry());
177
+ const hit = packumentCache.get(key);
178
+ if (hit && Date.now() - hit.at < PACKUMENT_TTL_MS && hit.views !== null)
179
+ return hit.views;
180
+ const { doc, base } = await fetchDocViaActiveThrowing(pkg, timeoutMs, signal, deps);
181
+ const views = packumentViews(doc);
182
+ backfill(packumentKey(pkg, base), views);
183
+ return views;
184
+ }
185
+ /**
186
+ * B3 预热(G1-3 裁决):bypass 缓存读 + 生效源单腿直取 + 成功回填——预热的目的就是真发
187
+ * 请求暖 CDN(B3 重试要打的正是生效源),缓存命中≠预热;失败/abort 异常向上传播(调用方
188
+ * makeNpmWarmPackument 按 R4c 消化:abort rethrow、其余吞错)。
189
+ */
190
+ export async function warmPackumentFor(pkg, timeoutMs = 20_000, signal, deps) {
191
+ const { doc, base } = await fetchDocViaActiveThrowing(pkg, timeoutMs, signal, deps);
192
+ backfill(packumentKey(pkg, base), packumentViews(doc));
193
+ }
194
+ function sleepAbortable(ms, signal) {
195
+ return new Promise((resolve) => {
196
+ const done = () => {
197
+ clearTimeout(t);
198
+ signal?.removeEventListener('abort', done);
199
+ resolve();
200
+ };
201
+ const t = setTimeout(done, ms);
202
+ signal?.addEventListener('abort', done, { once: true });
203
+ });
204
+ }
205
+ /**
206
+ * 精确版本元数据(兼容预检/integrity 锚定)。L2① 履约阶梯(ADR-0012):
207
+ * 显式 registry 参数 > 生效源 → 失败且生效源为镜像 → sync → 有界等待 10s → 同源重试一次
208
+ * → 仍败 → npmjs 兜底(sync+重试为尽力而为,npmjs 兜底是主安全网);
209
+ * 主源即 npmjs 时失败直接抛(权威源无镜像滞后可言)。
210
+ */
211
+ export async function npmVersion(pkg, version, timeoutMs = 20_000, signal, registry, deps) {
212
+ if (!PKG_RE.test(pkg))
213
+ throw new Error(`无效 npm 包名: ${pkg}`);
214
+ if (!isExactVersion(version))
215
+ throw new Error(`不是精确版本(拒绝 range/tag/前缀): ${version}`);
216
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
217
+ const sync = deps?.syncNpmmirror ?? syncNpmmirrorPackage;
218
+ const wait = deps?.wait ?? sleepAbortable;
219
+ const leg = async (base) => {
220
+ const data = await fetcher(`${registryBase(base)}/${encodeURIComponent(pkg)}/${version}`, { timeoutMs, signal });
221
+ const resolved = typeof data.version === 'string' ? data.version : '';
222
+ if (!resolved)
223
+ throw new Error(`npm 未返回版本: ${pkg}@${version}`);
224
+ const peers = {};
225
+ if (data.peerDependencies !== null && typeof data.peerDependencies === 'object') {
226
+ for (const [name, range] of Object.entries(data.peerDependencies)) {
227
+ if (typeof range === 'string')
228
+ peers[name] = range;
229
+ }
230
+ }
231
+ return {
232
+ version: resolved,
233
+ integrity: typeof data.dist?.integrity === 'string' ? data.dist.integrity : undefined,
234
+ tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
235
+ peers,
236
+ };
237
+ };
238
+ const primary = registry?.trim() ? registry.trim() : await activeNpmRegistry();
239
+ try {
240
+ return await leg(primary);
241
+ }
242
+ catch (firstErr) {
243
+ // L2①(ADR-0012):仅 sync 真正受理才重试镜像(kill switch / 同步失败不付 10s 等待)。
244
+ if (primary === NPM_MIRROR && (await sync(pkg))) {
245
+ await wait(10_000, signal);
246
+ try {
247
+ return await leg(primary);
248
+ }
249
+ catch {
250
+ // 同源重试仍败 → npmjs 兜底
251
+ }
252
+ }
253
+ if (primary !== DEFAULT_NPM_REGISTRY)
254
+ return leg(DEFAULT_NPM_REGISTRY);
255
+ throw firstErr;
256
+ }
257
+ }
258
+ /**
259
+ * 升级探测(检测读)。L3 权威链(ADR-0012):新鲜度敏感——npmjs 权威源优先
260
+ * (首腿超时收紧 `Math.min(timeoutMs, 5_000)`),失败降级生效源(≠npmjs 时);
261
+ * 不设 sync 腿。显式 registry 参数优先。
262
+ */
263
+ export async function npmLatest(pkg, timeoutMs = 20_000, signal, registry, deps) {
264
+ if (!PKG_RE.test(pkg))
265
+ throw new Error(`无效 npm 包名: ${pkg}`);
266
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
267
+ const leg = async (base, ms) => {
268
+ const data = await fetcher(`${registryBase(base)}/${encodeURIComponent(pkg)}/latest`, { timeoutMs: ms, signal });
269
+ const version = typeof data.version === 'string' ? data.version : '';
270
+ if (!version)
271
+ throw new Error(`npm 未返回版本: ${pkg}`);
272
+ return {
273
+ version,
274
+ integrity: typeof data.dist?.integrity === 'string' ? data.dist.integrity : undefined,
275
+ tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
276
+ };
277
+ };
278
+ const explicit = registry?.trim();
279
+ if (explicit)
280
+ return leg(explicit, timeoutMs);
281
+ const firstLegMs = Math.min(timeoutMs, 5_000);
282
+ try {
283
+ return await leg(DEFAULT_NPM_REGISTRY, firstLegMs);
284
+ }
285
+ catch (firstErr) {
286
+ const alt = await activeNpmRegistry();
287
+ if (alt === DEFAULT_NPM_REGISTRY)
288
+ throw firstErr;
289
+ return leg(alt, timeoutMs);
290
+ }
291
+ }
292
+ /**
293
+ * 拉取完整 packument(NO_MATCHING_VERSION 退避重试前的预热/校验原语)。
294
+ * 返回该包已知的全部版本号;解析不出 versions 时返回空列表(不抛)。
295
+ * 隐式 registry → 访问器缓存(lenient 两腿面;该路径在 B3 改用 warmPackumentFor 后已无
296
+ * 生产消费方,仅测试触达——G3-2 备案);显式 registry 直取不走缓存。
297
+ */
298
+ export async function npmPackument(pkg, timeoutMs = 20_000, signal, registry, deps) {
299
+ if (!PKG_RE.test(pkg))
300
+ throw new Error(`无效 npm 包名: ${pkg}`);
301
+ const explicit = registry?.trim();
302
+ if (!explicit) {
303
+ const views = await packumentDocViews(pkg, timeoutMs, signal, deps?.fetchJsonLimited ? { fetchJsonLimited: deps.fetchJsonLimited } : undefined);
304
+ if (!views)
305
+ throw new Error(`packument 不可用: ${pkg}`);
306
+ return { versions: views.versions };
307
+ }
308
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
309
+ const data = await fetcher(`${registryBase(explicit)}/${encodeURIComponent(pkg)}`, {
310
+ timeoutMs,
311
+ signal,
312
+ maxBytes: 8 * 1024 * 1024,
313
+ });
314
+ const versions = data?.versions !== null && typeof data?.versions === 'object' ? Object.keys(data.versions) : [];
315
+ return { versions };
316
+ }
317
+ /**
318
+ * npm packument readme 兜底(0.9.45 U10b / 0.9.50 两腿阶梯):腿1 `/latest` 小端点优先
319
+ * (KB 级载荷,超时收紧 `min(timeoutMs,5s)`),readme 命中即短路;空或腿失败落腿2
320
+ * packument。任一腿成功即成功——readme 空渲染「没有 README」(含 latest 空白 readme +
321
+ * packument 失败的降级,不抛);两腿全败抛终末腿(packument)错误。两腿均在生效源。
322
+ * 0.9.86:腿2 隐式 registry 改走访问器「生效源单腿带缓存」面(G2-2 方案 a——腿策略与
323
+ * 现状单腿一致,不换两腿;共享缓存);显式 registry 直取不走缓存。
324
+ */
325
+ export async function npmPackumentReadme(pkg, timeoutMs = 20_000, signal, registry, deps) {
326
+ if (!PKG_RE.test(pkg))
327
+ throw new Error(`无效 npm 包名: ${pkg}`);
328
+ const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
329
+ const explicit = registry?.trim();
330
+ const base = explicit ? registryBase(explicit) : registryBase(await activeNpmRegistry());
331
+ const extractReadme = (data) => typeof data?.readme === 'string' && data.readme.trim() !== '' ? data.readme : '';
332
+ // 腿1:/latest 版本文档(小载荷;超时收紧同 npmLatest 首腿先例)
333
+ let latestOk = false;
334
+ let latestRepo = '';
335
+ try {
336
+ const latestDoc = await fetcher(`${base}/${encodeURIComponent(pkg)}/latest`, { timeoutMs: Math.min(timeoutMs, 5_000), signal, maxBytes: 8 * 1024 * 1024 });
337
+ latestOk = true;
338
+ latestRepo = extractGithubRepo(latestDoc?.repository);
339
+ const latestReadme = extractReadme(latestDoc);
340
+ if (latestReadme)
341
+ return { readme: latestReadme, repo: latestRepo };
342
+ }
343
+ catch {
344
+ // latest 腿失败 → 落 packument 腿(终末腿语义见下)
345
+ }
346
+ // 腿2:packument(终末腿)——隐式 registry 走访问器 Active 面(单腿带缓存 + 错误透传)
347
+ try {
348
+ if (explicit) {
349
+ const data = await fetcher(`${base}/${encodeURIComponent(pkg)}`, { timeoutMs, signal, maxBytes: 8 * 1024 * 1024 });
350
+ return { readme: extractReadme(data), repo: extractGithubRepo(data?.repository) };
351
+ }
352
+ const views = await packumentDocViewsActive(pkg, timeoutMs, signal, deps?.fetchJsonLimited ? { fetchJsonLimited: deps.fetchJsonLimited } : undefined);
353
+ return { readme: views.readme, repo: views.repo };
354
+ }
355
+ catch (packumentErr) {
356
+ if (latestOk) {
357
+ // latest 到达但 readme 空 + packument 失败 → 降级空 readme(0.9.50 语义,不抛)
358
+ return { readme: '', repo: latestRepo };
359
+ }
360
+ throw packumentErr;
361
+ }
362
+ }
@@ -37,7 +37,7 @@ import { join } from 'node:path';
37
37
  import { webProfileDir } from './env.js';
38
38
  import { isSafePkgName, resolvePluginDir } from './installed.js';
39
39
  import { setLivePluginDisabled } from './live-plugin.js';
40
- import { npmPackument } from './versions.js';
40
+ import { warmPackumentFor } from './packument.js';
41
41
  import { syncNpmmirrorPackage } from './npm-route.js';
42
42
  import { clearAllLatestCache } from './latest-cache.js';
43
43
  import { assertNpmIntegrity, atomicWriteFile, readPnpmLockIntegrity, readPnpmLockOverrides, restoreSnapshots, snapshotFiles, verifySnapshots, } from './npm-integrity.js';
@@ -65,7 +65,11 @@ export const TX_FAILURE_CODES = [
65
65
  * fetcher 参数仅测试注入;失败吞错。deps 未提供且调用方未绑定时,
66
66
  * 事务内 B3 跳过预热仅重试——绝不调用 undefined。
67
67
  */
68
- export function makeNpmWarmPackument(timeoutMs, fetcher = npmPackument) {
68
+ export function makeNpmWarmPackument(timeoutMs,
69
+ // 0.9.86(ADR-0019):缺省 fetcher 改 warmPackumentFor——bypass 缓存 + 生效源单腿真发请求
70
+ // (预热的目的就是暖 CDN)+ 成功回填访问器缓存;abort 由本包装按 R4c 消化(rethrow)、
71
+ // 其余失败吞错(预热尽力而为)不变。
72
+ fetcher = warmPackumentFor) {
69
73
  return async (pkg, signal) => {
70
74
  try {
71
75
  await fetcher(pkg, timeoutMs, signal);
@@ -26,8 +26,7 @@
26
26
  import { readFile } from 'node:fs/promises';
27
27
  import { join } from 'node:path';
28
28
  import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
29
- import { fetchJsonLimited } from './httpx.js';
30
- import { DEFAULT_NPM_REGISTRY, activeNpmRegistry, onRouteSwitch } from './npm-route.js';
29
+ import { clearPackumentAccessorCache, fetchPackumentDoc, packumentTimes, packumentViews } from './packument.js';
31
30
  /** pnpm 11.7 实机测得的策略窗口:24h(operation-l5fXEd 的 cutoff = 检查时刻 − 1440min)。 */
32
31
  export const DEFAULT_MINIMUM_RELEASE_AGE_MIN = 1440;
33
32
  function scalarNumber(value) {
@@ -148,46 +147,13 @@ export function describeReleaseAgeFailure(input) {
148
147
  lines.push(`预计 ${fmtLocal(maxDeadline)} 后可重试;等待期内也可由操作员把 pnpm-workspace.yaml 中对应的独立精确排除条目改为包名级或复合条目以解除拦截`);
149
148
  return { message: lines.join('\n'), violations, targetViolating: targetViol !== undefined };
150
149
  }
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
150
  /**
167
- * registry packument 的 time 映射(与 npmPackument 同源;失败返回 null,不抛)。
168
- * L3 权威链(ADR-0012):发布时刻是完整性敏感读——npmjs 权威源优先(首腿超时收紧
169
- * `Math.min(timeoutMs, 5_000)`),失败降级生效源;两腿皆败 → null(fail-open 口径不变)。
151
+ * registry packument 的 time 映射(0.9.86 起薄委托 packument 访问器,ADR-0019:两腿阶梯
152
+ * 单份活代码于 fetchDocThrowing)。失败返回 null,不抛;L3 权威链(ADR-0012)语义不变。
170
153
  */
171
154
  export async function npmPackumentTimes(pkg, timeoutMs = 20_000, signal, deps) {
172
- if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
173
- return null;
174
- const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
175
- const firstLegMs = Math.min(timeoutMs, 5_000);
176
- try {
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);
187
- }
188
- catch {
189
- return null;
190
- }
155
+ const doc = await fetchPackumentDoc(pkg, timeoutMs, signal, deps);
156
+ return doc ? packumentViews(doc).times : null;
191
157
  }
192
158
  /** 读 profile 的 pnpm-workspace.yaml 策略;缺席/不可读 → null(调用方 fail-open)。 */
193
159
  export async function readWorkspacePolicy(profileDir) {
@@ -200,28 +166,20 @@ export async function readWorkspacePolicy(profileDir) {
200
166
  return null;
201
167
  }
202
168
  }
203
- const packumentCache = new Map();
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());
210
169
  /**
211
- * 带进程内缓存的 packument time 探测(TTL 5min):预检与登记(exclude-governance)
212
- * 共用——同一操作里预检刚拉过的 packument 不二次拉取(8MiB 级全量)。
170
+ * 0.9.86 薄委托(ADR-0019):本地 Map/TTL/路由注册整体退役——缓存与作废语义由 packument
171
+ * 访问器属主承担(single-flight/TTL 5min/负缓存/cap=4/onRouteSwitch 唯一注册均在彼处)。
213
172
  */
173
+ export function clearPackumentCache() {
174
+ clearPackumentAccessorCache();
175
+ }
176
+ /** 带缓存 packument time 探测(预检与登记共用):薄委托访问器 times 视图,签名与语义不变。 */
214
177
  export async function cachedPackumentTimes(pkg, timeoutMs = 12_000, signal, deps) {
215
- const hit = packumentCache.get(pkg);
216
- if (hit && Date.now() - hit.at < PACKUMENT_CACHE_TTL_MS)
217
- return hit.times;
218
- const times = await npmPackumentTimes(pkg, timeoutMs, signal, deps);
219
- packumentCache.set(pkg, { at: Date.now(), times });
220
- return times;
178
+ return packumentTimes(pkg, timeoutMs, signal, deps);
221
179
  }
222
- /** 测试钩子:清空 packument 进程内缓存。 */
180
+ /** 测试钩子:清空 packument 进程内缓存(现属访问器)。 */
223
181
  export function _resetReleaseAgeCachesForTests() {
224
- packumentCache.clear();
182
+ clearPackumentAccessorCache();
225
183
  }
226
184
  /**
227
185
  * 委派前预检(ADR-0009 收敛语义):仅当策略显式设置 age 或开启 strict 时,对窗口内
@@ -3,10 +3,15 @@
3
3
  * npm:registry /latest(pnpm 安装本身会按 lock integrity 校验 tarball)。
4
4
  * GitHub:优先最新 release/tag(更新提示只跟稳定版走,不跟 main HEAD——中间提交可能不稳定);
5
5
  * 未认证限额 60 次/小时,自用足够。
6
+ *
7
+ * 0.9.86(ADR-0019):npm registry 元数据族(npmVersion/npmPackument/npmPackumentReadme/
8
+ * npmLatest/extractGithubRepo/isExactVersion 及其类型)实现已迁入 packument 访问器属主模块;
9
+ * 本文件原名 re-export 保全部 import 位零改动,自身只留 GitHub 族。
6
10
  */
7
11
  import { gt } from 'semver';
8
12
  import { fetchJsonLimited, HttpError } from './httpx.js';
9
- import { DEFAULT_NPM_REGISTRY, NPM_MIRROR, activeNpmRegistry, syncNpmmirrorPackage } from './npm-route.js';
13
+ import { isExactVersion } from './packument.js';
14
+ export { npmVersion, npmPackument, npmPackumentReadme, npmLatest, extractGithubRepo, isExactVersion, } from './packument.js';
10
15
  /** GitHub 匿名限额(60 次/小时/IP)用尽时返回可读提示(含重置等待分钟数),否则 null。 */
11
16
  function githubRateLimitMessage(err) {
12
17
  if (err instanceof HttpError && err.status === 403 && err.headers?.get('x-ratelimit-remaining') === '0') {
@@ -20,195 +25,6 @@ function githubRateLimitMessage(err) {
20
25
  }
21
26
  return null;
22
27
  }
23
- /** registry base 归一(去尾斜杠;空/缺省 → 官方 npmjs)。 */
24
- function registryBase(registry) {
25
- const base = typeof registry === 'string' && registry.trim() !== '' ? registry.trim() : 'https://registry.npmjs.org';
26
- return base.replace(/\/+$/, '');
27
- }
28
- /** 精确 semver:接受 prerelease/build metadata,拒绝 v 前缀、range、tag 与脏尾缀。 */
29
- const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
30
- export function isExactVersion(version) {
31
- return EXACT_VERSION_RE.test(version);
32
- }
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) {
51
- if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
52
- throw new Error(`无效 npm 包名: ${pkg}`);
53
- if (!isExactVersion(version))
54
- throw new Error(`不是精确版本(拒绝 range/tag/前缀): ${version}`);
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
- }
69
- }
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
- };
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
- }
97
- }
98
- /**
99
- * 拉取完整 packument(NO_MATCHING_VERSION 退避重试前的预热/校验原语)。
100
- * 返回该包已知的全部版本号;解析不出 versions 时返回空列表(不抛)。
101
- * L1(ADR-0012/R1-6):registry 参数曾被忽略——现「显式参数 > 生效源」。
102
- */
103
- export async function npmPackument(pkg, timeoutMs = 20_000, signal, registry, deps) {
104
- if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
105
- throw new Error(`无效 npm 包名: ${pkg}`);
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
- });
113
- const versions = data?.versions !== null && typeof data?.versions === 'object' ? Object.keys(data.versions) : [];
114
- return { versions };
115
- }
116
- /**
117
- * packument repository → "owner/repo"(仅 github.com;git+https / git@ / .git / #frag 归一;
118
- * 非 GitHub 返回空串——README 相对路径锚定只对 GitHub raw/blob 有意义)。0.9.48。
119
- */
120
- export function extractGithubRepo(v) {
121
- const url = typeof v === 'string'
122
- ? v
123
- : v && typeof v === 'object' && typeof v.url === 'string'
124
- ? v.url
125
- : '';
126
- if (!url)
127
- return '';
128
- const m = /github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?(?:[/?#]|$)/i.exec(url);
129
- return m ? `${m[1]}/${m[2]}` : '';
130
- }
131
- /**
132
- * npm packument readme 兜底(0.9.45 U10b 修复):市场/收藏详情 Modal 的 README 折叠页对
133
- * 「未安装」条目无法走本地 profile 读取——回源 npm 兜底。安全基线同 npmPackument:HTTPS、
134
- * 8MB 上限;无 readme 字段返回空串(不抛,客户端显示「没有 README」);L1(ADR-0012):
135
- * 显式 registry 参数 > 生效源。
136
- * 0.9.48:随附 repo(repository 归一为 owner/repo,仅 GitHub),供 README 相对链接/图片
137
- * 锚定仓库基址;无 repository 或非 GitHub 时为空串。
138
- * 0.9.50:两腿阶梯——腿1 `/latest` 版本文档优先(KB 级小载荷,超大 packument 超帽免疫;
139
- * 超时收紧 `Math.min(timeoutMs, 5_000)`,同 npmLatest 首腿先例),readme 命中即短路;空或
140
- * 腿失败落腿2 packument(20s 满额,行为同 0.9.45)。任一腿成功即成功——readme 空渲染
141
- * 「没有 README」(含 latest 空白 readme + packument 失败的降级,不抛);两腿全败抛终末腿
142
- * (packument)错误。两腿均在生效源,不跨源;两腿 readme 判空同一 trim 规则。repo 随到达
143
- * 的腿取 repository(packument 到达时以 packument 为准)。
144
- */
145
- export async function npmPackumentReadme(pkg, timeoutMs = 20_000, signal, registry, deps) {
146
- if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
147
- throw new Error(`无效 npm 包名: ${pkg}`);
148
- const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
149
- const base = registry?.trim() ? registry.trim() : await activeNpmRegistry();
150
- const extractReadme = (data) => typeof data?.readme === 'string' && data.readme.trim() !== '' ? data.readme : '';
151
- // 腿1:/latest 版本文档(小载荷;超时收紧同 npmLatest 首腿先例)
152
- let latestOk = false;
153
- let latestRepo = '';
154
- try {
155
- const latestDoc = await fetcher(`${registryBase(base)}/${encodeURIComponent(pkg)}/latest`, { timeoutMs: Math.min(timeoutMs, 5_000), signal, maxBytes: 8 * 1024 * 1024 });
156
- latestOk = true;
157
- latestRepo = extractGithubRepo(latestDoc?.repository);
158
- const latestReadme = extractReadme(latestDoc);
159
- if (latestReadme)
160
- return { readme: latestReadme, repo: latestRepo };
161
- }
162
- catch {
163
- // latest 腿失败 → 落 packument 腿(终末腿语义见下)
164
- }
165
- // 腿2:packument(0.9.45 U10b 原兜底腿,行为不变)
166
- try {
167
- const data = await fetcher(`${registryBase(base)}/${encodeURIComponent(pkg)}`, { timeoutMs, signal, maxBytes: 8 * 1024 * 1024 });
168
- return { readme: extractReadme(data), repo: extractGithubRepo(data?.repository) };
169
- }
170
- catch (packumentErr) {
171
- // latest 腿已成功证明包在而无有效 readme → 降级成功(空 readme),不冒充失败
172
- if (latestOk)
173
- return { readme: '', repo: latestRepo };
174
- throw packumentErr;
175
- }
176
- }
177
- /**
178
- * 升级探测(检测读)。L3 权威链(ADR-0012):新鲜度敏感——npmjs 权威源优先
179
- * (首腿超时收紧 `Math.min(timeoutMs, 5_000)`),失败降级生效源(≠npmjs 时);
180
- * 不设 sync 腿(两腿全败属网络型失败,sync 只救镜像滞后)。显式 registry 参数优先。
181
- */
182
- export async function npmLatest(pkg, timeoutMs = 20_000, signal, registry, deps) {
183
- // 允许 scoped 包名:@scope/name(isSafePkgName 同款字符集)
184
- if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
185
- throw new Error(`无效 npm 包名: ${pkg}`);
186
- const fetcher = deps?.fetchJsonLimited ?? fetchJsonLimited;
187
- const leg = async (base, ms) => {
188
- const data = await fetcher(`${registryBase(base)}/${encodeURIComponent(pkg)}/latest`, { timeoutMs: ms, signal });
189
- const version = typeof data.version === 'string' ? data.version : '';
190
- if (!version)
191
- throw new Error(`npm 未返回版本: ${pkg}`);
192
- return {
193
- version,
194
- integrity: typeof data.dist?.integrity === 'string' ? data.dist.integrity : undefined,
195
- tarball: typeof data.dist?.tarball === 'string' ? data.dist.tarball : undefined,
196
- };
197
- };
198
- const explicit = registry?.trim();
199
- if (explicit)
200
- return leg(explicit, timeoutMs);
201
- const firstLegMs = Math.min(timeoutMs, 5_000);
202
- try {
203
- return await leg(DEFAULT_NPM_REGISTRY, firstLegMs);
204
- }
205
- catch (firstErr) {
206
- const alt = await activeNpmRegistry();
207
- if (alt === DEFAULT_NPM_REGISTRY)
208
- throw firstErr;
209
- return leg(alt, timeoutMs);
210
- }
211
- }
212
28
  export async function githubTagSha(repo, tag, timeoutMs = 20_000, signal, budget, apiBase = 'https://api.github.com') {
213
29
  // commits/{ref} 会自动解引用 annotated tag,返回的才是可用于 #sha 锁定的 commit
214
30
  const data = await fetchJsonLimited(`${apiBase}/repos/${repo}/commits/${encodeURIComponent(tag)}`, {
@@ -386,7 +202,7 @@ export async function githubLatestTag(repo, timeoutMs = 20_000, signal, budget,
386
202
  */
387
203
  export function isNewerVersion(candidate, current) {
388
204
  // 只接受精确 semver(与收录语义一致:v 前缀/range/tag 不参与比较);非法输入不抛、一律 false
389
- if (!EXACT_VERSION_RE.test(String(candidate ?? '')) || !EXACT_VERSION_RE.test(String(current ?? '')))
205
+ if (!isExactVersion(String(candidate ?? '')) || !isExactVersion(String(current ?? '')))
390
206
  return false;
391
207
  try {
392
208
  return gt(String(candidate), String(current));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.83",
3
+ "version": "0.9.86",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",