dsh-m 0.9.18 → 0.9.20
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 +30 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/docs/DESIGN.md +1 -1
- package/lib/core/host-api.js +2 -0
- package/lib/core/latest-cache.js +46 -104
- package/lib/core/market.js +26 -8
- package/lib/core/profile-ops.js +79 -43
- package/lib/core/release-age.js +267 -0
- package/lib/tools.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,21 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
|
|
|
8
8
|
|
|
9
9
|
## 中文
|
|
10
10
|
|
|
11
|
+
### 0.9.20 变更:latest 探测缓存退回纯内存(重启即失效)+ mutation 定向失效(ADR-0006)
|
|
12
|
+
|
|
13
|
+
- **事故复盘落地(2026-10-03 凌晨)**:0.9.14 起 latest 探测缓存 write-through 落盘、跨重启存活,失效通道只有 TTL 一条——00:22:58 缓存写入后,00:27–01:05 连发四版、01:07 重启 DSH、01:13 重开面板全部吃到陈旧值,卡片与 `dshm_outdated` 双双误报「已是最新」。本次推翻该设计,决策与取舍全文见 `docs/adr/0006-latest-cache-memory-only.md`。
|
|
14
|
+
- **缓存退回纯内存**:latest 探测结果只存内存 Map + TTL(`cacheTtlMin`,默认 60 分钟),**重启即失效**——「发完版重启一下就能看到」重新成立。代价是重启后首轮受限重探(当前页条目、8 并发 + deadline 兜底,TTL 内只付一次);registry/社区**目录正文**的落盘缓存与 SWR 不受影响,页面骨架照旧秒开。0.9.14 遗留的 `<cacheRoot>/latest/<ns>.json` 惰性文件由探测段一次性 best-effort 清扫。
|
|
15
|
+
- **mutation 定向失效**:install/upgrade/uninstall 事务**成功点**按 itemId 作废该条目全部 registryKey 缓存变体(浏览页键 / 已装页 matched 键 / npm-only 键)——升级后卡片不再出现「已装新版 / 最新旧版」自相矛盾;`dshm_outdated` 对刚升级包诚实。只失效不回写(故意装旧版时回写会伪造 latest=已装);事务回滚路径零失效;卸载对 github 源条目的 `gh:` 键尽力而为(pkg 名不可逆推 repo,交由 TTL 自然过期)。
|
|
16
|
+
- **不做项留痕**:force 穿透探测缓存与浏览页 GitHub 预算对齐经主人拍板暂缓——前者残余盲区仅「不重启 + TTL 内」窗口,后者对现行全 npm 源精选清单收益为零(ADR-0006 §Considered Options)。
|
|
17
|
+
|
|
18
|
+
### 0.9.19 变更:供应链等待期「锁文件校验」实证修复——委派前预检 + 全量违规解析 + 账实分裂明示
|
|
19
|
+
|
|
20
|
+
- **根因(本机 2026-10-03 00:15 实证)**:pnpm 11.7 对 desktop profile 做**锁文件级**供应链校验(`Verifying lockfile against supply-chain policies (180 entries)`),而它给每次成功安装自动追加的 `minimumReleaseAgeExclude` **非 scoped 独立精确条目不被这次校验认可**(`dsh-m@0.9.18` 即被拒;scoped 的 `'@iasiv5/dsh-quota-watch@0.1.13'` 则认可)——装过一个「太新」版本后,**等待期内任何官方包操作都会被这个旁包条目拦死**,与本次目标无关;且校验失败前目标包已被写入 node_modules、官方管理器只回滚 manifest/lockfile → 界面显示新版 active、操作记录却是失败(**「账实分裂」**),下次包操作还会把插件静默回退。
|
|
21
|
+
- **委派前预检(`releaseAgePrecheck`,只读、零文件级红线不破、fail-open)**:npm 源安装/升级在委派官方管理器前,对目标版本与锁内「不被校验认可的独立精确排除条目」逐个核对 registry 发布时刻,任一未满等待期 → 结构化 `release-age-wait` 拒绝(含各自可重试时刻),不再产生半写状态;发布时刻不可得 / 策略不可读 / 命中有效排除条目(包名级、`||` 复合、scoped 独立精确)→ 放行,pnpm 仍是最终执行者。
|
|
22
|
+
- **失败翻译换新(替代 0.9.10 单条解析)**:解析全部违规条目,按「本次目标 vs 锁内旁包」分述发布时刻与可重试时刻——旁包连坐不再被冒充成目标被拦;移除「改用 DSH Web 安装」的失配指引。`DesktopOpsError` 新增结构化 `details`(violations/targetViolating/splitState/blockers)。
|
|
23
|
+
- **账实分裂复读**:等待期失败后复读实装状态,node_modules 已是目标版本而 manifest 仍旧版时,在错误信息中明示「下次包操作会回退到旧版,等待期满重新升级即可对齐」。
|
|
24
|
+
- **接线**:desktop install / self-upgrade 调用点补穿 `profileDir`(升级路径此前已有)。
|
|
25
|
+
|
|
11
26
|
### 0.9.18 变更:精选收录 DSH Market + README 重构与变更日志外迁
|
|
12
27
|
|
|
13
28
|
- **新收录 DSH Market**(npm `dshmarket`,精选 18→19):三方可视化插件市场——浏览、搜索社区插件并一键安装,主题一键热切换;主桶装机必备、次桶崔添翼精选(`alsoCategories` 次级归属)。文案按收录规范三句式,三方条目不设 `verified`。
|
|
@@ -215,6 +230,21 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
|
|
|
215
230
|
|
|
216
231
|
## English
|
|
217
232
|
|
|
233
|
+
### Changed in 0.9.20 — latest probe cache back to memory-only (restart invalidates) + mutation-scoped invalidation (ADR-0006)
|
|
234
|
+
|
|
235
|
+
- **Post-mortem landed (night of 2026-10-03)**: since 0.9.14 the latest-version probe cache was written through to disk and survived restarts, with TTL as the only invalidation channel — after a cache write at 00:22:58, four releases (00:27–01:05), a DSH restart (01:07) and a panel reopen (01:13) all served the stale value; cards and `dshm_outdated` both wrongly reported "up to date". That design is overturned; the full decision record lives in `docs/adr/0006-latest-cache-memory-only.md`.
|
|
236
|
+
- **Memory-only cache**: probe results now live in an in-memory Map + TTL (`cacheTtlMin`, default 60) — **a DSH restart drops them**, so "publish, restart, see the new version" works again. The cost is one bounded re-probe wave on the first open after a restart (current page, 8 workers + deadline cap, paid once per TTL window); the registry/community **catalog body** disk cache and SWR are untouched, so the page skeleton still opens instantly. Leftover inert `latest/<ns>.json` envelopes from 0.9.14 are swept once, best-effort, before the probe segment.
|
|
237
|
+
- **Mutation-scoped invalidation**: on **success** of install/upgrade/uninstall, the entry's cached latest value is invalidated across all registryKey variants (browse key / installed matched key / npm-only key) by itemId — no more "installed new / latest old" self-contradicting cards; `dshm_outdated` is honest about freshly upgraded packages. Invalidate-only, no writeback (a deliberate old-version install would otherwise fabricate latest = installed); rollback paths never invalidate; uninstall invalidates npm keys on a best-effort basis (a `gh:` key cannot be reconstructed from a pkg name and expires via TTL).
|
|
238
|
+
- **Not done, on record**: force passthrough of the probe cache and browse-page GitHub budget alignment were deferred by the owner — the former's residual blind spot is only the "no restart, within TTL" window; the latter has zero benefit for today's all-npm curated list (ADR-0006 §Considered Options).
|
|
239
|
+
|
|
240
|
+
### Changed in 0.9.19 — supply-chain release-age: pre-delegation gate, full violation parsing, split-state disclosure
|
|
241
|
+
|
|
242
|
+
- **Root cause (observed 2026-10-03 00:15 on this machine)**: pnpm 11.7 runs a **lockfile-wide** supply-chain verification on desktop installs (`Verifying lockfile against supply-chain policies (180 entries)`), and the standalone exact `minimumReleaseAgeExclude` entries it auto-appends after each successful install are **not honored by that verification when unscoped** (`dsh-m@0.9.18` flagged; scoped `'@iasiv5/dsh-quota-watch@0.1.13'` honored) — so one freshly installed version bricks every package operation for 24h, regardless of the current target. The target package gets written into node_modules before the verification fails while the official manager rolls back only manifest/lockfile → **"split state"**: the UI shows the new version active, the operation record says failed, and the next operation silently downgrades the plugin.
|
|
243
|
+
- **Pre-delegation gate (`releaseAgePrecheck`; read-only, fail-open)**: npm installs/upgrades now check the target version and untrusted lockfile exclude entries against registry publish times **before** delegating; anything inside its waiting window is refused up front as structured `release-age-wait` with per-entry ready times — no more half-written states. Missing publish data / unreadable policy / entries covered by effective selectors (package-level, `||` compound, scoped standalone exact) → proceed; pnpm remains the final enforcer.
|
|
244
|
+
- **Failure translation rewritten** (replacing the 0.9.10 single-match parser): all violations are parsed and reported as "target vs lockfile bystanders" with their own publish times and retry deadlines; the misleading "install via DSH Web instead" hint is gone. `DesktopOpsError` gained structured `details` (violations/targetViolating/splitState/blockers).
|
|
245
|
+
- **Split-state re-read**: after a release-age failure the installed state is re-read; when node_modules already holds the target version while the manifest still records the old one, the error explains the upcoming silent downgrade and how to converge.
|
|
246
|
+
- **Wiring**: desktop install / self-upgrade call sites now pass `profileDir` (upgrade already did).
|
|
247
|
+
|
|
218
248
|
### Changed in 0.9.18 — DSH Market added to Curated + README restructure, changelog extracted
|
|
219
249
|
|
|
220
250
|
- **DSH Market added** (npm `dshmarket`, Curated 18→19): a third-party visual plugin market — browse, search community plugins, and one-click install; one-click live theme switching. Primary bucket Essentials, secondary Cui's Picks (via `alsoCategories`). Copy follows the registry guide; third-party entries carry no `verified` claims.
|
package/README.en.md
CHANGED
|
@@ -119,7 +119,7 @@ When the registry is unavailable, `registry` / `search` / `outdated` print the c
|
|
|
119
119
|
<p><sub>Community catalog · the default landing zone: zone tabs, persistent search, and counted open category chips</sub></p>
|
|
120
120
|
</div>
|
|
121
121
|
|
|
122
|
-
- **Fetch chain**: dist-tags probe → jsDelivr pinned fetch → npmmirror → unpkg fallback; unchanged versions are not re-fetched, probes are skipped within TTL. On failure it falls back to the runtime cache under `<cache dir>/awesome/` and **explicitly labels it "cached snapshot"** — stale data never masquerades as fresh.
|
|
122
|
+
- **Fetch chain**: dist-tags probe → jsDelivr pinned fetch → npmmirror → unpkg fallback; unchanged versions are not re-fetched, probes are skipped within TTL. Latest-version probes use a memory-only cache: it is dropped on every DSH restart (publish a new version, restart, and the new version shows up), and a plugin's cached entry is invalidated immediately after a successful install/upgrade/uninstall via dsh-m. On failure it falls back to the runtime cache under `<cache dir>/awesome/` and **explicitly labels it "cached snapshot"** — stale data never masquerades as fresh.
|
|
123
123
|
- **Switch & pin**: `communityCatalog` toggle in Settings (default on, live), `communityCatalogPin` to lock the catalog version (exact semver); CLI opt-out with `DSHM_COMMUNITY_CATALOG=0`, pin with `DSHM_COMMUNITY_CATALOG_PIN`.
|
|
124
124
|
- **Categories & search**: community categories are an open set (23 known categories with bilingual labels — Chinese maintained in-repo as the single source of truth, English from the upstream catalog, served per UI language); search runs a relevance-weighted pipeline (Chinese + English); the community zone offers a sort switch (downloads/stars/added × asc/desc, default downloads-desc; missing downloads ≠ zero downloads).
|
|
125
125
|
- **Install semantics**: community entries go through the same install path as curated entries (npm exact version / GitHub pinned commit SHA); the browse page probes npm entries only — GitHub entries are not probed at page level (anonymous 60 req/h quota is uncontrollable).
|
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ dshm restart --yes
|
|
|
119
119
|
<p><sub>社区清单 · 默认落地分区:分区 tabs、常驻搜索与带计数的开放分类 chips</sub></p>
|
|
120
120
|
</div>
|
|
121
121
|
|
|
122
|
-
- **数据获取**:dist-tags 探测最新版本 → jsDelivr 按精确版本直取 → npmmirror → unpkg 三线路兜底;版本未变不重拉正文,TTL
|
|
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
124
|
- **分类与搜索**:社区分类是开放集(已知 23 个分类带中英双语标签:中文由包内单一事实源维护、英文取上游目录,按界面语言下发);搜索走相关性加权管线(中英文双匹配);社区区排序可切(下载量/Star/收录日期 × 升降,默认下载量降序,无下载量 ≠ 0 下载)。
|
|
125
125
|
- **安装语义**:社区条目与精选条目走同一安装链路(npm 锁精确版本 / GitHub 锁 commit SHA);市场浏览页只对 npm 条目做更新探测,GitHub 条目不做页面级探测(匿名配额 60 次/小时不可控)。
|
package/docs/DESIGN.md
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
- **严格 v1 schema**:顶层只允许 `version/plugins`,条目只允许 `id/name/description/category/tags/source/npm/github/homepage/icon`;未知字段、非法 ID/npm/GitHub/URL、重复 ID/tag、字段超限、`plugins` 超过 1,000 条均拒绝整份清单(不截断、不部分加载);超过 200 条提示性能边界。
|
|
22
22
|
- **缓存模型**:cache 按 namespace 分目录(Host 与 Agent tools 用 `host/`,独立 CLI 用 `cli/`,互不删除),按带算法版本的 cacheKey 分文件(`CacheFile v2`:原子临时文件 + fsync + rename,0700/0600,symlink 拒写,同 key 进程内写锁)。**候选验证只写候选 cache,绝不 prune 旧源**;settings 写入成功后才 `commitActiveSource`:先原子写 accepted-source metadata(`host/active-source.json`,仅 Host),再清理非当前 custom cache;metadata 失败不 prune、prune 失败保留旧 cache,均只降级为 warning,不撤销已生效的配置。切换后只保留默认缓存与当前 custom 缓存。
|
|
23
23
|
- **统一安全 HTTP**:所有 JSON/text/HEAD 请求共享同一 primitive——手动重定向(每跳校验协议、最多 3 跳、循环检测、signal 传播、返回最终 URL)+ 响应大小上限 + 超时。
|
|
24
|
-
- **秒开缓存语义(0.9.14 SWR)**:`loadRegistry` 与社区获取链的 TTL 过期不再同步等网络——磁盘缓存存在即**先回 stale**(registry `source='default-cache'`、社区 `status='stale'` 既有契约;stale 状态由设置页社区卡承接,市场页不提示临时缓存状态——0.9.15 修订),后台**单飞**刷新写回 cache(同 key 进行中复用;不接调用者 signal,后台自愈不被请求 abort 腰斩;`force`/candidate/显式下载三路径不经 SWR)。默认双线路**粘性**:`loadDefaultChain` 读 cache 记录的上次成功 `source` 排序线路(稳定排序,cache 缺失 = 原序,force 同样生效)。社区 bg 自愈是 force flight——**不走同版本短路**(`if (!opts.force)` 守卫),每 TTL 至多全量重拉一次正文(同步路径的短路优化保留)。页条目 latest
|
|
24
|
+
- **秒开缓存语义(0.9.14 SWR)**:`loadRegistry` 与社区获取链的 TTL 过期不再同步等网络——磁盘缓存存在即**先回 stale**(registry `source='default-cache'`、社区 `status='stale'` 既有契约;stale 状态由设置页社区卡承接,市场页不提示临时缓存状态——0.9.15 修订),后台**单飞**刷新写回 cache(同 key 进行中复用;不接调用者 signal,后台自愈不被请求 abort 腰斩;`force`/candidate/显式下载三路径不经 SWR)。默认双线路**粘性**:`loadDefaultChain` 读 cache 记录的上次成功 `source` 排序线路(稳定排序,cache 缺失 = 原序,force 同样生效)。社区 bg 自愈是 force flight——**不走同版本短路**(`if (!opts.force)` 守卫),每 TTL 至多全量重拉一次正文(同步路径的短路优化保留)。页条目 latest 探测缓存为 `latest-cache.ts` 的**纯内存 Map + TTL**(0.9.20 ADR-0006,推翻 0.9.14 的「磁盘信封 write-through + 懒 seed、跨服务重启存活」——2026-10-03 事故实证发版窗口内重启/重开面板全吃陈旧值,重启即失效回归为特性;install/upgrade/uninstall 成功点由 market.ts 按 itemId 定向作废该条目全部 registryKey 变体,只失效不回写;0.9.14 遗留 `<cacheRoot>/latest/<ns>.json` 惰性文件由探测段一次性 best-effort 清扫,`latest/` 在 nsDir 之外、不受 `pruneCaches` 清扫的不变量不变)。客户端默认首页响应存 localStorage(10min TTL、双 zone 分键、读侧再过 normalize 收敛)——面板打开先渲染快照后 background 换新。
|
|
25
25
|
- **live 语义**:registry 地址是 live 设置,应用成功即时生效(无需重启);只有**首次部署新 Host 代码**需要一次用户确认后的 DSH Web 重启。`timeoutMs`/`cacheTtlMin` 亦为 live 读取;agent 框架注册时的静态工具 deadline 不承诺热更新。
|
|
26
26
|
|
|
27
27
|
### 2.2 Schema(v1 定稿,严格校验)
|
package/lib/core/host-api.js
CHANGED
|
@@ -277,6 +277,7 @@ export function createApiDispatcher(ctx) {
|
|
|
277
277
|
const managed = await d.desktopInstall(ctx.pkg.name, cfg(), {
|
|
278
278
|
namespace: 'host',
|
|
279
279
|
profile: profile.name,
|
|
280
|
+
profileDir: profile.dir,
|
|
280
281
|
signal,
|
|
281
282
|
}, { getService: d.getService, ensureService: d.ensureService });
|
|
282
283
|
if ('needsBuildApproval' in managed) {
|
|
@@ -417,6 +418,7 @@ export function createApiDispatcher(ctx) {
|
|
|
417
418
|
forceIncompatible: boolArg(body.forceIncompatible),
|
|
418
419
|
namespace: 'host',
|
|
419
420
|
profile: profile.name,
|
|
421
|
+
profileDir: profile.dir,
|
|
420
422
|
signal,
|
|
421
423
|
}, { getService: d.getService, ensureService: d.ensureService })) };
|
|
422
424
|
break;
|
package/lib/core/latest-cache.js
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 0.9.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* 0.9.20:latest 探测缓存的唯一 owner——**纯内存 Map + TTL**(ADR-0006)。
|
|
3
|
+
* 设计立场:缓存是优化不是功能;「重启即失效」是特性而非缺陷。0.9.14 的磁盘信封层
|
|
4
|
+
* (write-through 落盘 + 懒 seed,跨重启存活)在 2026-10-03 事故中被证伪为负债:
|
|
5
|
+
* 发版窗口内重启/重开面板全部吃到陈旧 latest(唯一失效通道只剩 TTL),卡片与
|
|
6
|
+
* `dshm_outdated` 双双误报「已是最新」。故磁盘层整体退役——重启变回可靠逃生门,
|
|
7
|
+
* 代价是重启后首轮受限重探(8 并发 + deadline 兜底,TTL 内只付一次)。
|
|
8
|
+
* 0.9.14 遗留的 `<cacheRoot>/latest/<ns>.json` 惰性文件由 ensureLatestCacheSwept
|
|
9
|
+
* 在探测段一次性 best-effort 清扫(每 (namespace,profile) 记忆化,失败静默)。
|
|
10
|
+
* mutation 协作(0.9.20 ①):install/upgrade/uninstall 成功后由 market.ts 调
|
|
11
|
+
* invalidateLatestCache 按 itemId 定向作废(只失效不回写:故意装旧版时回写会伪造
|
|
12
|
+
* latest=已装版本;registry 真值交给下一次探测)。
|
|
8
13
|
*/
|
|
9
|
-
import {
|
|
14
|
+
import { rm } from 'node:fs/promises';
|
|
10
15
|
import { join } from 'node:path';
|
|
11
16
|
import { WEB_PROFILE, cacheRoot } from './env.js';
|
|
12
|
-
export const LATEST_CACHE_VERSION = 1;
|
|
13
17
|
const latestCache = new Map();
|
|
14
18
|
const LATEST_CACHE_MAX = 5000;
|
|
15
|
-
/**
|
|
16
|
-
|
|
19
|
+
/** 缓存键第三段(itemId):浏览页键 / 已装页 matched 键 / npm-only 键共用同一尾段,定向失效按它匹配。 */
|
|
20
|
+
export function latestItemId(item) {
|
|
21
|
+
return item.source === 'npm' && item.npm ? `npm:${item.npm}` : item.github ? `gh:${item.github}` : item.id;
|
|
22
|
+
}
|
|
17
23
|
export function latestCacheKey(namespace, registryKey, item) {
|
|
18
|
-
|
|
19
|
-
return `${namespace}|${registryKey}|${id}`;
|
|
24
|
+
return `${namespace}|${registryKey}|${latestItemId(item)}`;
|
|
20
25
|
}
|
|
26
|
+
/** TTL 过期即从内存删除条目并返回 null(过期值不复活、不外泄;不得以 ttlMin=0 当「force 穿透」用——会先删旧值)。 */
|
|
21
27
|
export function readLatestCache(key, ttlMin) {
|
|
22
28
|
const entry = latestCache.get(key);
|
|
23
29
|
if (!entry)
|
|
@@ -28,112 +34,48 @@ export function readLatestCache(key, ttlMin) {
|
|
|
28
34
|
}
|
|
29
35
|
return entry.value;
|
|
30
36
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
function latestFile(namespace, profile) {
|
|
35
|
-
return join(latestDir(profile), `${namespace}.json`);
|
|
36
|
-
}
|
|
37
|
-
let persistChain = Promise.resolve();
|
|
38
|
-
/** 入队时快照单条(key+entry):flush 晚于内存 reset 也不丢数据;串行链防并发读改写互相覆盖。 */
|
|
39
|
-
function queuePersist(key, namespace, profile, entry) {
|
|
40
|
-
persistChain = persistChain.then(() => persistEnvelope(key, namespace, profile, entry)).catch(() => undefined);
|
|
41
|
-
}
|
|
42
|
-
async function persistEnvelope(key, namespace, profile, entry) {
|
|
43
|
-
let entries = {};
|
|
44
|
-
try {
|
|
45
|
-
const parsed = JSON.parse(await readFile(latestFile(namespace, profile), 'utf8'));
|
|
46
|
-
if (parsed && typeof parsed === 'object' &&
|
|
47
|
-
parsed.version === LATEST_CACHE_VERSION && parsed.namespace === namespace &&
|
|
48
|
-
parsed.entries && typeof parsed.entries === 'object') {
|
|
49
|
-
entries = parsed.entries;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
// 无/损坏信封 → 从本条重建(静默)
|
|
54
|
-
}
|
|
55
|
-
const floor = Date.now() - SEED_MAX_AGE_MS;
|
|
56
|
-
for (const k of Object.keys(entries)) {
|
|
57
|
-
const e = entries[k];
|
|
58
|
-
if (!e || typeof e !== 'object' || typeof e.at !== 'number' || !Number.isFinite(e.at) || e.at < floor)
|
|
59
|
-
delete entries[k];
|
|
60
|
-
}
|
|
61
|
-
entries[key] = entry;
|
|
62
|
-
const envelope = { version: LATEST_CACHE_VERSION, namespace, entries };
|
|
63
|
-
await mkdir(latestDir(profile), { recursive: true });
|
|
64
|
-
const file = latestFile(namespace, profile);
|
|
65
|
-
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
66
|
-
await writeFile(tmp, JSON.stringify(envelope));
|
|
67
|
-
await rename(tmp, file);
|
|
68
|
-
}
|
|
69
|
-
/** 内存 set + write-through:ns 自 key 首段 `|` 前缀解析定位 `<ns>.json` 信封;profile 缺省 web(0.9.0 双 profile 段约定,调用方可显式传)。 */
|
|
70
|
-
export function writeLatestCache(key, value, profile = WEB_PROFILE) {
|
|
37
|
+
/** 内存 set(超上限淘汰最旧);纯内存,无任何落盘。 */
|
|
38
|
+
export function writeLatestCache(key, value) {
|
|
71
39
|
if (latestCache.size >= LATEST_CACHE_MAX) {
|
|
72
40
|
const oldest = latestCache.keys().next().value;
|
|
73
41
|
if (oldest !== undefined)
|
|
74
42
|
latestCache.delete(oldest);
|
|
75
43
|
}
|
|
76
44
|
latestCache.set(key, { at: Date.now(), value });
|
|
77
|
-
const ns = key.split('|', 1)[0];
|
|
78
|
-
if (ns !== 'host' && ns !== 'cli')
|
|
79
|
-
return;
|
|
80
|
-
const entry = latestCache.get(key);
|
|
81
|
-
if (!entry)
|
|
82
|
-
return;
|
|
83
|
-
queuePersist(key, ns, profile, entry);
|
|
84
45
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
46
|
+
/**
|
|
47
|
+
* 0.9.20 ①:mutation 成功后的定向失效——按 itemId 尾段作废该条目在指定 namespace 下的
|
|
48
|
+
* 全部 registryKey 变体(浏览页键、已装页 matched 键、npm-only 键共用同一 itemId)。
|
|
49
|
+
* 返回删除条数(测试断言用)。
|
|
50
|
+
*/
|
|
51
|
+
export function invalidateLatestCache(namespace, itemId) {
|
|
52
|
+
const prefix = `${namespace}|`;
|
|
53
|
+
const suffix = `|${itemId}`;
|
|
54
|
+
let removed = 0;
|
|
55
|
+
for (const key of [...latestCache.keys()]) {
|
|
56
|
+
if (key.startsWith(prefix) && key.endsWith(suffix)) {
|
|
57
|
+
latestCache.delete(key);
|
|
58
|
+
removed += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return removed;
|
|
62
|
+
}
|
|
63
|
+
// ---------- 0.9.14 遗留磁盘信封的一次性清扫(ADR-0006) ----------
|
|
64
|
+
const swept = new Map();
|
|
65
|
+
/** 探测段前调用:删除遗留 `<cacheRoot>/latest/<ns>.json`(退役后的惰性文件,删失败静默);每 (namespace,profile) 记忆化一次。 */
|
|
66
|
+
export async function ensureLatestCacheSwept(opts = {}) {
|
|
88
67
|
const namespace = opts.namespace ?? 'host';
|
|
89
68
|
const profile = opts.profile ?? WEB_PROFILE;
|
|
90
69
|
const memoKey = `${namespace}§${profile}`;
|
|
91
|
-
let task =
|
|
70
|
+
let task = swept.get(memoKey);
|
|
92
71
|
if (!task) {
|
|
93
|
-
task = (
|
|
94
|
-
|
|
95
|
-
await seedFromDisk(namespace, profile);
|
|
96
|
-
})().catch(() => undefined);
|
|
97
|
-
seeded.set(memoKey, task);
|
|
72
|
+
task = rm(join(cacheRoot(profile), 'latest', `${namespace}.json`), { force: true }).catch(() => undefined);
|
|
73
|
+
swept.set(memoKey, task);
|
|
98
74
|
}
|
|
99
75
|
await task;
|
|
100
76
|
}
|
|
101
|
-
|
|
102
|
-
let raw;
|
|
103
|
-
try {
|
|
104
|
-
raw = await readFile(latestFile(namespace, profile), 'utf8');
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
return; // 无信封 = 首次运行;静默
|
|
108
|
-
}
|
|
109
|
-
let envelope;
|
|
110
|
-
try {
|
|
111
|
-
const parsed = JSON.parse(raw);
|
|
112
|
-
if (!parsed || typeof parsed !== 'object')
|
|
113
|
-
return;
|
|
114
|
-
if (parsed.version !== LATEST_CACHE_VERSION || parsed.namespace !== namespace)
|
|
115
|
-
return;
|
|
116
|
-
if (!parsed.entries || typeof parsed.entries !== 'object')
|
|
117
|
-
return;
|
|
118
|
-
envelope = parsed;
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return; // 损坏整文件弃(静默,行为退化为现状探测重放)
|
|
122
|
-
}
|
|
123
|
-
const floor = Date.now() - SEED_MAX_AGE_MS;
|
|
124
|
-
for (const [key, entry] of Object.entries(envelope.entries)) {
|
|
125
|
-
if (!entry || typeof entry !== 'object')
|
|
126
|
-
continue;
|
|
127
|
-
if (typeof entry.at !== 'number' || !Number.isFinite(entry.at) || entry.at < floor)
|
|
128
|
-
continue;
|
|
129
|
-
if (!entry.value || typeof entry.value !== 'object')
|
|
130
|
-
continue;
|
|
131
|
-
if (!latestCache.has(key))
|
|
132
|
-
latestCache.set(key, entry);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
/** 测试钩子:清内存 Map + seed 记忆化(dsh-version.ts resetDshVersionCacheForTest 同款先例)。 */
|
|
77
|
+
/** 测试钩子:清内存 Map + 清扫记忆化(dsh-version.ts resetDshVersionCacheForTest 同款先例)。 */
|
|
136
78
|
export function resetLatestCacheForTest() {
|
|
137
79
|
latestCache.clear();
|
|
138
|
-
|
|
80
|
+
swept.clear();
|
|
139
81
|
}
|
package/lib/core/market.js
CHANGED
|
@@ -23,7 +23,7 @@ import { adaptCommunityCatalog } from './community-adapter.js';
|
|
|
23
23
|
import { normalizeSearchText, relevanceScore, tokenizeSearchText } from './search-relevance.js';
|
|
24
24
|
import { GithubBudgetExhaustedError, createGithubRequestBudget, isExactVersion } from './versions.js';
|
|
25
25
|
import { HttpError } from './httpx.js';
|
|
26
|
-
import {
|
|
26
|
+
import { ensureLatestCacheSwept, invalidateLatestCache, latestCacheKey, latestItemId, readLatestCache, writeLatestCache } from './latest-cache.js';
|
|
27
27
|
import { verifyInstalledAdditions } from './install-guard.js';
|
|
28
28
|
import { fetchTextLimited } from './httpx.js';
|
|
29
29
|
import { githubLatestTag as defaultGithubLatestTag, isNewerVersion, npmLatest as defaultNpmLatest, npmVersion, } from './versions.js';
|
|
@@ -139,6 +139,13 @@ function deadlineRace(task, ms) {
|
|
|
139
139
|
});
|
|
140
140
|
});
|
|
141
141
|
}
|
|
142
|
+
// ---------- latest TTL cache ----------
|
|
143
|
+
// 0.9.20:纯内存 TTL 缓存(ADR-0006,推翻 0.9.14 的落盘跨重启存活——重启即失效是特性);
|
|
144
|
+
// mutation 成功后的定向失效走 invalidateLatestForEntry(只失效不回写)。
|
|
145
|
+
/** 0.9.20 ①:install/upgrade/uninstall 成功点调用的定向失效——registry 真值交给下一次探测。 */
|
|
146
|
+
function invalidateLatestForEntry(namespace, entry) {
|
|
147
|
+
invalidateLatestCache(namespace ?? 'host', latestItemId(entry));
|
|
148
|
+
}
|
|
142
149
|
function probeTask(item, deps, timeoutMs, signal) {
|
|
143
150
|
if (item.source === 'npm' && item.npm) {
|
|
144
151
|
return deps.npmLatest(item.npm, timeoutMs, signal).then((r) => ({ version: r.version }));
|
|
@@ -509,8 +516,8 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
509
516
|
let latestComplete = true;
|
|
510
517
|
let latestTimedOut = false;
|
|
511
518
|
if (withLatest && items.length > 0) {
|
|
512
|
-
// 0.9.
|
|
513
|
-
await
|
|
519
|
+
// 0.9.20:latest 缓存纯内存(ADR-0006)——探测段前仅一次性清扫 0.9.14 遗留磁盘信封
|
|
520
|
+
await ensureLatestCacheSwept({ namespace, profile: opts.profile });
|
|
514
521
|
// 先吃 cache 命中
|
|
515
522
|
const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
|
|
516
523
|
for (const item of items) {
|
|
@@ -540,7 +547,7 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
540
547
|
const outcome = await probeWithBudget(task, perBudget, signal);
|
|
541
548
|
if (outcome.ok && outcome.value) {
|
|
542
549
|
applyProbe(item, outcome.value);
|
|
543
|
-
writeLatestCache(latestCacheKey(namespace, loaded.configuredAddress, item), outcome.value
|
|
550
|
+
writeLatestCache(latestCacheKey(namespace, loaded.configuredAddress, item), outcome.value);
|
|
544
551
|
}
|
|
545
552
|
else if (outcome.timeout) {
|
|
546
553
|
item.latestError = '更新检查未完成:超时';
|
|
@@ -629,9 +636,9 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
|
|
|
629
636
|
}
|
|
630
637
|
const registryState = stateOf(loaded);
|
|
631
638
|
matchInstalled(items, community.merged);
|
|
632
|
-
// 0.9.
|
|
633
|
-
await
|
|
634
|
-
await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000
|
|
639
|
+
// 0.9.20:latest 缓存纯内存(ADR-0006)——探测段前仅一次性清扫 0.9.14 遗留磁盘信封
|
|
640
|
+
await ensureLatestCacheSwept({ namespace, profile: opts.profile });
|
|
641
|
+
await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000 });
|
|
635
642
|
return { items, others: installed.others, profileDir: installed.profileDir, registryState, community: community.summary };
|
|
636
643
|
}
|
|
637
644
|
/** matching(主+社区合并条目):命中主条目或社区条目都写 registryId;社区命中再标 community。 */
|
|
@@ -685,7 +692,7 @@ async function probeLatest(items, ctx, d, rt) {
|
|
|
685
692
|
}
|
|
686
693
|
if (value) {
|
|
687
694
|
applyProbe(item, value);
|
|
688
|
-
writeLatestCache(cacheKey, value
|
|
695
|
+
writeLatestCache(cacheKey, value);
|
|
689
696
|
}
|
|
690
697
|
}
|
|
691
698
|
catch (err) {
|
|
@@ -1087,6 +1094,8 @@ async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
|
|
|
1087
1094
|
...(prior.kind === 'dependency' ? { priorPkgs: [pkg] } : {}),
|
|
1088
1095
|
});
|
|
1089
1096
|
if (guard.unavailable.length > 0) {
|
|
1097
|
+
// 0.9.20 ①:事务已 committed(守卫不可定 fail-open 亦是成功态)→ 定向作废该条目 latest 缓存
|
|
1098
|
+
invalidateLatestForEntry(opts.namespace, entry);
|
|
1090
1099
|
return {
|
|
1091
1100
|
id: entry.id,
|
|
1092
1101
|
pkg: result.pkg ?? pkg,
|
|
@@ -1118,6 +1127,8 @@ async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
|
|
|
1118
1127
|
});
|
|
1119
1128
|
}
|
|
1120
1129
|
const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
|
|
1130
|
+
// 0.9.20 ①:安装成功 → 定向作废该条目 latest 缓存(只失效不回写,ADR-0006)
|
|
1131
|
+
invalidateLatestForEntry(opts.namespace, entry);
|
|
1121
1132
|
return {
|
|
1122
1133
|
id: entry.id,
|
|
1123
1134
|
pkg: result.pkg ?? pkg,
|
|
@@ -1192,6 +1203,8 @@ async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
|
|
|
1192
1203
|
...(prior.kind === 'dependency' ? { priorPkgs: [realKey] } : {}),
|
|
1193
1204
|
});
|
|
1194
1205
|
if (guard.unavailable.length > 0) {
|
|
1206
|
+
// 0.9.20 ①:事务已 committed(守卫不可定 fail-open 亦是成功态)→ 定向作废该条目 latest 缓存
|
|
1207
|
+
invalidateLatestForEntry(opts.namespace, entry);
|
|
1195
1208
|
const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
|
|
1196
1209
|
return {
|
|
1197
1210
|
id: entry.id,
|
|
@@ -1222,6 +1235,8 @@ async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
|
|
|
1222
1235
|
});
|
|
1223
1236
|
}
|
|
1224
1237
|
const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
|
|
1238
|
+
// 0.9.20 ①:安装成功 → 定向作废该条目 latest 缓存(只失效不回写,ADR-0006)
|
|
1239
|
+
invalidateLatestForEntry(opts.namespace, entry);
|
|
1225
1240
|
return {
|
|
1226
1241
|
id: entry.id,
|
|
1227
1242
|
pkg: realKey,
|
|
@@ -1251,6 +1266,9 @@ async function uninstallPluginLocked(pkg, _cfg = {}, opts = {}, deps = {}) {
|
|
|
1251
1266
|
const result = await runProfileTransaction({ kind: 'uninstall', pkg, signal: opts.signal }, deps.transaction ?? {});
|
|
1252
1267
|
if (!result.ok)
|
|
1253
1268
|
throw new TransactionError(result);
|
|
1269
|
+
// 0.9.20 ①:卸载成功 → 尽力作废 npm-only 键(github 条目的 gh: 键无法由 pkg 名重构,
|
|
1270
|
+
// 交由 TTL 自然过期——卸载后已装列表无此条目,不存在「已装/最新」自相矛盾场景)
|
|
1271
|
+
invalidateLatestCache(opts.namespace ?? 'host', `npm:${pkg}`);
|
|
1254
1272
|
const orphaned = result.orphanedPatchFiles ?? [];
|
|
1255
1273
|
const leftovers = [...new Set([...leftoverCandidates(pkg), ...orphaned])];
|
|
1256
1274
|
return {
|
package/lib/core/profile-ops.js
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* 重试把 pending 名单原样传回 `approvedBuilds`(键格式由官方管理器自己写),
|
|
15
15
|
* dsh-m 绝不直接编辑 desktop profile 的 pnpm-workspace.yaml,绝不全量放行;
|
|
16
16
|
* - 成功判定 = 官方结果无 error + `listBundles()` 复读目标 bundle 在装,二者同时满足。
|
|
17
|
+
* - 0.9.19:供应链等待期在委派前预检(release-age.ts,零写操作 fail-open);失败翻译解析
|
|
18
|
+
* 全部违规条目并复读「账实分裂」(pnpm 11 锁文件校验失败前目标已写入 node_modules、
|
|
19
|
+
* 官方管理器只回滚 manifest/lockfile 的实证形态——0:15 quota-watch 事件)。
|
|
17
20
|
*
|
|
18
21
|
* 禁止事项:desktop 路径零 Web fallback——不做文件级装后守卫(app.asar 探测盲区)、
|
|
19
22
|
* 不写 allowBuilds、不跑 dsh-m 自实现 pnpm 编排、服务缺席一律结构化拒绝。
|
|
@@ -26,39 +29,21 @@ import { listInstalledPlugins as defaultListInstalled } from './installed.js';
|
|
|
26
29
|
import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
|
|
27
30
|
import { npmLatest as defaultNpmLatest, githubLatestTag as defaultGithubLatestTag } from './versions.js';
|
|
28
31
|
import { togglePlugin, ToggleError } from './toggle.js';
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return `${minutes} 分钟`;
|
|
35
|
-
const hours = Math.floor(minutes / 60);
|
|
36
|
-
const rest = minutes % 60;
|
|
37
|
-
return rest ? `${hours} 小时 ${rest} 分钟` : `${hours} 小时`;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* 0.9.10:desktop profile 的 pnpm 供应链策略(minimumReleaseAge)拒绝「太新鲜」的版本——
|
|
41
|
-
* 这是策略在正确工作(防供应链攻击的发布等待期),不是环境故障。解析出条目/发布时刻/
|
|
42
|
-
* 策略截止,翻译成「何时可重试」的诚实指引,而不是甩一屏 pnpm 原文(dsh-market #732 同态度)。
|
|
43
|
-
*/
|
|
44
|
-
function releaseAgeWaitMessage(diagnostic) {
|
|
45
|
-
const m = /(\S+@\S+) was published at ([^,]+), within the minimumReleaseAge cutoff \(([^)]+)\)/.exec(diagnostic);
|
|
46
|
-
if (!m) {
|
|
47
|
-
return '官方管理器的供应链策略(minimumReleaseAge)未满足:新发布的版本需满等待期才能装入 desktop profile。稍后再点升级即可;等待期内也可改用 DSH Web 安装';
|
|
48
|
-
}
|
|
49
|
-
const entry = m[1];
|
|
50
|
-
const publishedAt = Date.parse(m[2]);
|
|
51
|
-
const cutoff = Date.parse(m[3]);
|
|
52
|
-
const deadline = Number.isFinite(publishedAt) && Number.isFinite(cutoff) ? publishedAt + (Date.now() - cutoff) : NaN;
|
|
53
|
-
const when = Number.isFinite(deadline) ? `约 ${humanDuration(deadline - Date.now())}后(${new Date(deadline).toLocaleString('zh-CN', { hour12: false })})` : '稍后';
|
|
54
|
-
return `官方管理器的供应链策略(minimumReleaseAge)要求新发布版本满等待期才能安装:${entry} 尚在等待期内,预计 ${when}可重试。等待期内也可改用 DSH Web 安装同版本`;
|
|
55
|
-
}
|
|
32
|
+
import { describeReleaseAgeFailure, releaseAgePrecheck } from './release-age.js';
|
|
33
|
+
// 0.9.19:等待期失败翻译移入 release-age.ts(describeReleaseAgeFailure)——全量违规解析 +
|
|
34
|
+
// 目标/旁包分述。0.9.10 版只取第一条违规并把旁包名字当成本次目标、给「改用 DSH Web」的
|
|
35
|
+
// 失配指引(本机 quota-watch 0.1.15 事件实证:被拦的是旁包 dsh-m@0.9.18,文案却让用户等
|
|
36
|
+
// 旁包满期、去装 web profile)。
|
|
56
37
|
export class DesktopOpsError extends Error {
|
|
57
38
|
code;
|
|
58
|
-
|
|
39
|
+
/** 0.9.19:结构化细节(release-age:violations/targetViolating/splitState;precheck:blockers)。 */
|
|
40
|
+
details;
|
|
41
|
+
constructor(code, message, details) {
|
|
59
42
|
super(message);
|
|
60
43
|
this.name = 'DesktopOpsError';
|
|
61
44
|
this.code = code;
|
|
45
|
+
if (details !== undefined)
|
|
46
|
+
this.details = details;
|
|
62
47
|
}
|
|
63
48
|
}
|
|
64
49
|
/** 结构化信号:build-blocked + pendingBuilds(desktopInstall/Upgrade 共享,调用方映射各自返回形态)。 */
|
|
@@ -135,10 +120,24 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
|
135
120
|
else {
|
|
136
121
|
throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 安装`);
|
|
137
122
|
}
|
|
123
|
+
// 0.9.19:供应链等待期预检——pnpm 11 锁文件校验会拦死整次安装,且失败前目标已写入
|
|
124
|
+
// node_modules(账实分裂);目标太新或锁内「独立精确排除条目」未满期 → 委派前结构化拒绝。
|
|
125
|
+
if (entry.source === 'npm' && version !== undefined) {
|
|
126
|
+
const gate = await releaseAgePrecheck({
|
|
127
|
+
pkg,
|
|
128
|
+
version,
|
|
129
|
+
profileDir: opts.profileDir,
|
|
130
|
+
timeoutMs,
|
|
131
|
+
signal: opts.signal,
|
|
132
|
+
deps: { packumentTimes: deps.packumentTimes, workspacePolicy: deps.workspacePolicy },
|
|
133
|
+
});
|
|
134
|
+
if (gate.blocked)
|
|
135
|
+
throw new DesktopOpsError('release-age-wait', gate.message, { blockers: gate.blockers });
|
|
136
|
+
}
|
|
138
137
|
let change;
|
|
139
138
|
let bundleName;
|
|
140
139
|
try {
|
|
141
|
-
({ change, bundleName } = await runManagedInstall(service, pkg, spec, opts.approvedBuilds));
|
|
140
|
+
({ change, bundleName } = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }));
|
|
142
141
|
}
|
|
143
142
|
catch (err) {
|
|
144
143
|
if (err instanceof DesktopBuildApprovalNeeded) {
|
|
@@ -179,6 +178,9 @@ export async function desktopToggle(pkg, enabled, deps = {}) {
|
|
|
179
178
|
* desktopInstall / desktopUpgrade 共用;build-blocked 以 DesktopBuildApprovalNeeded 抛出,
|
|
180
179
|
* 由调用方映射各自的返回形态。判定纪律沿用文件头「报告 §5.2」(dsh-market #703/#772 实证):
|
|
181
180
|
* 以 application/stage 为准,绝不只看 packageResult.exitCode;overridden 非失败。
|
|
181
|
+
* 0.9.19:release-age 失败翻译换 release-age.ts(全量违规解析 + 目标/旁包分述),并复读
|
|
182
|
+
* 「账实分裂」(pnpm 11 锁文件校验失败前目标可能已写入 node_modules,官方管理器只回滚
|
|
183
|
+
* manifest/lockfile),把回退风险明示进错误信息。
|
|
182
184
|
*
|
|
183
185
|
* 本地增强(2026-10-02,未随计划发版):enable 阶段失败自动重试一次 + 文案按 packageResult 精确化。
|
|
184
186
|
* 背景:desktop 高频装卸/插件树重载窗口下,官方管理器 enable 阶段与应用侧操作竞速,返回
|
|
@@ -205,42 +207,63 @@ function enableStageFailureMessage(pkg, change) {
|
|
|
205
207
|
}
|
|
206
208
|
return `安装后启用阶段失败(${pkg};application=failed、stage=enable${change.error?.code ? `、error=${change.error.code}` : ''}):${writtenText},请在官方 Desktop 插件页查看状态或重试`;
|
|
207
209
|
}
|
|
208
|
-
async function runManagedInstall(service,
|
|
209
|
-
const installOnce = async () => changeOf(await service.installBundle(spec, {
|
|
210
|
+
async function runManagedInstall(service, ctx) {
|
|
211
|
+
const installOnce = async () => changeOf(await service.installBundle(ctx.spec, {
|
|
210
212
|
enabled: true,
|
|
211
|
-
...(approvedBuilds && approvedBuilds.length > 0 ? { approvedBuilds } : {}),
|
|
213
|
+
...(ctx.approvedBuilds && ctx.approvedBuilds.length > 0 ? { approvedBuilds: ctx.approvedBuilds } : {}),
|
|
212
214
|
}));
|
|
213
215
|
/** 失败映射(既有语义不变:cancelled / build-blocked 审批 / release-age 等待 / enable / 通用)。 */
|
|
214
|
-
const mapFailure = (change) => {
|
|
216
|
+
const mapFailure = async (change) => {
|
|
215
217
|
if (change.application === 'cancelled') {
|
|
216
|
-
throw new DesktopOpsError('install-refused', `安装已被取消(${pkg}):desktop profile 文件已由官方管理器恢复,未安装`);
|
|
218
|
+
throw new DesktopOpsError('install-refused', `安装已被取消(${ctx.pkg}):desktop profile 文件已由官方管理器恢复,未安装`);
|
|
217
219
|
}
|
|
218
220
|
const pending = Array.isArray(change.pendingBuilds) ? change.pendingBuilds.filter((n) => typeof n === 'string') : [];
|
|
219
221
|
if (change.packageResult?.kind === 'build-blocked' && pending.length > 0) {
|
|
220
222
|
throw new DesktopBuildApprovalNeeded(pending);
|
|
221
223
|
}
|
|
222
|
-
// 0.9.10
|
|
224
|
+
// 0.9.19:供应链等待期——解析全部违规条目(0.9.10 只取第一条,旁包被当成被拦目标)、
|
|
225
|
+
// 目标/旁包分述 + 账实分裂复读(pnpm 11 校验失败前已写入 node_modules 的实证形态)。
|
|
223
226
|
const diagnosticText = `${change.error?.diagnostic ?? ''}\n${change.error?.code ?? ''}`;
|
|
224
227
|
if (/ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|minimumReleaseAge/i.test(diagnosticText)) {
|
|
225
|
-
|
|
228
|
+
const view = describeReleaseAgeFailure({ pkg: ctx.pkg, version: ctx.version, diagnostic: diagnosticText });
|
|
229
|
+
let message = view.message;
|
|
230
|
+
let splitState;
|
|
231
|
+
if (ctx.profileDir && ctx.version) {
|
|
232
|
+
try {
|
|
233
|
+
const items = (await ctx.listInstalled?.(ctx.profileDir) ?? { items: [] }).items;
|
|
234
|
+
const row = items.find((it) => it.pkg === ctx.pkg);
|
|
235
|
+
if (row && row.version === ctx.version && typeof row.spec === 'string' && row.spec !== '') {
|
|
236
|
+
splitState = { installedVersion: row.version, manifestSpec: row.spec };
|
|
237
|
+
message += `\n注意:${row.version} 已写入 node_modules 且在运行,但 manifest/lockfile 仍记录 ${row.spec}(官方管理器失败回滚所致,「账实分裂」)——官方管理器下次任何包操作都会把它回退到 ${row.spec};等待期满后重新升级即可对齐。`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// 复读失败不遮蔽原错误
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
throw new DesktopOpsError('release-age-wait', message, {
|
|
245
|
+
violations: view.violations,
|
|
246
|
+
targetViolating: view.targetViolating,
|
|
247
|
+
...(splitState !== undefined ? { splitState } : {}),
|
|
248
|
+
});
|
|
226
249
|
}
|
|
227
250
|
if (change.stage === 'enable') {
|
|
228
|
-
throw new DesktopOpsError('enable-failed', enableStageFailureMessage(pkg, change));
|
|
251
|
+
throw new DesktopOpsError('enable-failed', enableStageFailureMessage(ctx.pkg, change));
|
|
229
252
|
}
|
|
230
|
-
throw new DesktopOpsError('install-refused', `官方管理器安装失败(${pkg};application=${change.application ?? 'failed'}${change.error?.code ? `、error=${change.error.code}` : ''}${change.error?.diagnostic ? `:${change.error.diagnostic}` : ''})`);
|
|
253
|
+
throw new DesktopOpsError('install-refused', `官方管理器安装失败(${ctx.pkg};application=${change.application ?? 'failed'}${change.error?.code ? `、error=${change.error.code}` : ''}${change.error?.diagnostic ? `:${change.error.diagnostic}` : ''})`);
|
|
231
254
|
};
|
|
232
255
|
/** 复读校验:listBundles 可用必须见目标在装;不可用(老管理器)至少要求官方宣称产出 bundle。 */
|
|
233
256
|
const verifyAndBundle = async (change) => {
|
|
234
|
-
const bundleName = change.bundle ?? pkg;
|
|
257
|
+
const bundleName = change.bundle ?? ctx.pkg;
|
|
235
258
|
if (typeof service.listBundles === 'function') {
|
|
236
259
|
const bundles = await service.listBundles();
|
|
237
260
|
const found = (bundles ?? []).find((b) => b && b.name === bundleName);
|
|
238
261
|
if (!found || found.installed === false) {
|
|
239
|
-
throw new DesktopOpsError('verify-failed', `安装复读失败(${pkg}):官方结果为 ${change.application ?? 'unknown'} 但 listBundles 未見目标 bundle 在装——不冒充安装成功`);
|
|
262
|
+
throw new DesktopOpsError('verify-failed', `安装复读失败(${ctx.pkg}):官方结果为 ${change.application ?? 'unknown'} 但 listBundles 未見目标 bundle 在装——不冒充安装成功`);
|
|
240
263
|
}
|
|
241
264
|
}
|
|
242
265
|
else if (!change.bundle) {
|
|
243
|
-
throw new DesktopOpsError('verify-failed', `安装复读不可用(${pkg}):官方结果未携带 bundle 字段且 listBundles 缺席——不冒充安装成功`);
|
|
266
|
+
throw new DesktopOpsError('verify-failed', `安装复读不可用(${ctx.pkg}):官方结果未携带 bundle 字段且 listBundles 缺席——不冒充安装成功`);
|
|
244
267
|
}
|
|
245
268
|
return { change, bundleName };
|
|
246
269
|
};
|
|
@@ -253,7 +276,7 @@ async function runManagedInstall(service, pkg, spec, approvedBuilds) {
|
|
|
253
276
|
change = await installOnce();
|
|
254
277
|
}
|
|
255
278
|
if (change.application === 'failed' || change.application === 'cancelled' || change.error)
|
|
256
|
-
mapFailure(change);
|
|
279
|
+
await mapFailure(change);
|
|
257
280
|
return verifyAndBundle(change);
|
|
258
281
|
}
|
|
259
282
|
/**
|
|
@@ -359,10 +382,23 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
|
|
|
359
382
|
else {
|
|
360
383
|
throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 升级`);
|
|
361
384
|
}
|
|
385
|
+
// 0.9.19:供应链等待期预检(同 desktopInstallLocked;0:15 quota-watch 事件即此形态实证)
|
|
386
|
+
if (entry.source === 'npm' && version !== undefined) {
|
|
387
|
+
const gate = await releaseAgePrecheck({
|
|
388
|
+
pkg,
|
|
389
|
+
version,
|
|
390
|
+
profileDir: opts.profileDir,
|
|
391
|
+
timeoutMs,
|
|
392
|
+
signal: opts.signal,
|
|
393
|
+
deps: { packumentTimes: deps.packumentTimes, workspacePolicy: deps.workspacePolicy },
|
|
394
|
+
});
|
|
395
|
+
if (gate.blocked)
|
|
396
|
+
throw new DesktopOpsError('release-age-wait', gate.message, { blockers: gate.blockers });
|
|
397
|
+
}
|
|
362
398
|
let change;
|
|
363
399
|
let bundleName;
|
|
364
400
|
try {
|
|
365
|
-
({ change, bundleName } = await runManagedInstall(service, pkg, spec, opts.approvedBuilds));
|
|
401
|
+
({ change, bundleName } = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }));
|
|
366
402
|
}
|
|
367
403
|
catch (err) {
|
|
368
404
|
if (err instanceof DesktopBuildApprovalNeeded) {
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop 供应链等待期(minimumReleaseAge)预检与失败翻译(0.9.19)。
|
|
3
|
+
*
|
|
4
|
+
* pnpm 11.7 实机实证(2026-10-03,本机 desktop profile,官方管理器操作日志
|
|
5
|
+
* operation-hNjgaN / operation-l5fXEd):
|
|
6
|
+
* 1. 显式安装「太新」目标:非严格模式放行,pnpm 自动向 pnpm-workspace.yaml 的
|
|
7
|
+
* `minimumReleaseAgeExclude` 追加一条「独立精确条目」(`pkg@ver` 形态);
|
|
8
|
+
* 2. 但后续任何包操作的锁文件级供应链校验(`Verifying lockfile against
|
|
9
|
+
* supply-chain policies (N entries)`)不认可 pnpm 自己追加的这种条目——
|
|
10
|
+
* 任何一条未满期的 `pkg@ver` 都会拦死整个安装,且与本次目标无关(旁包能拦住
|
|
11
|
+
* 所有升级,0.9.18 拦住 quota-watch 0.1.15 升级即实证);
|
|
12
|
+
* 3. 校验失败前目标包可能已写入 node_modules,官方管理器只回滚 manifest/lockfile
|
|
13
|
+
* →「账实分裂」:界面实装新版 active,账本仍旧版,下次包操作静默回退。
|
|
14
|
+
*
|
|
15
|
+
* 对策(零文件级红线不破:只读 profile 文件,绝不写):
|
|
16
|
+
* ① 委派前预检(releaseAgePrecheck):目标版本或锁内「不被校验认可的独立精确
|
|
17
|
+
* 排除条目」任一未满等待期 → 结构化拒绝,不委派官方管理器;
|
|
18
|
+
* ② 失败翻译(describeReleaseAgeFailure):解析全部违规条目,按「本次目标 vs
|
|
19
|
+
* 锁内旁包」分别给出发布时刻与可重试时刻——不再把旁包名字冒充成本次目标被拦。
|
|
20
|
+
*
|
|
21
|
+
* fail-open 纪律:发布时刻不可得 / 策略文件不可读 / profileDir 缺席 / 命中有效排除
|
|
22
|
+
* 条目(包名级、`||` 复合、scoped 独立精确)→ 一律放行;pnpm 仍是最终执行者。
|
|
23
|
+
*/
|
|
24
|
+
import { readFile } from 'node:fs/promises';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
|
27
|
+
import { fetchJsonLimited } from './httpx.js';
|
|
28
|
+
import { isExactVersion } from './versions.js';
|
|
29
|
+
/** pnpm 11.7 实机测得的策略窗口:24h(operation-l5fXEd 的 cutoff = 检查时刻 − 1440min)。 */
|
|
30
|
+
export const DEFAULT_MINIMUM_RELEASE_AGE_MIN = 1440;
|
|
31
|
+
/** 锁内「独立精确排除条目」旁包探测上限(防清单失控;超出部分放弃预判,交 pnpm 兜底)。 */
|
|
32
|
+
export const MAX_EXCLUDE_PROBES = 8;
|
|
33
|
+
function scalarNumber(value) {
|
|
34
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
35
|
+
return value;
|
|
36
|
+
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)))
|
|
37
|
+
return Number(value);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 最小解析 pnpm-workspace.yaml 的两个策略键。
|
|
42
|
+
* 保守口径:坏 YAML / 根非映射 / 结构不符 → 空策略(minimumReleaseAgeMin=null、excludes=[]),
|
|
43
|
+
* 调用方按默认窗口 + 无排除处理,绝不抛。
|
|
44
|
+
*/
|
|
45
|
+
export function parseWorkspacePolicy(text) {
|
|
46
|
+
const empty = { minimumReleaseAgeMin: null, excludes: [] };
|
|
47
|
+
let document;
|
|
48
|
+
try {
|
|
49
|
+
document = parseDocument(text);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return empty;
|
|
53
|
+
}
|
|
54
|
+
if (document.errors.length > 0 || !isMap(document.contents))
|
|
55
|
+
return empty;
|
|
56
|
+
const excludes = [];
|
|
57
|
+
const rawExcludes = document.contents.get('minimumReleaseAgeExclude');
|
|
58
|
+
if (isSeq(rawExcludes)) {
|
|
59
|
+
for (const item of rawExcludes.items) {
|
|
60
|
+
if (isScalar(item) && typeof item.value === 'string' && item.value.trim() !== '') {
|
|
61
|
+
excludes.push(item.value.trim());
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// yaml 库的 Map.get() 对标量返回已解包的原始值(number/string),对集合返回节点——两种形态都兜住
|
|
66
|
+
const rawAge = document.contents.get('minimumReleaseAge');
|
|
67
|
+
const age = isScalar(rawAge) ? scalarNumber(rawAge.value) : scalarNumber(rawAge);
|
|
68
|
+
return { minimumReleaseAgeMin: age !== null && age > 0 ? age : null, excludes };
|
|
69
|
+
}
|
|
70
|
+
export function excludeMatch(selector, pkg, version) {
|
|
71
|
+
const s = selector.trim();
|
|
72
|
+
if (s === '')
|
|
73
|
+
return 'no';
|
|
74
|
+
if (s.includes('||')) {
|
|
75
|
+
for (const part of s.split('||')) {
|
|
76
|
+
if (excludeMatch(part.trim(), pkg, version) !== 'no')
|
|
77
|
+
return 'effective';
|
|
78
|
+
}
|
|
79
|
+
return 'no';
|
|
80
|
+
}
|
|
81
|
+
const at = s.lastIndexOf('@');
|
|
82
|
+
if (at <= 0) {
|
|
83
|
+
// 包名级(dshmarket)或裸版本号段(只应出现在复合条目内,顶层出现视为包名比较)
|
|
84
|
+
return s === pkg || s === version ? 'effective' : 'no';
|
|
85
|
+
}
|
|
86
|
+
const namePart = s.slice(0, at);
|
|
87
|
+
const verPart = s.slice(at + 1);
|
|
88
|
+
if (namePart !== pkg)
|
|
89
|
+
return 'no';
|
|
90
|
+
if (verPart === version) {
|
|
91
|
+
return pkg.startsWith('@') ? 'effective' : 'unreliable-unscoped-exact';
|
|
92
|
+
}
|
|
93
|
+
return 'no';
|
|
94
|
+
}
|
|
95
|
+
/** 从独立精确条目提取 (pkg, version);复合/包名级/版本段非精确 semver → null。 */
|
|
96
|
+
export function splitExactSelector(selector) {
|
|
97
|
+
const s = selector.trim();
|
|
98
|
+
if (s === '' || s.includes('||'))
|
|
99
|
+
return null;
|
|
100
|
+
const at = s.lastIndexOf('@');
|
|
101
|
+
if (at <= 0)
|
|
102
|
+
return null;
|
|
103
|
+
const pkg = s.slice(0, at);
|
|
104
|
+
const version = s.slice(at + 1);
|
|
105
|
+
if (!isExactVersion(version))
|
|
106
|
+
return null;
|
|
107
|
+
if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
|
|
108
|
+
return null;
|
|
109
|
+
return { pkg, version, reliable: pkg.startsWith('@') };
|
|
110
|
+
}
|
|
111
|
+
const VIOLATION_RE = /(\S+@\S+) was published at ([^,]+), within the minimumReleaseAge cutoff \(([^)]+)\)/g;
|
|
112
|
+
/** 解析 pnpm 诊断里的全部等待期违规条目(0.9.10 只取第一条是「报错报错包」的根因之一)。 */
|
|
113
|
+
export function parseViolations(diagnostic, nowMs = Date.now()) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const m of diagnostic.matchAll(VIOLATION_RE)) {
|
|
116
|
+
const entry = m[1] ?? '';
|
|
117
|
+
const publishedAt = Date.parse(m[2] ?? '');
|
|
118
|
+
const cutoff = Date.parse(m[3] ?? '');
|
|
119
|
+
if (entry === '' || !Number.isFinite(publishedAt) || !Number.isFinite(cutoff))
|
|
120
|
+
continue;
|
|
121
|
+
const at = entry.lastIndexOf('@');
|
|
122
|
+
out.push({
|
|
123
|
+
entry,
|
|
124
|
+
pkg: at > 0 ? entry.slice(0, at) : entry,
|
|
125
|
+
version: at > 0 ? entry.slice(at + 1) : null,
|
|
126
|
+
publishedAt,
|
|
127
|
+
cutoff,
|
|
128
|
+
deadline: publishedAt + (nowMs - cutoff),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
function fmtLocal(ms) {
|
|
134
|
+
return new Date(ms).toLocaleString('zh-CN', { hour12: false });
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 0.9.19 失败翻译:替代 0.9.10 的 releaseAgeWaitMessage(只取第一条违规、把旁包
|
|
138
|
+
* 名字当成被拦对象、给「DSH Web 安装同版本」的失配指引)。多行文案进操作记录面板。
|
|
139
|
+
*/
|
|
140
|
+
export function describeReleaseAgeFailure(input) {
|
|
141
|
+
const now = input.nowMs ?? Date.now();
|
|
142
|
+
const violations = parseViolations(input.diagnostic, now);
|
|
143
|
+
if (violations.length === 0) {
|
|
144
|
+
return {
|
|
145
|
+
message: '官方管理器的供应链策略(minimumReleaseAge)未满足:新发布的版本需满等待期才能装入 desktop profile。稍后再点升级即可',
|
|
146
|
+
violations,
|
|
147
|
+
targetViolating: false,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const targetEntry = input.version ? `${input.pkg}@${input.version}` : input.pkg;
|
|
151
|
+
const targetViol = violations.find((v) => v.pkg === input.pkg);
|
|
152
|
+
const others = violations.filter((v) => v !== targetViol);
|
|
153
|
+
const lines = [`官方管理器的供应链策略(minimumReleaseAge)拦截了本次安装(${input.pkg}):锁文件供应链校验未通过`];
|
|
154
|
+
if (targetViol) {
|
|
155
|
+
lines.push(`· 本次目标 ${targetViol.entry}:发布于 ${fmtLocal(targetViol.publishedAt)},预计 ${fmtLocal(targetViol.deadline)} 后满等待期`);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
lines.push(`· 本次目标 ${targetEntry}:不在违规名单(被锁内其他未满期条目连坐)`);
|
|
159
|
+
}
|
|
160
|
+
for (const v of others) {
|
|
161
|
+
lines.push(`· 锁内未满期条目 ${v.entry}:发布于 ${fmtLocal(v.publishedAt)},预计 ${fmtLocal(v.deadline)} 后满等待期(pnpm 安装它时自动追加的独立精确排除条目不被锁文件校验认可,等待期内任何官方包操作都会被拦)`);
|
|
162
|
+
}
|
|
163
|
+
const maxDeadline = Math.max(...violations.map((v) => v.deadline));
|
|
164
|
+
lines.push(`预计 ${fmtLocal(maxDeadline)} 后可重试;等待期内也可由操作员把 pnpm-workspace.yaml 中对应的独立精确排除条目改为包名级或复合条目以解除拦截`);
|
|
165
|
+
return { message: lines.join('\n'), violations, targetViolating: targetViol !== undefined };
|
|
166
|
+
}
|
|
167
|
+
/** registry packument 的 time 映射(与 npmPackument 同源;失败返回 null,不抛)。 */
|
|
168
|
+
export async function npmPackumentTimes(pkg, timeoutMs = 20_000, signal) {
|
|
169
|
+
if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
|
|
170
|
+
return null;
|
|
171
|
+
try {
|
|
172
|
+
const data = await fetchJsonLimited(`https://registry.npmjs.org/${encodeURIComponent(pkg)}`, { timeoutMs, signal, maxBytes: 8 * 1024 * 1024 });
|
|
173
|
+
if (!data || typeof data !== 'object' || data.time === null || typeof data.time !== 'object')
|
|
174
|
+
return null;
|
|
175
|
+
const out = {};
|
|
176
|
+
for (const [k, v] of Object.entries(data.time)) {
|
|
177
|
+
if (typeof v === 'string')
|
|
178
|
+
out[k] = v;
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** 读 profile 的 pnpm-workspace.yaml 策略;缺席/不可读 → null(调用方 fail-open)。 */
|
|
187
|
+
export async function readWorkspacePolicy(profileDir) {
|
|
188
|
+
if (!profileDir)
|
|
189
|
+
return null;
|
|
190
|
+
try {
|
|
191
|
+
return parseWorkspacePolicy(await readFile(join(profileDir, 'pnpm-workspace.yaml'), 'utf8'));
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const packumentCache = new Map();
|
|
198
|
+
const PACKUMENT_CACHE_TTL_MS = 5 * 60_000;
|
|
199
|
+
/** 测试钩子:清空 packument 进程内缓存。 */
|
|
200
|
+
export function _resetReleaseAgeCachesForTests() {
|
|
201
|
+
packumentCache.clear();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 委派前预检:目标版本与锁内「不被校验认可的独立精确排除条目」逐个对发布时刻,
|
|
205
|
+
* 任一未满等待期 → 结构化拒绝(blocked + 多行理由)。零写操作;不确定一律放行。
|
|
206
|
+
*/
|
|
207
|
+
export async function releaseAgePrecheck(input) {
|
|
208
|
+
// profileDir 缺席 = 读不到操作员排除条目 → 不预判(避免误拦操作员显式 pin 的安装)
|
|
209
|
+
if (!input.profileDir)
|
|
210
|
+
return { blocked: false };
|
|
211
|
+
const now = input.nowMs ?? Date.now();
|
|
212
|
+
const policy = input.deps?.workspacePolicy
|
|
213
|
+
? await input.deps.workspacePolicy(input.profileDir)
|
|
214
|
+
: await readWorkspacePolicy(input.profileDir);
|
|
215
|
+
const policyMin = policy?.minimumReleaseAgeMin ?? DEFAULT_MINIMUM_RELEASE_AGE_MIN;
|
|
216
|
+
const probe = async (pkg) => {
|
|
217
|
+
const hit = packumentCache.get(pkg);
|
|
218
|
+
if (hit && Date.now() - hit.at < PACKUMENT_CACHE_TTL_MS)
|
|
219
|
+
return hit.times;
|
|
220
|
+
const times = await (input.deps?.packumentTimes ?? npmPackumentTimes)(pkg, Math.min(input.timeoutMs ?? 20_000, 12_000), input.signal);
|
|
221
|
+
packumentCache.set(pkg, { at: Date.now(), times });
|
|
222
|
+
return times;
|
|
223
|
+
};
|
|
224
|
+
const blockers = [];
|
|
225
|
+
// ① 本次目标版本的发布时刻(有效排除条目覆盖 → 放行)
|
|
226
|
+
const times = await probe(input.pkg);
|
|
227
|
+
const publishedAt = times ? Date.parse(times[input.version] ?? '') : NaN;
|
|
228
|
+
if (Number.isFinite(publishedAt)) {
|
|
229
|
+
const exempt = (policy?.excludes ?? []).some((sel) => excludeMatch(sel, input.pkg, input.version) === 'effective');
|
|
230
|
+
if (!exempt) {
|
|
231
|
+
const deadline = publishedAt + policyMin * 60_000;
|
|
232
|
+
if (now < deadline) {
|
|
233
|
+
blockers.push({ entry: `${input.pkg}@${input.version}`, role: 'target', publishedAt, deadline });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// ② 锁内「不被校验认可的独立精确排除条目」(非 scoped)——任何一条未满期都会拦死整次安装
|
|
238
|
+
const seen = new Set([`${input.pkg}@${input.version}`]);
|
|
239
|
+
const candidates = (policy?.excludes ?? [])
|
|
240
|
+
.map((sel) => splitExactSelector(sel))
|
|
241
|
+
.filter((x) => x !== null && !x.reliable);
|
|
242
|
+
for (const ex of candidates.slice(0, MAX_EXCLUDE_PROBES)) {
|
|
243
|
+
const entry = `${ex.pkg}@${ex.version}`;
|
|
244
|
+
if (seen.has(entry))
|
|
245
|
+
continue;
|
|
246
|
+
seen.add(entry);
|
|
247
|
+
const t2 = await probe(ex.pkg);
|
|
248
|
+
const pub = t2 ? Date.parse(t2[ex.version] ?? '') : NaN;
|
|
249
|
+
if (!Number.isFinite(pub))
|
|
250
|
+
continue;
|
|
251
|
+
const deadline = pub + policyMin * 60_000;
|
|
252
|
+
if (now < deadline) {
|
|
253
|
+
blockers.push({ entry, role: 'lockfile-exclude', publishedAt: pub, deadline });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (blockers.length === 0)
|
|
257
|
+
return { blocked: false };
|
|
258
|
+
const lines = ['官方管理器的供应链策略(minimumReleaseAge)将拦截本次安装,已提前拒绝(未触碰 profile 文件):'];
|
|
259
|
+
for (const b of blockers) {
|
|
260
|
+
lines.push(b.role === 'target'
|
|
261
|
+
? `· 目标 ${b.entry}:发布于 ${fmtLocal(b.publishedAt)},预计 ${fmtLocal(b.deadline)} 后满等待期`
|
|
262
|
+
: `· 锁内排除条目 ${b.entry}:发布于 ${fmtLocal(b.publishedAt)},预计 ${fmtLocal(b.deadline)} 后满等待期(pnpm 自动追加的独立精确条目不被锁文件校验认可,等待期内任何官方包操作都会被拦)`);
|
|
263
|
+
}
|
|
264
|
+
const maxDeadline = Math.max(...blockers.map((b) => b.deadline));
|
|
265
|
+
lines.push(`预计 ${fmtLocal(maxDeadline)} 后可重试;等待期内也可由操作员修正 pnpm-workspace.yaml 的排除条目形态(包名级/复合)解除拦截`);
|
|
266
|
+
return { blocked: true, message: lines.join('\n'), blockers };
|
|
267
|
+
}
|
package/lib/tools.js
CHANGED
|
@@ -255,7 +255,7 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
|
|
|
255
255
|
if (profile.kind !== 'web') {
|
|
256
256
|
assertWriteAllowed(profile, 'install');
|
|
257
257
|
// 0.9.8:补传服务解析 deps(此前漏传 → desktop 安装恒 no-manager,实机 2026-10-01)
|
|
258
|
-
return cloneJson(await m.desktopInstall(id, cfg, { version, forceIncompatible: force, namespace: 'host', profile: profile.name }, { getService: getService, ensureService: ensureService }));
|
|
258
|
+
return cloneJson(await m.desktopInstall(id, cfg, { version, forceIncompatible: force, namespace: 'host', profile: profile.name, profileDir: profile.dir }, { getService: getService, ensureService: ensureService }));
|
|
259
259
|
}
|
|
260
260
|
try {
|
|
261
261
|
return cloneJson(await m.installFromRegistry(id, cfg, { version, forceIncompatible: force, namespace: 'host' }));
|