dsh-m 0.9.22 → 0.9.24
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 +28 -0
- package/docs/DESIGN.md +4 -2
- package/lib/client.js +62 -10
- package/lib/core/dsh-cli.js +98 -31
- package/lib/core/exclude-governance.js +264 -0
- package/lib/core/host-api.js +28 -0
- package/lib/core/market.js +8 -3
- package/lib/core/profile-ops.js +124 -19
- package/lib/core/registry.js +11 -0
- package/lib/core/release-age.js +72 -96
- package/package.json +1 -1
- package/registry.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,20 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
|
|
|
8
8
|
|
|
9
9
|
## 中文
|
|
10
10
|
|
|
11
|
+
### 0.9.24 变更:排除条目代管——供应链等待期从「提前拒绝」到「治理 + 登记」(ADR-0009)
|
|
12
|
+
|
|
13
|
+
- **问题(实机 2026-10-03 实证)**:0.9.19 的委派前预检把「目标版本未满 24h 等待期」预测成"官方管理器必拦"并提前拒绝——同目标 dsh-market 却能装上(copilot-auth@1.2.4 发布 8 分钟、quota-watch@0.1.21 两例)。pnpm 11.7 默认非严格策略对显式点名的新版本本来就放行并自动登记排除条目,预检的墙并不存在。
|
|
14
|
+
- **三挂点代管**:① 委派前治理——desktop profile 排除块的坏形态(pnpm 自追加的死规则)合并为"每包一条、版本并集复合";② 装机成功后登记——等待期内目标并入排除块(scoped 精确单条 / 非 scoped 目标+上一版双选择器);③ 双码失败(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` / `ERR_PNPM_NO_MATURE_MATCHING_VERSION`)→ 治理 → 至多重试一次,仍败走既有失败翻译与账实分裂复读。web ladder 同套三挂点。
|
|
15
|
+
- **边界与安全**:红线收窄出唯一例外(desktop 仅此块、仅委派前后挂点、原子写、解析失败即弃、留痕不落全文);治理/登记全程持官方同款锁并 fail-open——任何失败不阻塞委派,pnpm 仍是最终执行者;显式设置 age 或 strict 的 profile 仍前置拒绝并给可重试时刻;机理按"首条规则生效"口径改判(know-how 020 §2.4 形态论退役)。
|
|
16
|
+
- **文档**:决策全文 `docs/adr/0009-release-age-exclude-governance.md`(含 9 项评估过不做的栅栏附录与观察预案);GLOSSARY 新词「首条规则/排除条目/治理/登记/等待期」。
|
|
17
|
+
|
|
18
|
+
### 0.9.23 变更:已装页两段加载——列表秒开 + 更新提示就地补 ⬆(ADR-0008)
|
|
19
|
+
|
|
20
|
+
- **问题(实机 2026-10-03 实证)**:更新探测焊在已装列表接口里且带 60min 内存 TTL——发版后重开面板吃到陈旧缓存,升级提示迟迟不出现(quota-watch 新版发布 17 分钟后面板仍无提示,dshmarket 同刻已见)。
|
|
21
|
+
- **两段并行(零新增按钮)**:第一段 `installed {probe:false}` 立即返回已装列表(registry 匹配与开关相位不受影响);第二段新方法 `installedUpdates`(`probeMode: 'only'`,**TTL=0 每次挂载真实探测**)完成后把 ⬆ 徽标 / tab 红点 / 「全部升级 (N)」就地补上;探测失败仍逐项走「检查未完成」,不冒充「没得更新」。
|
|
22
|
+
- **边界与副作用(ADR-0008 如实留痕)**:范围仅已装页——市场浏览页探测、`dshm_list`/`dshm_outdated` 工具、CLI 行为不变(缺省 `probeMode: 'full'`);ttl=0 会先删 host namespace 共享缓存条目再重探,浏览页/工具的 TTL 内命中被刷新为更新值(数据只更新鲜、代码路径与自身 TTL 制度不变),GitHub 被动预算消耗速率上升(上限 25/req、50/h 滚动不变),超限走 latestError 优雅降级。
|
|
23
|
+
- **文档**:决策全文 `docs/adr/0008-installed-two-phase-probe.md`;DESIGN「已装页/缓存语义」补句;GLOSSARY 新词「两段加载」。
|
|
24
|
+
|
|
11
25
|
### 0.9.22 变更:升级生效判定——tarball 差异三态分类 + 三端重启提示分流(ADR-0007)
|
|
12
26
|
|
|
13
27
|
- **问题(0.9.21 实证)**:`@iasiv5/dsh-skins` 1.2.3→1.3.0 升级后未重启即已生效(客户端 bundle rev 热更),三端仍无条件提示「需要重启」——警报疲劳会侵蚀提示的权威性,重启本身也有真实成本(web 服务瞬断 / desktop 手动重开)。
|
|
@@ -242,6 +256,20 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
|
|
|
242
256
|
|
|
243
257
|
## English
|
|
244
258
|
|
|
259
|
+
### Changed in 0.9.24 — release-age exclusion governance: from pre-flight refusal to govern + register (ADR-0009)
|
|
260
|
+
|
|
261
|
+
- **Problem (observed live 2026-10-03)**: the 0.9.19 pre-flight precheck predicted "the official manager will block a target inside the 24h release-age window" and refused up front — while dsh-market installed the very same targets (copilot-auth@1.2.4 published 8 minutes prior; quota-watch@0.1.21). pnpm 11.7's default non-strict policy admits explicitly named fresh releases and auto-registers their exclusion entries; the wall the precheck predicted did not exist.
|
|
262
|
+
- **Three governance hooks**: (1) pre-delegation govern — merge malformed exclusion shapes (pnpm's auto-appended dead rules) into "one rule per package, version union"; (2) post-success register — fold in-window targets into the exclusion block (scoped exact / non-scoped target+previous composite); (3) on dual-code failure (`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` / `ERR_PNPM_NO_MATURE_MATCHING_VERSION`) → govern → retry the command at most once, then fall through to the existing failure translation. The web ladder gets the same three hooks.
|
|
263
|
+
- **Boundary & safety**: the red line narrows by exactly one exception (desktop: this block only, delegation hooks only, atomic write, parse-failure bail-out, tracing without file dumps); govern/register hold the official same lock and fail open — any failure never blocks delegation, pnpm stays the final enforcer; profiles that explicitly set `minimumReleaseAge` or `minimumReleaseAgeStrict` still get refused up front with a retry time; the mechanism verdict follows "first rule wins" (know-how 020 §2.4 form-theory retired).
|
|
264
|
+
- **Docs**: full decision record in `docs/adr/0009-release-age-exclude-governance.md` (with a fence appendix of 9 evaluated-and-skipped items and the observation contingency); GLOSSARY terms "First Rule / Release-Age Exclusion / Govern / Register / Release Age".
|
|
265
|
+
|
|
266
|
+
### Changed in 0.9.23 — two-phase Installed tab: instant list + update hints filled in place (ADR-0008)
|
|
267
|
+
|
|
268
|
+
- **Problem (observed live 2026-10-03)**: update probes were welded into the installed-list endpoint behind a 60-min in-memory TTL — after publishing a release, reopening the panel served the stale cache and update hints lagged (quota-watch's new version stayed invisible 17 minutes after publish while dsh-market already showed it).
|
|
269
|
+
- **Two parallel phases (zero new buttons)**: phase 1 `installed {probe:false}` returns the installed list immediately (registry matching and toggle phase untouched); phase 2 is a new `installedUpdates` method (`probeMode: 'only'`, **TTL=0 — a real probe on every panel mount**) that fills in ⬆ badges / the tab dot / "Upgrade all (N)" in place; failed checks still surface per item as "check incomplete" instead of pretending "up to date".
|
|
270
|
+
- **Scope & side effects (documented in ADR-0008)**: installed tab only — browse-page probes, `dshm_list`/`dshm_outdated` tools and CLI are unchanged (default `probeMode: 'full'`); ttl=0 evicts shared host-namespace cache entries before re-probing, so browse/tools TTL hits receive fresher write-backs (data only gets fresher; code paths and their own TTL regimes unchanged); GitHub passive budget consumption rate rises (limits unchanged: 25/req, 50/h rolling) with graceful `latestError` degradation.
|
|
271
|
+
- **Docs**: full decision record in `docs/adr/0008-installed-two-phase-probe.md`; DESIGN notes on the installed page / cache semantics; GLOSSARY term "Two-phase Load".
|
|
272
|
+
|
|
245
273
|
### Changed in 0.9.22 — upgrade activation classification (tarball diff, three states) + three-surface restart-hint split (ADR-0007)
|
|
246
274
|
|
|
247
275
|
- **Problem (observed in 0.9.21)**: after upgrading `@iasiv5/dsh-skins` 1.2.3→1.3.0 the plugin was already live without a restart (client bundle rev hot-reload), yet all three surfaces still said "restart required" — the noise trains users to ignore the hint, and restarts have real costs (web service blip / manual desktop reopen).
|
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 探测缓存为 `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
|
|
24
|
+
- **秒开缓存语义(0.9.14 SWR)**:`loadRegistry` 与社区获取链的 TTL 过期不再同步等网络——磁盘缓存存在即**先回 stale**(registry `source='default-cache'`、社区 `status='stale'` 既有契约;stale 状态由设置页社区卡承接,市场页不提示临时缓存状态——0.9.15 修订),后台**单飞**刷新写回 cache(同 key 进行中复用;不接调用者 signal,后台自愈不被请求 abort 腰斩;`force`/candidate/显式下载三路径不经 SWR)。默认双线路**粘性**:`loadDefaultChain` 读 cache 记录的上次成功 `source` 排序线路(稳定排序,cache 缺失 = 原序,force 同样生效)。社区 bg 自愈是 force flight——**不走同版本短路**(`if (!opts.force)` 守卫),每 TTL 至多全量重拉一次正文(同步路径的短路优化保留)。页条目 latest 探测缓存为 `latest-cache.ts` 的**纯内存 Map + TTL**(0.9.20 ADR-0006,推翻 0.9.14 的「磁盘信封 write-through + 懒 seed、跨服务重启存活」——2026-10-03 事故实证发版窗口内重启/重开面板全吃陈旧值,重启即失效回归为特性;install/upgrade/uninstall 成功点由 market.ts 按 itemId 定向作废该条目全部 registryKey 变体,只失效不回写;0.9.14 遗留 `<cacheRoot>/latest/<ns>.json` 惰性文件由探测段一次性 best-effort 清扫,`latest/` 在 nsDir 之外、不受 `pruneCaches` 清扫的不变量不变)。客户端默认首页响应存 localStorage(10min TTL、双 zone 分键、读侧再过 normalize 收敛)——面板打开先渲染快照后 background 换新。已装面板第二段探测为 TTL=0 例外(ADR-0008)。
|
|
25
25
|
- **live 语义**:registry 地址是 live 设置,应用成功即时生效(无需重启);只有**首次部署新 Host 代码**需要一次用户确认后的 DSH Web 重启。`timeoutMs`/`cacheTtlMin` 亦为 live 读取;agent 框架注册时的静态工具 deadline 不承诺热更新。
|
|
26
26
|
|
|
27
27
|
### 2.2 Schema(v1 定稿,严格校验)
|
|
@@ -152,6 +152,8 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
|
|
|
152
152
|
|
|
153
153
|
与 `application`(Profile 变更事务词汇)的边界:`activation` 只是「新版本如何生效」的操作建议,不是事务状态——事务四态(applied/restart-required/overridden/failed)描述**变更本身**,`activation` 描述**生效方式**;两者不互相推导(GLOSSARY Avoid 行同源)。
|
|
154
154
|
|
|
155
|
+
排除条目治理与登记(desktop 红线例外的唯一记载处为 ADR):见 [ADR-0009](./adr/0009-release-age-exclude-governance.md)。
|
|
156
|
+
|
|
155
157
|
## 4. GUI(旗舰,v1 必须做好)
|
|
156
158
|
|
|
157
159
|
0.4.0 增补:已装卡右上 `dshm-switch` 开关(受 `toggleable` 控制,锁因 title 提示);sub 行相位点 `● active · v1.0.8 · npm`(相位点只映射 phase 五值,「已停用」归 Switch,两输入源各管各的);开关结果通知按 `applied` 分流(live → 绿 toast「即时生效」;restart-required → 沿用重启横幅 + 一键重启);安装遇兼容拦截(409 + issue)弹「仍要安装」确认(红字风险 + peers 清单 → `forceIncompatible` 重发)。
|
|
@@ -162,7 +164,7 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
|
|
|
162
164
|
3 个视图,**中文优先**,跟随 DSH Web 深色主题(0.7.0:卡片展开式详情已由 Modal 取代):
|
|
163
165
|
|
|
164
166
|
1. **市场页**(默认):**分区制**(0.7.0 §2.6)——社区/精选/收藏三分区,各分区独立分类/搜索/排序/分页状态;搜索/分类/排序为**服务端处理**(Host 强制 `withLatest=true`、limit clamp 1..96 缺省 24,探测仅当前页);`MarketPanel` 是市场/已装数据唯一 owner(请求 generation + AbortController 丢弃旧响应);安装/卸载/升级完成后通过统一协调器同时刷新市场与已装快照,不要求关闭并重新打开面板;页码窗口化分页(24/48/96)+ 默认/自定义/缓存/不可用短提示(不含本地路径;0.5.0 的「超过 200 条性能提示」随分区退役);详情 Modal 保留 README markdown 预览与 npm/GitHub 官方外链。
|
|
165
|
-
2. **已装页**:profile 实际安装列表,标注来源(市场/非市场/未知);registry 不可用时仍列出已装并标记;「可升级 → x.y.z」徽标
|
|
167
|
+
2. **已装页**:profile 实际安装列表,标注来源(市场/非市场/未知);registry 不可用时仍列出已装并标记;「可升级 → x.y.z」徽标 +「升级」;「卸载」。两段加载(ADR-0008):列表不等待探测(`probe:false`),更新提示由 `installedUpdates`(TTL=0)就地补上。
|
|
166
168
|
3. **设置页**:registry 地址草稿 +「校验并应用」(先校验候选再写 settings,失败不落盘)/「恢复默认」/「下载默认 registry.json」(不改当前配置)/「检查条目可达性」(probe 统计 + 最多 100 条 issue,只读不改配置);同时展示配置地址、当前生效配置、生效来源与 configStatus(含 rejected/回滚原因与维护性 warnings);完整本地路径仅在此页显示。registry 配置为 **live 生效,不出现安装类重启横幅**;安装/卸载/升级仍保留重启横幅。
|
|
167
169
|
|
|
168
170
|
技术:`src/client.js` 经 **esbuild** 打包为 `lib/client.js`;`window.__ModuleLoader__.load({ id: "dsh-m", factory })` 注册;**React 从 module loader require**(零额外运行时依赖);manifest 注入 `@deepseek-ai/dsh-client-runtime`、`@deepseek-ai/dsh-client-ui-slots`、`@deepseek-ai/dsh-client-ui-settings`。
|
package/lib/client.js
CHANGED
|
@@ -689,10 +689,11 @@ var view_refresh_exports = {};
|
|
|
689
689
|
__export(view_refresh_exports, {
|
|
690
690
|
refreshAfterMutation: () => refreshAfterMutation
|
|
691
691
|
});
|
|
692
|
-
async function refreshAfterMutation({ marketReload, installedReload }) {
|
|
692
|
+
async function refreshAfterMutation({ marketReload, installedReload, updatesReload }) {
|
|
693
693
|
await Promise.all([
|
|
694
694
|
marketReload(false),
|
|
695
|
-
installedReload()
|
|
695
|
+
installedReload(),
|
|
696
|
+
updatesReload()
|
|
696
697
|
]);
|
|
697
698
|
}
|
|
698
699
|
var init_view_refresh = __esm({
|
|
@@ -701,6 +702,53 @@ var init_view_refresh = __esm({
|
|
|
701
702
|
}
|
|
702
703
|
});
|
|
703
704
|
|
|
705
|
+
// src/client/installed-updates.js
|
|
706
|
+
var installed_updates_exports = {};
|
|
707
|
+
__export(installed_updates_exports, {
|
|
708
|
+
applyInstalledUpdates: () => applyInstalledUpdates,
|
|
709
|
+
installedUpdateStats: () => installedUpdateStats
|
|
710
|
+
});
|
|
711
|
+
function applyInstalledUpdates(items, updates) {
|
|
712
|
+
if (!Array.isArray(items)) return [];
|
|
713
|
+
const byPkg = /* @__PURE__ */ new Map();
|
|
714
|
+
if (Array.isArray(updates)) {
|
|
715
|
+
for (const u of updates) {
|
|
716
|
+
if (!u || typeof u !== "object" || typeof u.pkg !== "string" || u.pkg === "") continue;
|
|
717
|
+
byPkg.set(u.pkg, u);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return items.map((it) => {
|
|
721
|
+
if (!it || typeof it !== "object") return it;
|
|
722
|
+
const u = byPkg.get(it.pkg);
|
|
723
|
+
if (!u) return it;
|
|
724
|
+
return {
|
|
725
|
+
...it,
|
|
726
|
+
latestVersion: u.latestVersion ?? void 0,
|
|
727
|
+
latestTag: u.latestTag ?? void 0,
|
|
728
|
+
latestSha: u.latestSha ?? void 0,
|
|
729
|
+
outdated: u.outdated === true,
|
|
730
|
+
latestError: u.latestError ?? void 0,
|
|
731
|
+
latestErrorCode: u.latestErrorCode ?? void 0
|
|
732
|
+
};
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
function installedUpdateStats(items) {
|
|
736
|
+
const arr = Array.isArray(items) ? items : [];
|
|
737
|
+
let outdatedCount = 0;
|
|
738
|
+
let incompleteCount = 0;
|
|
739
|
+
for (const it of arr) {
|
|
740
|
+
if (!it || typeof it !== "object") continue;
|
|
741
|
+
if (it.outdated === true) outdatedCount += 1;
|
|
742
|
+
if (typeof it.latestError === "string" && it.latestError !== "") incompleteCount += 1;
|
|
743
|
+
}
|
|
744
|
+
return { outdatedCount, incompleteCount };
|
|
745
|
+
}
|
|
746
|
+
var init_installed_updates = __esm({
|
|
747
|
+
"src/client/installed-updates.js"() {
|
|
748
|
+
"use strict";
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
704
752
|
// src/client/operations.js
|
|
705
753
|
var operations_exports = {};
|
|
706
754
|
__export(operations_exports, {
|
|
@@ -1169,6 +1217,7 @@ var { toggleViewModel: toggleViewModel2, toggleNoticeKeys: toggleNoticeKeys2 } =
|
|
|
1169
1217
|
var { pickPayload: pickPayload2, parseToolArgs: parseToolArgs2 } = (init_tool_view(), __toCommonJS(tool_view_exports));
|
|
1170
1218
|
var { RESTART_POLL_MS: RESTART_POLL_MS2, RESTART_DEADLINE_MS: RESTART_DEADLINE_MS2, nextRestartWait: nextRestartWait2, isAmbiguousRestartRequestError: isAmbiguousRestartRequestError2 } = (init_restart_wait(), __toCommonJS(restart_wait_exports));
|
|
1171
1219
|
var { refreshAfterMutation: refreshAfterMutation2 } = (init_view_refresh(), __toCommonJS(view_refresh_exports));
|
|
1220
|
+
var { applyInstalledUpdates: applyInstalledUpdates2, installedUpdateStats: installedUpdateStats2 } = (init_installed_updates(), __toCommonJS(installed_updates_exports));
|
|
1172
1221
|
var { createOperationsStore: createOperationsStore2, restoreRecords: restoreRecords2, createOpsPump: createOpsPump2, opAppliesTo: opAppliesTo2, TERMINAL_CLEARABLE: TERMINAL_CLEARABLE2, upgradeNotify: upgradeNotify2 } = (init_operations(), __toCommonJS(operations_exports));
|
|
1173
1222
|
var { createFavoritesStore: createFavoritesStore2, partitionStale: partitionStale2 } = (init_favorites(), __toCommonJS(favorites_exports));
|
|
1174
1223
|
var { readSelfCheckCache: readSelfCheckCache2, writeSelfCheckCache: writeSelfCheckCache2, clearSelfCheckCache: clearSelfCheckCache2, deriveChipState: deriveChipState2 } = (init_self_check(), __toCommonJS(self_check_exports));
|
|
@@ -2924,8 +2973,9 @@ function ReadmeBlock({ pkg }) {
|
|
|
2924
2973
|
state.truncated ? h("div", { className: "dshm-md-note" }, lookup("readme.truncated")) : null
|
|
2925
2974
|
);
|
|
2926
2975
|
}
|
|
2927
|
-
function InstalledTab({ notify, installed, onMutation, ops }) {
|
|
2976
|
+
function InstalledTab({ notify, installed, updates, onMutation, ops }) {
|
|
2928
2977
|
const { loading, data, error, reload } = installed;
|
|
2978
|
+
const updateRows = updates && updates.data && Array.isArray(updates.data.updates) ? updates.data.updates : [];
|
|
2929
2979
|
const [openPkg, setOpenPkg] = useState(null);
|
|
2930
2980
|
const [readmePkg, setReadmePkg] = useState(null);
|
|
2931
2981
|
const activeMutateTarget = (ops.records.find((r) => r.kind !== "install" && (r.status === "running" || r.status === "queued" || r.status === "input")) || {}).target || null;
|
|
@@ -3004,7 +3054,7 @@ function InstalledTab({ notify, installed, onMutation, ops }) {
|
|
|
3004
3054
|
};
|
|
3005
3055
|
if (loading && !data) return h("div", { className: "dshm-empty" }, lookup("installed.loading"), Spin());
|
|
3006
3056
|
if (error) return h("div", { className: "dshm-err" }, lookup("failed.read", { err: error }));
|
|
3007
|
-
const items = data && data.items || [];
|
|
3057
|
+
const items = applyInstalledUpdates2(data && data.items || [], updateRows);
|
|
3008
3058
|
if (!items.length) return h("div", { className: "dshm-empty" }, `${lookup("installed.empty")} (${data.profileDir})`);
|
|
3009
3059
|
return h(
|
|
3010
3060
|
React.Fragment,
|
|
@@ -3626,7 +3676,8 @@ function MarketPanel({ onClose }) {
|
|
|
3626
3676
|
const marketCommunity = useMarketData("community");
|
|
3627
3677
|
const marketPrimary = useMarketData("primary");
|
|
3628
3678
|
const markets = { community: marketCommunity, primary: marketPrimary };
|
|
3629
|
-
const installed = useAsync(() => api("installed"), []);
|
|
3679
|
+
const installed = useAsync(() => api("installed", { probe: false }), []);
|
|
3680
|
+
const updates = useAsync(() => api("installedUpdates"), []);
|
|
3630
3681
|
const [opRecords, setOpRecords] = useState(() => opsStore.list().map((r) => ({ ...r })));
|
|
3631
3682
|
const syncOps = useCallback(() => setOpRecords(opsStore.list().map((r) => ({ ...r }))), []);
|
|
3632
3683
|
const runOp = useCallback(
|
|
@@ -3683,14 +3734,14 @@ function MarketPanel({ onClose }) {
|
|
|
3683
3734
|
[marketCommunity.reload, marketPrimary.reload]
|
|
3684
3735
|
);
|
|
3685
3736
|
const refreshViews = useCallback(
|
|
3686
|
-
() => refreshAfterMutation2({ marketReload: marketReloadAll, installedReload: installed.reload }),
|
|
3687
|
-
[marketReloadAll, installed.reload]
|
|
3737
|
+
() => refreshAfterMutation2({ marketReload: marketReloadAll, installedReload: installed.reload, updatesReload: updates.reload }),
|
|
3738
|
+
[marketReloadAll, installed.reload, updates.reload]
|
|
3688
3739
|
);
|
|
3689
3740
|
const onRegistryChanged = refreshViews;
|
|
3690
3741
|
const stillApplies = useCallback(async (r) => {
|
|
3691
3742
|
let fresh = null;
|
|
3692
3743
|
try {
|
|
3693
|
-
fresh = await api("installed");
|
|
3744
|
+
fresh = await api("installed", { probe: false });
|
|
3694
3745
|
} catch {
|
|
3695
3746
|
fresh = null;
|
|
3696
3747
|
}
|
|
@@ -3737,7 +3788,8 @@ function MarketPanel({ onClose }) {
|
|
|
3737
3788
|
market: marketCommunity.data ? marketCommunity.data.total : null,
|
|
3738
3789
|
installed: installed.data ? installed.data.items.length : null
|
|
3739
3790
|
};
|
|
3740
|
-
const
|
|
3791
|
+
const mergedItems = installed.data && Array.isArray(installed.data.items) ? applyInstalledUpdates2(installed.data.items, updates.data && Array.isArray(updates.data.updates) ? updates.data.updates : []) : null;
|
|
3792
|
+
const outdatedCount = installedUpdateStats2(mergedItems || []).outdatedCount;
|
|
3741
3793
|
const [banner, setBanner] = useState(null);
|
|
3742
3794
|
const [toast, setToast] = useState(null);
|
|
3743
3795
|
useEffect(() => {
|
|
@@ -3817,7 +3869,7 @@ function MarketPanel({ onClose }) {
|
|
|
3817
3869
|
"div",
|
|
3818
3870
|
{ className: "dshm-body" },
|
|
3819
3871
|
tab === "market" ? h(MarketTab, { notify, markets, onMutation: refreshViews, ops, favorites, profileKind: profile?.kind ?? null }) : null,
|
|
3820
|
-
tab === "installed" ? h(InstalledTab, { notify, installed, onMutation: refreshViews, ops }) : null,
|
|
3872
|
+
tab === "installed" ? h(InstalledTab, { notify, installed, updates, onMutation: refreshViews, ops }) : null,
|
|
3821
3873
|
tab === "settings" ? h(SettingsTab, { notify, onRegistryChanged }) : null,
|
|
3822
3874
|
h(OperationsPanel, {
|
|
3823
3875
|
records: opRecords,
|
package/lib/core/dsh-cli.js
CHANGED
|
@@ -11,6 +11,9 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
|
11
11
|
import { installTimeoutMs, WEB_PROFILE, webProfileDir } from './env.js';
|
|
12
12
|
import { createProgressTracker } from './progress.js';
|
|
13
13
|
import { applyPreciseBuilds, readPendingBuilds } from './build-approval.js';
|
|
14
|
+
import { governExcludeBlock, parseNpmSpec, registerExclusion } from './exclude-governance.js';
|
|
15
|
+
import { listInstalledPlugins } from './installed.js';
|
|
16
|
+
const DUAL_CODE_RE = /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|ERR_PNPM_NO_MATURE_MATCHING_VERSION/;
|
|
14
17
|
/** pnpm patchedDependencies 条目键与目标包匹配:`pkg` 或 `pkg@任意版本/区间`。 */
|
|
15
18
|
function matchesPatchedKey(key, pkg) {
|
|
16
19
|
return key === pkg || key.startsWith(`${pkg}@`);
|
|
@@ -579,54 +582,118 @@ export function makeAddViaLadder(deps) {
|
|
|
579
582
|
const resolveBuilds = deps.resolveBuilds ?? writePreciseBuilds;
|
|
580
583
|
const opts = (signal) => (signal !== undefined ? { signal } : undefined);
|
|
581
584
|
return async (source, profileDir, signal) => {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
+
// ADR-0009 挂点1:委派前治理(fail-open)——已落盘的编辑以短语进 output(成功/失败都带)
|
|
586
|
+
let governNote = '';
|
|
587
|
+
try {
|
|
588
|
+
const gov = await (deps.govern ?? governExcludeBlock)(profileDir);
|
|
589
|
+
if (gov.changed && gov.mergedNames?.length)
|
|
590
|
+
governNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
// fail-open:治理失败不阻塞
|
|
594
|
+
}
|
|
595
|
+
// 登记原料:npm 精确 spec + 上一版本(本地读,失败 → undefined)
|
|
596
|
+
const spec = parseNpmSpec(source);
|
|
597
|
+
let previousVersion;
|
|
598
|
+
if (spec) {
|
|
585
599
|
try {
|
|
586
|
-
|
|
600
|
+
previousVersion = deps.installedVersion
|
|
601
|
+
? await deps.installedVersion(profileDir, spec.pkg)
|
|
602
|
+
: (await listInstalledPlugins(profileDir)).items.find((it) => it.pkg === spec.pkg)?.version;
|
|
587
603
|
}
|
|
588
|
-
catch
|
|
589
|
-
|
|
590
|
-
return { class: 'hard-fail', output: truncateOutput(text) };
|
|
604
|
+
catch {
|
|
605
|
+
previousVersion = undefined;
|
|
591
606
|
}
|
|
607
|
+
}
|
|
608
|
+
let registerNote = '';
|
|
609
|
+
const safeRegister = async () => {
|
|
610
|
+
if (!spec)
|
|
611
|
+
return;
|
|
592
612
|
try {
|
|
593
|
-
const
|
|
594
|
-
|
|
613
|
+
const reg = await (deps.register ?? registerExclusion)(profileDir, { ...spec, previousVersion });
|
|
614
|
+
if (reg.applied && reg.form)
|
|
615
|
+
registerNote = `;排除条目已登记 ${spec.pkg}@${spec.version}(${reg.form})`;
|
|
595
616
|
}
|
|
596
|
-
catch
|
|
597
|
-
|
|
617
|
+
catch {
|
|
618
|
+
// fail-open:登记失败不影响成功态
|
|
598
619
|
}
|
|
599
620
|
};
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
return { class: 'ok', output: truncateOutput(output), buildApprovals
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
621
|
+
const okOutcome = async (output, buildApprovals = [], fallbackAllBuilds = false) => {
|
|
622
|
+
await safeRegister();
|
|
623
|
+
return { class: 'ok', output: truncateOutput(output + governNote + governRetryNote + registerNote), buildApprovals, fallbackAllBuilds };
|
|
624
|
+
};
|
|
625
|
+
const failOutcome = (text) => ({ ...classifyPnpmError(text), output: truncateOutput(text + governNote + governRetryNote) });
|
|
626
|
+
// ADR-0009 挂点3:阶梯内每次真实 pnpm 命令统一包装——双码失败 → 治理 → changed 则
|
|
627
|
+
// 重试该命令一次(布尔全局保证每次 ladder 调用至多一次;四个失败出口共用)。
|
|
628
|
+
// 挂点3 已落盘的合并以短语并入 output(留痕契约含挂点3,与 hook1 短语去重)。
|
|
629
|
+
let governedRetryUsed = false;
|
|
630
|
+
let governRetryNote = '';
|
|
631
|
+
const attemptAdd = async (args) => {
|
|
632
|
+
try {
|
|
633
|
+
return { ok: true, output: await run(WEB_PROFILE, args, opts(signal)) };
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
const text = errText(err);
|
|
637
|
+
if (governedRetryUsed || !DUAL_CODE_RE.test(text))
|
|
638
|
+
return { ok: false, text };
|
|
639
|
+
governedRetryUsed = true;
|
|
607
640
|
try {
|
|
608
|
-
await
|
|
641
|
+
const gov = await (deps.govern ?? governExcludeBlock)(profileDir);
|
|
642
|
+
if (!gov.changed)
|
|
643
|
+
return { ok: false, text };
|
|
644
|
+
if (gov.mergedNames?.length) {
|
|
645
|
+
const note = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
|
|
646
|
+
if (!governNote.includes(note))
|
|
647
|
+
governRetryNote = note;
|
|
648
|
+
}
|
|
609
649
|
}
|
|
610
|
-
catch
|
|
611
|
-
|
|
612
|
-
return { ...classifyPnpmError(rebuildText), output: truncateOutput(rebuildText) };
|
|
650
|
+
catch {
|
|
651
|
+
return { ok: false, text };
|
|
613
652
|
}
|
|
614
653
|
try {
|
|
615
|
-
|
|
616
|
-
return { class: 'ok', output: truncateOutput(output), buildApprovals: [], fallbackAllBuilds: false };
|
|
654
|
+
return { ok: true, output: await run(WEB_PROFILE, args, opts(signal)) };
|
|
617
655
|
}
|
|
618
656
|
catch (retryErr) {
|
|
619
|
-
|
|
620
|
-
if (isPrepareBlocked(retryText))
|
|
621
|
-
return retryAfterPrepare();
|
|
622
|
-
return { ...classifyPnpmError(retryText), output: truncateOutput(retryText) };
|
|
657
|
+
return { ok: false, text: errText(retryErr) };
|
|
623
658
|
}
|
|
624
659
|
}
|
|
625
|
-
|
|
626
|
-
|
|
660
|
+
};
|
|
661
|
+
const retryAfterPrepare = async () => {
|
|
662
|
+
// Y2(终审复审):放行写入本身失败也必须转换为结果,维持「永不 throw」契约
|
|
663
|
+
let decision;
|
|
664
|
+
try {
|
|
665
|
+
decision = resolveBuilds(profileDir);
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
const text = errText(err);
|
|
669
|
+
return { class: 'hard-fail', output: truncateOutput(text + governNote + governRetryNote) };
|
|
627
670
|
}
|
|
628
|
-
|
|
671
|
+
const retried = await attemptAdd(['add', source]);
|
|
672
|
+
if (retried.ok)
|
|
673
|
+
return okOutcome(retried.output, decision.approvals, decision.fallbackAll);
|
|
674
|
+
return { ...classifyPnpmError(retried.text), output: truncateOutput(retried.text + governNote + governRetryNote) };
|
|
675
|
+
};
|
|
676
|
+
const first = await attemptAdd(['add', source]);
|
|
677
|
+
if (first.ok)
|
|
678
|
+
return okOutcome(first.output);
|
|
679
|
+
const text = first.text;
|
|
680
|
+
if (text.includes(PNPM_OUTCOME_CODES.PUBLIC_HOIST_PATTERN_DIFF)) {
|
|
681
|
+
// 重建 install 自身也过锁文件校验:双码治理+重试同样适用(attemptAdd 包装)
|
|
682
|
+
const rebuild = await attemptAdd(['install', '--no-frozen-lockfile']);
|
|
683
|
+
if (!rebuild.ok)
|
|
684
|
+
return failOutcome(rebuild.text);
|
|
685
|
+
const second = await attemptAdd(['add', source]);
|
|
686
|
+
if (second.ok)
|
|
687
|
+
return okOutcome(second.output);
|
|
688
|
+
const retryText = second.text;
|
|
689
|
+
if (isPrepareBlocked(retryText))
|
|
690
|
+
return retryAfterPrepare();
|
|
691
|
+
return failOutcome(retryText);
|
|
692
|
+
}
|
|
693
|
+
if (!isPrepareBlocked(text)) {
|
|
694
|
+
return failOutcome(text);
|
|
629
695
|
}
|
|
696
|
+
return retryAfterPrepare();
|
|
630
697
|
};
|
|
631
698
|
}
|
|
632
699
|
function errText(err) {
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 排除条目代管(ADR-0009):pnpm-workspace.yaml `minimumReleaseAgeExclude` 块的
|
|
3
|
+
* 纯文本变换(解析 / 治理合并 / 登记)与落盘封装(原子写 + 官方同款锁 + fail-open)。
|
|
4
|
+
*
|
|
5
|
+
* 规范形态:每包一条、版本并集复合(`name@a || b`);包名级保持裸名;scoped 名在
|
|
6
|
+
* YAML 中必须单引号包裹(`@` 不能作 plain scalar 开头)。治理只修不建;登记可为
|
|
7
|
+
* 无块的文件创建块;解析失败(含 `#` 注释行)一律弃写——pnpm 仍是最终执行者。
|
|
8
|
+
*
|
|
9
|
+
* 机理(首条规则生效):pnpm evaluateVersionPolicy 每包名只认第一条排除规则,
|
|
10
|
+
* 同名后续规则死亡(dshmarket #732 实证)——同名合并即消除死规则。
|
|
11
|
+
* 语义对照(只读参考):.dsh-research/dsh-market-clone/src/profile.ts:1310-1385(merge)。
|
|
12
|
+
*/
|
|
13
|
+
import { readFile as readFileFs } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { writeFileAtomic, withFileLock } from '@deepseek-ai/dsh-atomic-write';
|
|
16
|
+
import { isExactVersion } from './versions.js';
|
|
17
|
+
import { cachedPackumentTimes, DEFAULT_MINIMUM_RELEASE_AGE_MIN, readWorkspacePolicy } from './release-age.js';
|
|
18
|
+
/** 块形态(`minimumReleaseAgeExclude:` + 块内 `- <条目>` 行);行内数组等其它形态不在此列。 */
|
|
19
|
+
const BLOCK_RE = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m;
|
|
20
|
+
/** 任意形态的键存在性(含行内);登记据此避免产生重复键。 */
|
|
21
|
+
const KEY_ANY_FORM_RE = /^[ \t]*minimumReleaseAgeExclude[ \t]*:/m;
|
|
22
|
+
const WS_FILE = 'pnpm-workspace.yaml';
|
|
23
|
+
function splitEntry(raw) {
|
|
24
|
+
let text = raw.trim();
|
|
25
|
+
if (text.length >= 2 && ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"')))) {
|
|
26
|
+
text = text.slice(1, -1);
|
|
27
|
+
}
|
|
28
|
+
const at = text.startsWith('@') ? text.indexOf('@', 1) : text.indexOf('@');
|
|
29
|
+
if (at === -1)
|
|
30
|
+
return text === '' ? null : { name: text, selectors: null };
|
|
31
|
+
const name = text.slice(0, at);
|
|
32
|
+
const selector = text.slice(at + 1).trim();
|
|
33
|
+
if (name === '' || selector === '')
|
|
34
|
+
return null;
|
|
35
|
+
return { name, selectors: selector.split('||').map((s) => s.trim()) };
|
|
36
|
+
}
|
|
37
|
+
/** 解析排除块:absent = 键缺失;unparseable = 块内含 `#` 或无法精确读取的条目(调用方弃写)。 */
|
|
38
|
+
export function parseExcludeBlock(yaml) {
|
|
39
|
+
const block = BLOCK_RE.exec(yaml);
|
|
40
|
+
if (block === null)
|
|
41
|
+
return { kind: 'absent' };
|
|
42
|
+
const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
|
|
43
|
+
const indentMatch = /^([ \t]+)-/.exec(block[1]);
|
|
44
|
+
const indent = indentMatch === null ? ' ' : indentMatch[1];
|
|
45
|
+
const entries = [];
|
|
46
|
+
const rawLines = [];
|
|
47
|
+
for (const line of block[1].split(/\r?\n/)) {
|
|
48
|
+
if (line.trim() === '')
|
|
49
|
+
continue;
|
|
50
|
+
const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line);
|
|
51
|
+
if (m === null || m[1].includes('#'))
|
|
52
|
+
return { kind: 'unparseable' };
|
|
53
|
+
const split = splitEntry(m[1]);
|
|
54
|
+
if (split === null)
|
|
55
|
+
return { kind: 'unparseable' };
|
|
56
|
+
entries.push({ name: split.name, selectors: split.selectors, quoted: /^['"]/.test(m[1]), raw: line });
|
|
57
|
+
rawLines.push(line);
|
|
58
|
+
}
|
|
59
|
+
return { kind: 'ok', entries, rawLines, eol, indent };
|
|
60
|
+
}
|
|
61
|
+
/** 规范行渲染:单选择器 `name@v`、多选择器 `name@a || b`、包名级裸名;scoped 名加单引号。 */
|
|
62
|
+
function renderEntryLine(name, selectors, indent) {
|
|
63
|
+
const text = selectors === null || selectors.length === 0
|
|
64
|
+
? name
|
|
65
|
+
: selectors.length === 1
|
|
66
|
+
? `${name}@${selectors[0]}`
|
|
67
|
+
: `${name}@${selectors.join(' || ')}`;
|
|
68
|
+
return `${indent}- ${text.startsWith('@') ? `'${text.replaceAll("'", "''")}'` : text}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 治理:同名规则合并为规范形态(每包一条、版本并集、原首现顺序);只修不建;
|
|
72
|
+
* 块缺失 → changed=false;解析失败 → null(弃写)。changed 的判定逐名比较
|
|
73
|
+
* 「合并后渲染行 vs 原始行」,只有实际被改写的名字进入 mergedNames。
|
|
74
|
+
*/
|
|
75
|
+
export function mergeExcludeRules(yaml) {
|
|
76
|
+
const parse = parseExcludeBlock(yaml);
|
|
77
|
+
if (parse.kind === 'absent')
|
|
78
|
+
return { yaml, changed: false, mergedNames: [], blockAbsent: true };
|
|
79
|
+
if (parse.kind === 'unparseable')
|
|
80
|
+
return null;
|
|
81
|
+
const { entries, rawLines, eol, indent } = parse;
|
|
82
|
+
const groups = new Map();
|
|
83
|
+
const order = [];
|
|
84
|
+
entries.forEach((e, i) => {
|
|
85
|
+
let g = groups.get(e.name);
|
|
86
|
+
if (g === undefined) {
|
|
87
|
+
g = { selectors: [], seen: new Set(), firstRaw: rawLines[i], count: 0 };
|
|
88
|
+
groups.set(e.name, g);
|
|
89
|
+
order.push(e.name);
|
|
90
|
+
}
|
|
91
|
+
g.count += 1;
|
|
92
|
+
if (e.selectors === null) {
|
|
93
|
+
// 包名级吞掉同名一切版本段
|
|
94
|
+
g.selectors = null;
|
|
95
|
+
g.seen.clear();
|
|
96
|
+
}
|
|
97
|
+
else if (g.selectors !== null) {
|
|
98
|
+
for (const s of e.selectors) {
|
|
99
|
+
if (!g.seen.has(s)) {
|
|
100
|
+
g.seen.add(s);
|
|
101
|
+
g.selectors.push(s);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
const mergedNames = order.filter((name) => {
|
|
107
|
+
const g = groups.get(name);
|
|
108
|
+
if (g === undefined)
|
|
109
|
+
return false;
|
|
110
|
+
return g.count > 1 || renderEntryLine(name, g.selectors, indent) !== g.firstRaw;
|
|
111
|
+
});
|
|
112
|
+
if (mergedNames.length === 0)
|
|
113
|
+
return { yaml, changed: false, mergedNames: [], blockAbsent: false };
|
|
114
|
+
const lines = order.map((name) => {
|
|
115
|
+
const g = groups.get(name);
|
|
116
|
+
return renderEntryLine(name, g?.selectors ?? null, indent);
|
|
117
|
+
});
|
|
118
|
+
const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
|
|
119
|
+
return { yaml: yaml.replace(BLOCK_RE, () => blockText), changed: true, mergedNames, blockAbsent: false };
|
|
120
|
+
}
|
|
121
|
+
function newRuleLine(target, indent) {
|
|
122
|
+
if (target.pkg.startsWith('@'))
|
|
123
|
+
return { line: renderEntryLine(target.pkg, [target.version], indent), form: 'created-scoped-exact' };
|
|
124
|
+
if (target.previousVersion !== undefined && target.previousVersion !== target.version) {
|
|
125
|
+
return { line: renderEntryLine(target.pkg, [target.version, target.previousVersion], indent), form: 'created-composite' };
|
|
126
|
+
}
|
|
127
|
+
return { line: renderEntryLine(target.pkg, [target.version], indent), form: 'created-exact' };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 登记:把目标精确版本并入该包**首条**规则(首条规则生效口径);无既有规则时按
|
|
131
|
+
* scoped → 精确单条、非 scoped + 异版 previousVersion → 双选择器复合、其余 → 精确单条
|
|
132
|
+
* 新建;块缺失的文件在尾部创建块(键以行内等其它形式存在 → null,避免重复键)。
|
|
133
|
+
* 目标已被覆盖(包名级 / 选择器含该版本)→ noop。
|
|
134
|
+
*/
|
|
135
|
+
export function registerExclusionInYaml(yaml, target) {
|
|
136
|
+
const parse = parseExcludeBlock(yaml);
|
|
137
|
+
if (parse.kind === 'unparseable')
|
|
138
|
+
return null;
|
|
139
|
+
if (parse.kind === 'absent') {
|
|
140
|
+
if (KEY_ANY_FORM_RE.test(yaml))
|
|
141
|
+
return null;
|
|
142
|
+
const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
|
|
143
|
+
const { line, form } = newRuleLine(target, ' ');
|
|
144
|
+
const base = yaml === '' || yaml.endsWith(eol) ? yaml : yaml + eol;
|
|
145
|
+
return { yaml: `${base}minimumReleaseAgeExclude:${eol}${line}${eol}`, changed: true, form };
|
|
146
|
+
}
|
|
147
|
+
const { entries, rawLines, eol, indent } = parse;
|
|
148
|
+
const lines = [...rawLines];
|
|
149
|
+
const idx = entries.findIndex((e) => e.name === target.pkg);
|
|
150
|
+
if (idx >= 0) {
|
|
151
|
+
const e = entries[idx];
|
|
152
|
+
if (e.selectors === null)
|
|
153
|
+
return { yaml, changed: false, form: 'noop' };
|
|
154
|
+
if (e.selectors.includes(target.version))
|
|
155
|
+
return { yaml, changed: false, form: 'noop' };
|
|
156
|
+
lines[idx] = renderEntryLine(target.pkg, [...e.selectors, target.version], indent);
|
|
157
|
+
const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
|
|
158
|
+
return { yaml: yaml.replace(BLOCK_RE, () => blockText), changed: true, form: 'merged' };
|
|
159
|
+
}
|
|
160
|
+
const { line, form } = newRuleLine(target, indent);
|
|
161
|
+
lines.push(line);
|
|
162
|
+
const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
|
|
163
|
+
return { yaml: yaml.replace(BLOCK_RE, () => blockText), changed: true, form };
|
|
164
|
+
}
|
|
165
|
+
export function parseNpmSpec(spec) {
|
|
166
|
+
let text = spec.trim();
|
|
167
|
+
if (text.length >= 2 && ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"')))) {
|
|
168
|
+
text = text.slice(1, -1);
|
|
169
|
+
}
|
|
170
|
+
if (text === '' || text.includes(':'))
|
|
171
|
+
return null;
|
|
172
|
+
const at = text.startsWith('@') ? text.indexOf('@', 1) : text.indexOf('@');
|
|
173
|
+
if (at <= 0)
|
|
174
|
+
return null;
|
|
175
|
+
const pkg = text.slice(0, at);
|
|
176
|
+
const version = text.slice(at + 1);
|
|
177
|
+
if (pkg === '' || version === '' || !isExactVersion(version))
|
|
178
|
+
return null;
|
|
179
|
+
return { pkg, version };
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* 委派前治理:读-改-写全程持官方同款锁(`withFileLock(<profile>/package.json)`,
|
|
183
|
+
* toggle 同先例),changed 才原子写。fail-open:任何失败返回结构化 reason,不抛——
|
|
184
|
+
* pnpm 是最终执行者。锁只串行持锁写者,不防不持锁的 pnpm 追加(残余风险见 ADR-0009)。
|
|
185
|
+
*/
|
|
186
|
+
export async function governExcludeBlock(profileDir, deps = {}) {
|
|
187
|
+
if (!profileDir)
|
|
188
|
+
return { ok: true, changed: false, reason: 'no-dir' };
|
|
189
|
+
const doRead = deps.readFile ?? ((path) => readFileFs(path, 'utf8'));
|
|
190
|
+
const doWrite = deps.writeFile ??
|
|
191
|
+
(async (path, text) => {
|
|
192
|
+
await writeFileAtomic(path, text, { mode: 0o600 });
|
|
193
|
+
});
|
|
194
|
+
const file = join(profileDir, WS_FILE);
|
|
195
|
+
try {
|
|
196
|
+
return await withFileLock(join(profileDir, 'package.json'), async () => {
|
|
197
|
+
let yaml;
|
|
198
|
+
try {
|
|
199
|
+
yaml = await doRead(file);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return { ok: false, changed: false, reason: 'no-file' };
|
|
203
|
+
}
|
|
204
|
+
if (!KEY_ANY_FORM_RE.test(yaml))
|
|
205
|
+
return { ok: true, changed: false, reason: 'no-block' };
|
|
206
|
+
const merged = mergeExcludeRules(yaml);
|
|
207
|
+
if (merged === null)
|
|
208
|
+
return { ok: false, changed: false, reason: 'unparseable' };
|
|
209
|
+
if (!merged.changed)
|
|
210
|
+
return { ok: true, changed: false, reason: 'clean' };
|
|
211
|
+
await doWrite(file, merged.yaml);
|
|
212
|
+
return { ok: true, changed: true, mergedNames: merged.mergedNames };
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return { ok: false, changed: false, reason: 'write-failed' };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 成功后登记:仅对发布时刻可判「窗口内」(显式 age 值优先,缺省 1440 分钟)的目标
|
|
221
|
+
* 触发;时刻不可得一律不登记(不发明)。本地检查(文件缺席 → no-file)先于 registry
|
|
222
|
+
* 探测——缺省绑定下无块文件零网络。与治理同锁同 fail-open 纪律。
|
|
223
|
+
*/
|
|
224
|
+
export async function registerExclusion(profileDir, target, deps = {}) {
|
|
225
|
+
if (!profileDir)
|
|
226
|
+
return { applied: false, reason: 'no-dir' };
|
|
227
|
+
const doRead = deps.readFile ?? ((path) => readFileFs(path, 'utf8'));
|
|
228
|
+
const doWrite = deps.writeFile ??
|
|
229
|
+
(async (path, text) => {
|
|
230
|
+
await writeFileAtomic(path, text, { mode: 0o600 });
|
|
231
|
+
});
|
|
232
|
+
const file = join(profileDir, WS_FILE);
|
|
233
|
+
try {
|
|
234
|
+
return await withFileLock(join(profileDir, 'package.json'), async () => {
|
|
235
|
+
// 本地检查先行:文件缺席直接 no-file(不发 registry 探测——缺省绑定下测试离线可跑)
|
|
236
|
+
let yaml;
|
|
237
|
+
try {
|
|
238
|
+
yaml = await doRead(file);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return { applied: false, reason: 'no-file' };
|
|
242
|
+
}
|
|
243
|
+
const times = await (deps.packumentTimes ?? cachedPackumentTimes)(target.pkg, 12_000);
|
|
244
|
+
const now = deps.nowMs ?? Date.now();
|
|
245
|
+
const publishedAt = times ? Date.parse(times[target.version] ?? '') : NaN;
|
|
246
|
+
if (!Number.isFinite(publishedAt))
|
|
247
|
+
return { applied: false, reason: 'not-young' };
|
|
248
|
+
const policy = await readWorkspacePolicy(profileDir);
|
|
249
|
+
const windowMin = policy?.minimumReleaseAgeMin ?? DEFAULT_MINIMUM_RELEASE_AGE_MIN;
|
|
250
|
+
if (now >= publishedAt + windowMin * 60_000)
|
|
251
|
+
return { applied: false, reason: 'not-young' };
|
|
252
|
+
const next = registerExclusionInYaml(yaml, target);
|
|
253
|
+
if (next === null)
|
|
254
|
+
return { applied: false, reason: 'unparseable' };
|
|
255
|
+
if (!next.changed)
|
|
256
|
+
return { applied: false, reason: 'up-to-date', form: next.form };
|
|
257
|
+
await doWrite(file, next.yaml);
|
|
258
|
+
return { applied: true, form: next.form };
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return { applied: false, reason: 'write-failed' };
|
|
263
|
+
}
|
|
264
|
+
}
|
package/lib/core/host-api.js
CHANGED
|
@@ -385,15 +385,43 @@ export function createApiDispatcher(ctx) {
|
|
|
385
385
|
// 0.9.5 双 profile 读路径接线(实机回归 2026-10-01):desktop 下已装列表此前
|
|
386
386
|
// 漏传 profile → 落回 webProfileDir,已装页恒显「web profile 尚未安装任何插件」。
|
|
387
387
|
// 与 tools.ts dshm_list 同款接线(profile.dir 单一事实源)。
|
|
388
|
+
// 两段加载(ADR-0008):body.probe === false → 跳过探测段(面板第一段快列表);
|
|
389
|
+
// 缺省 'full'——工具/CLI/旧客户端行为不变。
|
|
388
390
|
const result = await d.listInstalledWithMeta(cfg(), {
|
|
389
391
|
namespace: 'host',
|
|
390
392
|
profileDir: profile.dir,
|
|
391
393
|
profile: profile.name,
|
|
394
|
+
probeMode: body.probe === false ? 'none' : 'full',
|
|
392
395
|
signal,
|
|
393
396
|
});
|
|
394
397
|
payload = { ...result };
|
|
395
398
|
break;
|
|
396
399
|
}
|
|
400
|
+
case 'installedUpdates': {
|
|
401
|
+
// 两段加载(ADR-0008)第二段:probeMode 'only' → ttlMin=0 永远新鲜(先删共享缓存条目再重探)。
|
|
402
|
+
// 响应只保留更新字段且含全部已装项(latestError 项也在——client 的「检查未完成」依赖它),
|
|
403
|
+
// 不携带 items/others 全量负载。
|
|
404
|
+
await ctx.controller.ensureReady();
|
|
405
|
+
const result = await d.listInstalledWithMeta(cfg(), {
|
|
406
|
+
namespace: 'host',
|
|
407
|
+
profileDir: profile.dir,
|
|
408
|
+
profile: profile.name,
|
|
409
|
+
probeMode: 'only',
|
|
410
|
+
signal,
|
|
411
|
+
});
|
|
412
|
+
payload = {
|
|
413
|
+
updates: result.items.map((it) => ({
|
|
414
|
+
pkg: it.pkg,
|
|
415
|
+
latestVersion: it.latestVersion ?? null,
|
|
416
|
+
latestTag: it.latestTag ?? null,
|
|
417
|
+
latestSha: it.latestSha ?? null,
|
|
418
|
+
outdated: it.outdated,
|
|
419
|
+
latestError: it.latestError ?? null,
|
|
420
|
+
latestErrorCode: it.latestErrorCode ?? null,
|
|
421
|
+
})),
|
|
422
|
+
};
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
397
425
|
case 'readme': {
|
|
398
426
|
const target = strArg(body, 'pkg');
|
|
399
427
|
if (!target)
|
package/lib/core/market.js
CHANGED
|
@@ -637,9 +637,14 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
|
|
|
637
637
|
}
|
|
638
638
|
const registryState = stateOf(loaded);
|
|
639
639
|
matchInstalled(items, community.merged);
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
640
|
+
// 两段加载(ADR-0008):probeMode 控制探测段——缺省 'full' 行为不变;'none' 面板快列表跳过探测;
|
|
641
|
+
// 'only' 面板第二段 ttlMin=0 永远新鲜(readLatestCache(key,0) 先删共享条目再重探:host ns 下浏览页/
|
|
642
|
+
// 工具的 TTL 命中被刷新为更新值,预算消耗速率上升、上限不变,超限走 latestError 降级)。
|
|
643
|
+
if ((opts.probeMode ?? 'full') !== 'none') {
|
|
644
|
+
// 0.9.20:latest 缓存纯内存(ADR-0006)——探测段前仅一次性清扫 0.9.14 遗留磁盘信封
|
|
645
|
+
await ensureLatestCacheSwept({ namespace, profile: opts.profile });
|
|
646
|
+
await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: opts.probeMode === 'only' ? 0 : Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000 });
|
|
647
|
+
}
|
|
643
648
|
return { items, others: installed.others, profileDir: installed.profileDir, registryState, community: community.summary };
|
|
644
649
|
}
|
|
645
650
|
/** matching(主+社区合并条目):命中主条目或社区条目都写 registryId;社区命中再标 community。 */
|
package/lib/core/profile-ops.js
CHANGED
|
@@ -31,6 +31,7 @@ import { npmLatest as defaultNpmLatest, githubLatestTag as defaultGithubLatestTa
|
|
|
31
31
|
import { classifyUpgradeActivation } from './activation.js';
|
|
32
32
|
import { togglePlugin, ToggleError } from './toggle.js';
|
|
33
33
|
import { describeReleaseAgeFailure, releaseAgePrecheck } from './release-age.js';
|
|
34
|
+
import { governExcludeBlock, registerExclusion } from './exclude-governance.js';
|
|
34
35
|
// 0.9.19:等待期失败翻译移入 release-age.ts(describeReleaseAgeFailure)——全量违规解析 +
|
|
35
36
|
// 目标/旁包分述。0.9.10 版只取第一条违规并把旁包名字当成本次目标、给「改用 DSH Web」的
|
|
36
37
|
// 失配指引(本机 quota-watch 0.1.15 事件实证:被拦的是旁包 dsh-m@0.9.18,文案却让用户等
|
|
@@ -121,9 +122,23 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
|
121
122
|
else {
|
|
122
123
|
throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 安装`);
|
|
123
124
|
}
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
125
|
+
// ADR-0009:委派前治理(排除块规范形态,fail-open)+ 供应链等待期预检收敛——默认策略
|
|
126
|
+
// 放行并附陈述(官方管理器对显式点名的新版本直接安装并自动登记);显式设置 age 或
|
|
127
|
+
// strict 开启才拒绝。治理已落盘的编辑在成功 output 与失败 message 双路留痕。
|
|
128
|
+
let governanceNote = '';
|
|
129
|
+
let releaseAgeNotice = '';
|
|
130
|
+
let governedMergedNames;
|
|
131
|
+
if (entry.source === 'npm' && version !== undefined && opts.profileDir) {
|
|
132
|
+
try {
|
|
133
|
+
const gov = await (deps.governExclude ?? governExcludeBlock)(opts.profileDir);
|
|
134
|
+
if (gov.changed && gov.mergedNames?.length) {
|
|
135
|
+
governanceNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
|
|
136
|
+
governedMergedNames = gov.mergedNames;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// fail-open:治理失败不阻塞委派(pnpm 仍是最终执行者)
|
|
141
|
+
}
|
|
127
142
|
const gate = await releaseAgePrecheck({
|
|
128
143
|
pkg,
|
|
129
144
|
version,
|
|
@@ -132,20 +147,35 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
|
132
147
|
signal: opts.signal,
|
|
133
148
|
deps: { packumentTimes: deps.packumentTimes, workspacePolicy: deps.workspacePolicy },
|
|
134
149
|
});
|
|
135
|
-
if (gate.blocked)
|
|
136
|
-
throw new DesktopOpsError('release-age-wait', gate.message, { blockers: gate.blockers });
|
|
150
|
+
if (gate.blocked) {
|
|
151
|
+
throw new DesktopOpsError('release-age-wait', gate.message + governanceNote, { blockers: gate.blockers, ...(governedMergedNames !== undefined ? { governanceMerged: governedMergedNames } : {}) });
|
|
152
|
+
}
|
|
153
|
+
if (gate.notice)
|
|
154
|
+
releaseAgeNotice = `;${gate.notice}`;
|
|
137
155
|
}
|
|
138
156
|
let change;
|
|
139
157
|
let bundleName;
|
|
158
|
+
let registerNote;
|
|
159
|
+
let managed;
|
|
140
160
|
try {
|
|
141
|
-
|
|
161
|
+
managed = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }, { governExclude: deps.governExclude, registerExclude: deps.registerExclude, packumentTimes: deps.packumentTimes });
|
|
162
|
+
change = managed.change;
|
|
163
|
+
bundleName = managed.bundleName;
|
|
164
|
+
registerNote = managed.registerNote;
|
|
142
165
|
}
|
|
143
166
|
catch (err) {
|
|
144
167
|
if (err instanceof DesktopBuildApprovalNeeded) {
|
|
145
|
-
return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message };
|
|
168
|
+
return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message + governanceNote };
|
|
169
|
+
}
|
|
170
|
+
if (err instanceof Error) {
|
|
171
|
+
if (governanceNote !== '' && !err.message.includes(governanceNote))
|
|
172
|
+
err.message += governanceNote;
|
|
173
|
+
if (managed?.governRetryNote !== undefined && !err.message.includes(managed.governRetryNote))
|
|
174
|
+
err.message += managed.governRetryNote;
|
|
146
175
|
}
|
|
147
176
|
throw err;
|
|
148
177
|
}
|
|
178
|
+
const retryNote = managed.governRetryNote !== undefined && !governanceNote.includes(managed.governRetryNote) ? managed.governRetryNote : '';
|
|
149
179
|
return {
|
|
150
180
|
id: entry.id,
|
|
151
181
|
pkg,
|
|
@@ -158,7 +188,7 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
|
158
188
|
...(compat !== null ? { compat } : {}),
|
|
159
189
|
...(compatSkipped ? { compatSkipped } : {}),
|
|
160
190
|
needsRestart: true,
|
|
161
|
-
output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
|
|
191
|
+
output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}${governanceNote}${retryNote}${releaseAgeNotice}${registerNote ?? ''}`,
|
|
162
192
|
via: 'desktop-manager',
|
|
163
193
|
...(change.application === 'overridden' ? { overridden: true } : {}),
|
|
164
194
|
};
|
|
@@ -208,7 +238,17 @@ function enableStageFailureMessage(pkg, change) {
|
|
|
208
238
|
}
|
|
209
239
|
return `安装后启用阶段失败(${pkg};application=failed、stage=enable${change.error?.code ? `、error=${change.error.code}` : ''}):${writtenText},请在官方 Desktop 插件页查看状态或重试`;
|
|
210
240
|
}
|
|
211
|
-
|
|
241
|
+
const DUAL_CODE_RE = /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|ERR_PNPM_NO_MATURE_MATCHING_VERSION/;
|
|
242
|
+
/** 可治理的失败形态:cancelled / build-blocked 不治理不重试(与 mapFailure 分类一致)。 */
|
|
243
|
+
function isGovableFailure(change) {
|
|
244
|
+
if (change.application === 'cancelled')
|
|
245
|
+
return false;
|
|
246
|
+
const pending = Array.isArray(change.pendingBuilds) ? change.pendingBuilds.filter((n) => typeof n === 'string') : [];
|
|
247
|
+
if (change.packageResult?.kind === 'build-blocked' && pending.length > 0)
|
|
248
|
+
return false;
|
|
249
|
+
return change.application === 'failed' || change.error !== undefined;
|
|
250
|
+
}
|
|
251
|
+
async function runManagedInstall(service, ctx, deps = {}) {
|
|
212
252
|
const installOnce = async () => changeOf(await service.installBundle(ctx.spec, {
|
|
213
253
|
enabled: true,
|
|
214
254
|
...(ctx.approvedBuilds && ctx.approvedBuilds.length > 0 ? { approvedBuilds: ctx.approvedBuilds } : {}),
|
|
@@ -276,9 +316,46 @@ async function runManagedInstall(service, ctx) {
|
|
|
276
316
|
await sleepMs(enableRetryDelayMs);
|
|
277
317
|
change = await installOnce();
|
|
278
318
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
319
|
+
// ADR-0009 挂点3:双码失败 → 治理 → changed 则重试该命令一次(每次调用至多一次;
|
|
320
|
+
// cancelled / build-blocked 不治理不重试)。仍败走既有 mapFailure(含账实分裂复读);
|
|
321
|
+
// 挂点3 已落盘的合并以短语并入失败 message 与成功 output(留痕契约含挂点3)。
|
|
322
|
+
let governRetryNote;
|
|
323
|
+
if (isGovableFailure(change) && DUAL_CODE_RE.test(`${change.error?.diagnostic ?? ''}\n${change.error?.code ?? ''}`)) {
|
|
324
|
+
const gov = await (deps.governExclude ?? governExcludeBlock)(ctx.profileDir ?? '');
|
|
325
|
+
if (gov.changed) {
|
|
326
|
+
if (gov.mergedNames?.length)
|
|
327
|
+
governRetryNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
|
|
328
|
+
change = await installOnce();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (change.application === 'failed' || change.application === 'cancelled' || change.error) {
|
|
332
|
+
try {
|
|
333
|
+
await mapFailure(change);
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
if (err instanceof Error && governRetryNote !== undefined && !err.message.includes(governRetryNote))
|
|
337
|
+
err.message += governRetryNote;
|
|
338
|
+
throw err;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const verified = await verifyAndBundle(change);
|
|
342
|
+
// ADR-0009 挂点2:成功后登记(仅 npm 源窗口内目标;fail-open,applied 短语由调用方并入 output)
|
|
343
|
+
let registerNote;
|
|
344
|
+
if (ctx.profileDir && ctx.version) {
|
|
345
|
+
try {
|
|
346
|
+
const reg = await (deps.registerExclude ?? registerExclusion)(ctx.profileDir, {
|
|
347
|
+
pkg: ctx.pkg,
|
|
348
|
+
version: ctx.version,
|
|
349
|
+
...(ctx.previousVersion !== undefined ? { previousVersion: ctx.previousVersion } : {}),
|
|
350
|
+
}, deps.packumentTimes ? { packumentTimes: deps.packumentTimes } : {});
|
|
351
|
+
if (reg.applied && reg.form)
|
|
352
|
+
registerNote = `;排除条目已登记 ${ctx.pkg}@${ctx.version}(${reg.form})`;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// fail-open:登记失败不影响安装/升级成功态
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { ...verified, ...(registerNote !== undefined || governRetryNote !== undefined ? { registerNote, governRetryNote } : {}) };
|
|
282
359
|
}
|
|
283
360
|
/**
|
|
284
361
|
* Desktop 卸载(0.9.8,dsh-market 同策略):removeBundle 委派 + ChangeResult 判定 +
|
|
@@ -383,8 +460,21 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
|
|
|
383
460
|
else {
|
|
384
461
|
throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 升级`);
|
|
385
462
|
}
|
|
386
|
-
//
|
|
387
|
-
|
|
463
|
+
// ADR-0009:委派前治理 + 预检收敛(同 desktopInstallLocked;0:15 quota-watch 事件为治理前的旧形态实证)
|
|
464
|
+
let governanceNote = '';
|
|
465
|
+
let releaseAgeNotice = '';
|
|
466
|
+
let governedMergedNames;
|
|
467
|
+
if (entry.source === 'npm' && version !== undefined && opts.profileDir) {
|
|
468
|
+
try {
|
|
469
|
+
const gov = await (deps.governExclude ?? governExcludeBlock)(opts.profileDir);
|
|
470
|
+
if (gov.changed && gov.mergedNames?.length) {
|
|
471
|
+
governanceNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
|
|
472
|
+
governedMergedNames = gov.mergedNames;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// fail-open:治理失败不阻塞委派
|
|
477
|
+
}
|
|
388
478
|
const gate = await releaseAgePrecheck({
|
|
389
479
|
pkg,
|
|
390
480
|
version,
|
|
@@ -393,20 +483,35 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
|
|
|
393
483
|
signal: opts.signal,
|
|
394
484
|
deps: { packumentTimes: deps.packumentTimes, workspacePolicy: deps.workspacePolicy },
|
|
395
485
|
});
|
|
396
|
-
if (gate.blocked)
|
|
397
|
-
throw new DesktopOpsError('release-age-wait', gate.message, { blockers: gate.blockers });
|
|
486
|
+
if (gate.blocked) {
|
|
487
|
+
throw new DesktopOpsError('release-age-wait', gate.message + governanceNote, { blockers: gate.blockers, ...(governedMergedNames !== undefined ? { governanceMerged: governedMergedNames } : {}) });
|
|
488
|
+
}
|
|
489
|
+
if (gate.notice)
|
|
490
|
+
releaseAgeNotice = `;${gate.notice}`;
|
|
398
491
|
}
|
|
399
492
|
let change;
|
|
400
493
|
let bundleName;
|
|
494
|
+
let registerNote;
|
|
495
|
+
let managed;
|
|
401
496
|
try {
|
|
402
|
-
|
|
497
|
+
managed = await runManagedInstall(service, { pkg, spec, version, previousVersion: target.version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }, { governExclude: deps.governExclude, registerExclude: deps.registerExclude, packumentTimes: deps.packumentTimes });
|
|
498
|
+
change = managed.change;
|
|
499
|
+
bundleName = managed.bundleName;
|
|
500
|
+
registerNote = managed.registerNote;
|
|
403
501
|
}
|
|
404
502
|
catch (err) {
|
|
405
503
|
if (err instanceof DesktopBuildApprovalNeeded) {
|
|
406
|
-
return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message };
|
|
504
|
+
return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message + governanceNote };
|
|
505
|
+
}
|
|
506
|
+
if (err instanceof Error) {
|
|
507
|
+
if (governanceNote !== '' && !err.message.includes(governanceNote))
|
|
508
|
+
err.message += governanceNote;
|
|
509
|
+
if (managed?.governRetryNote !== undefined && !err.message.includes(managed.governRetryNote))
|
|
510
|
+
err.message += managed.governRetryNote;
|
|
407
511
|
}
|
|
408
512
|
throw err;
|
|
409
513
|
}
|
|
514
|
+
const retryNote = managed.governRetryNote !== undefined && !governanceNote.includes(managed.governRetryNote) ? managed.governRetryNote : '';
|
|
410
515
|
// 0.9.22 生效判定:npm 源升级在成功点分类;任何异常 fail-open 到 unknown,绝不影响升级成功态
|
|
411
516
|
let activation;
|
|
412
517
|
let needsRestart = true;
|
|
@@ -432,7 +537,7 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
|
|
|
432
537
|
...(compat !== null ? { compat } : {}),
|
|
433
538
|
...(compatSkipped ? { compatSkipped } : {}),
|
|
434
539
|
needsRestart,
|
|
435
|
-
output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
|
|
540
|
+
output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}${governanceNote}${retryNote}${releaseAgeNotice}${registerNote ?? ''}`,
|
|
436
541
|
via: 'desktop-manager',
|
|
437
542
|
...(change.application === 'overridden' ? { overridden: true } : {}),
|
|
438
543
|
fromVersion: target.version,
|
package/lib/core/registry.js
CHANGED
|
@@ -825,3 +825,14 @@ export async function loadDefaultRegistry(cfg = {}, opts = {}) {
|
|
|
825
825
|
}
|
|
826
826
|
return loadDefaultChain(cfg, opts, { namespace, profile, includeBundled: true });
|
|
827
827
|
}
|
|
828
|
+
/**
|
|
829
|
+
* verified 污染检测(know-how 022):`verified` 只记 **DSH 运行时**装机实测版本;
|
|
830
|
+
* 历史上反复出现把**插件自身版本号**误填进 verified 的事故(实证:dsh-quota-watch
|
|
831
|
+
* 条目曾出现 "0.1.0"——那是 quota-watch 的插件版本,不是任何 DSH 运行时版本)。
|
|
832
|
+
* 把条目 verified 与该插件自身 npm 已发布版本求交集,非空即高度可疑(warn 不 fail:
|
|
833
|
+
* 极小概率某代 DSH 与插件版本字面重合,人工复核后放行)。
|
|
834
|
+
*/
|
|
835
|
+
export function verifiedPollution(verified, publishedVersions) {
|
|
836
|
+
const own = new Set(publishedVersions);
|
|
837
|
+
return (verified ?? []).filter((v) => own.has(v));
|
|
838
|
+
}
|
package/lib/core/release-age.js
CHANGED
|
@@ -1,35 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 供应链等待期(minimumReleaseAge)预检与失败翻译(0.9.19 立基,0.9.x 按治理语义收敛,ADR-0009)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* operation-
|
|
6
|
-
* 1.
|
|
7
|
-
* `minimumReleaseAgeExclude`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* 实证基准(2026-10-03,本机 desktop profile,pnpm 11.7.0,官方管理器操作日志
|
|
5
|
+
* operation-zsc4ei / operation-L8oq1z / operation-l5fXEd):
|
|
6
|
+
* 1. 默认策略(未显式设置 age、未开 strict = 非严格)下,显式点名的「太新」目标
|
|
7
|
+
* 直接放行安装,pnpm 自动向 pnpm-workspace.yaml 的 `minimumReleaseAgeExclude`
|
|
8
|
+
* 追加 `pkg@ver` 条目("Added 1 entry …");
|
|
9
|
+
* 2. pnpm 的 evaluateVersionPolicy **每包名只认第一条排除规则**,同名后续规则死亡
|
|
10
|
+
* (dshmarket #732 实证,首条规则生效机理)——死规则会让等待期内全部包操作被
|
|
11
|
+
* 锁文件级校验拦死(0:15 quota-watch 连坐实录);
|
|
12
12
|
* 3. 校验失败前目标包可能已写入 node_modules,官方管理器只回滚 manifest/lockfile
|
|
13
|
-
*
|
|
13
|
+
* →「账实分裂」,下次包操作静默回退。
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
* ① 委派前预检(releaseAgePrecheck
|
|
17
|
-
*
|
|
15
|
+
* 职责(ADR-0009):
|
|
16
|
+
* ① 委派前预检(releaseAgePrecheck):仅「显式设置 age 或 strict 开启」时对窗口内
|
|
17
|
+
* 目标拒绝(附可重试时刻);默认策略放行并返回陈述性 notice。锁内坏形态由
|
|
18
|
+
* exclude-governance 的治理挂点处理,本模块不再预拒绝(0.9.19 ②腿退役)。
|
|
18
19
|
* ② 失败翻译(describeReleaseAgeFailure):解析全部违规条目,按「本次目标 vs
|
|
19
|
-
*
|
|
20
|
+
* 旁包」分述发布时刻与可重试时刻。
|
|
21
|
+
* ③ cachedPackumentTimes:预检与登记共用的带缓存 packument 探测。
|
|
20
22
|
*
|
|
21
|
-
* fail-open 纪律:发布时刻不可得 / 策略文件不可读 / profileDir 缺席 /
|
|
22
|
-
*
|
|
23
|
+
* fail-open 纪律:发布时刻不可得 / 策略文件不可读 / profileDir 缺席 / 命中任一
|
|
24
|
+
* 排除规则(首条规则口径)→ 一律放行;pnpm 仍是最终执行者。
|
|
23
25
|
*/
|
|
24
26
|
import { readFile } from 'node:fs/promises';
|
|
25
27
|
import { join } from 'node:path';
|
|
26
28
|
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
|
27
29
|
import { fetchJsonLimited } from './httpx.js';
|
|
28
|
-
import { isExactVersion } from './versions.js';
|
|
29
30
|
/** pnpm 11.7 实机测得的策略窗口:24h(operation-l5fXEd 的 cutoff = 检查时刻 − 1440min)。 */
|
|
30
31
|
export const DEFAULT_MINIMUM_RELEASE_AGE_MIN = 1440;
|
|
31
|
-
/** 锁内「独立精确排除条目」旁包探测上限(防清单失控;超出部分放弃预判,交 pnpm 兜底)。 */
|
|
32
|
-
export const MAX_EXCLUDE_PROBES = 8;
|
|
33
32
|
function scalarNumber(value) {
|
|
34
33
|
if (typeof value === 'number' && Number.isFinite(value))
|
|
35
34
|
return value;
|
|
@@ -43,7 +42,7 @@ function scalarNumber(value) {
|
|
|
43
42
|
* 调用方按默认窗口 + 无排除处理,绝不抛。
|
|
44
43
|
*/
|
|
45
44
|
export function parseWorkspacePolicy(text) {
|
|
46
|
-
const empty = { minimumReleaseAgeMin: null, excludes: [] };
|
|
45
|
+
const empty = { explicitAge: false, minimumReleaseAgeMin: null, strict: false, excludes: [] };
|
|
47
46
|
let document;
|
|
48
47
|
try {
|
|
49
48
|
document = parseDocument(text);
|
|
@@ -62,10 +61,17 @@ export function parseWorkspacePolicy(text) {
|
|
|
62
61
|
}
|
|
63
62
|
}
|
|
64
63
|
}
|
|
65
|
-
// yaml 库的 Map.get() 对标量返回已解包的原始值(number/string),对集合返回节点——两种形态都兜住
|
|
64
|
+
// yaml 库的 Map.get() 对标量返回已解包的原始值(number/string/boolean),对集合返回节点——两种形态都兜住
|
|
66
65
|
const rawAge = document.contents.get('minimumReleaseAge');
|
|
67
66
|
const age = isScalar(rawAge) ? scalarNumber(rawAge.value) : scalarNumber(rawAge);
|
|
68
|
-
|
|
67
|
+
const rawStrict = document.contents.get('minimumReleaseAgeStrict');
|
|
68
|
+
const strict = rawStrict === true || (isScalar(rawStrict) && rawStrict.value === true);
|
|
69
|
+
return {
|
|
70
|
+
explicitAge: rawAge !== undefined && rawAge !== null,
|
|
71
|
+
minimumReleaseAgeMin: age !== null && age > 0 ? age : null,
|
|
72
|
+
strict,
|
|
73
|
+
excludes,
|
|
74
|
+
};
|
|
69
75
|
}
|
|
70
76
|
export function excludeMatch(selector, pkg, version) {
|
|
71
77
|
const s = selector.trim();
|
|
@@ -73,7 +79,7 @@ export function excludeMatch(selector, pkg, version) {
|
|
|
73
79
|
return 'no';
|
|
74
80
|
if (s.includes('||')) {
|
|
75
81
|
for (const part of s.split('||')) {
|
|
76
|
-
if (excludeMatch(part.trim(), pkg, version)
|
|
82
|
+
if (excludeMatch(part.trim(), pkg, version) === 'effective')
|
|
77
83
|
return 'effective';
|
|
78
84
|
}
|
|
79
85
|
return 'no';
|
|
@@ -83,30 +89,7 @@ export function excludeMatch(selector, pkg, version) {
|
|
|
83
89
|
// 包名级(dshmarket)或裸版本号段(只应出现在复合条目内,顶层出现视为包名比较)
|
|
84
90
|
return s === pkg || s === version ? 'effective' : 'no';
|
|
85
91
|
}
|
|
86
|
-
|
|
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('@') };
|
|
92
|
+
return s.slice(0, at) === pkg && s.slice(at + 1) === version ? 'effective' : 'no';
|
|
110
93
|
}
|
|
111
94
|
const VIOLATION_RE = /(\S+@\S+) was published at ([^,]+), within the minimumReleaseAge cutoff \(([^)]+)\)/g;
|
|
112
95
|
/** 解析 pnpm 诊断里的全部等待期违规条目(0.9.10 只取第一条是「报错报错包」的根因之一)。 */
|
|
@@ -196,72 +179,65 @@ export async function readWorkspacePolicy(profileDir) {
|
|
|
196
179
|
}
|
|
197
180
|
const packumentCache = new Map();
|
|
198
181
|
const PACKUMENT_CACHE_TTL_MS = 5 * 60_000;
|
|
182
|
+
/**
|
|
183
|
+
* 带进程内缓存的 packument time 探测(TTL 5min):预检与登记(exclude-governance)
|
|
184
|
+
* 共用——同一操作里预检刚拉过的 packument 不二次拉取(8MiB 级全量)。
|
|
185
|
+
*/
|
|
186
|
+
export async function cachedPackumentTimes(pkg, timeoutMs = 12_000, signal) {
|
|
187
|
+
const hit = packumentCache.get(pkg);
|
|
188
|
+
if (hit && Date.now() - hit.at < PACKUMENT_CACHE_TTL_MS)
|
|
189
|
+
return hit.times;
|
|
190
|
+
const times = await npmPackumentTimes(pkg, timeoutMs, signal);
|
|
191
|
+
packumentCache.set(pkg, { at: Date.now(), times });
|
|
192
|
+
return times;
|
|
193
|
+
}
|
|
199
194
|
/** 测试钩子:清空 packument 进程内缓存。 */
|
|
200
195
|
export function _resetReleaseAgeCachesForTests() {
|
|
201
196
|
packumentCache.clear();
|
|
202
197
|
}
|
|
203
198
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
199
|
+
* 委派前预检(ADR-0009 收敛语义):仅当策略显式设置 age 或开启 strict 时,对窗口内
|
|
200
|
+
* 且未被任一排除规则覆盖(首条规则口径)的目标结构化拒绝(附可重试时刻);默认
|
|
201
|
+
* 策略(非严格)放行并返回陈述性 notice——官方管理器对显式点名的新版本会直接
|
|
202
|
+
* 安装并自动登记,锁内坏形态由 exclude-governance 的治理挂点处理。零写操作;
|
|
203
|
+
* fail-open:发布时刻不可得 / profileDir 缺席 / 命中排除规则 → 放行。
|
|
206
204
|
*/
|
|
207
205
|
export async function releaseAgePrecheck(input) {
|
|
208
|
-
// profileDir 缺席 =
|
|
206
|
+
// profileDir 缺席 = 读不到操作员排除条目与策略 → 不预判
|
|
209
207
|
if (!input.profileDir)
|
|
210
208
|
return { blocked: false };
|
|
211
209
|
const now = input.nowMs ?? Date.now();
|
|
212
210
|
const policy = input.deps?.workspacePolicy
|
|
213
211
|
? await input.deps.workspacePolicy(input.profileDir)
|
|
214
212
|
: await readWorkspacePolicy(input.profileDir);
|
|
215
|
-
const
|
|
213
|
+
const explicit = policy?.explicitAge ?? false;
|
|
214
|
+
const strict = policy?.strict ?? false;
|
|
215
|
+
const policyMin = explicit && policy?.minimumReleaseAgeMin != null ? policy.minimumReleaseAgeMin : DEFAULT_MINIMUM_RELEASE_AGE_MIN;
|
|
216
216
|
const probe = async (pkg) => {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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;
|
|
217
|
+
if (input.deps?.packumentTimes)
|
|
218
|
+
return input.deps.packumentTimes(pkg, Math.min(input.timeoutMs ?? 20_000, 12_000), input.signal);
|
|
219
|
+
return cachedPackumentTimes(pkg, 12_000, input.signal);
|
|
223
220
|
};
|
|
224
|
-
const blockers = [];
|
|
225
|
-
// ① 本次目标版本的发布时刻(有效排除条目覆盖 → 放行)
|
|
226
221
|
const times = await probe(input.pkg);
|
|
227
222
|
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)
|
|
223
|
+
if (!Number.isFinite(publishedAt))
|
|
257
224
|
return { blocked: false };
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
225
|
+
const exempt = (policy?.excludes ?? []).some((sel) => excludeMatch(sel, input.pkg, input.version) === 'effective');
|
|
226
|
+
const deadline = publishedAt + policyMin * 60_000;
|
|
227
|
+
if (exempt || now >= deadline)
|
|
228
|
+
return { blocked: false };
|
|
229
|
+
if (!explicit && !strict) {
|
|
230
|
+
return {
|
|
231
|
+
blocked: false,
|
|
232
|
+
notice: `目标 ${input.pkg}@${input.version} 发布于 ${fmtLocal(publishedAt)}(等待期内):官方管理器将按显式点名安装,装好后 dsh-m 会登记豁免条目`,
|
|
233
|
+
};
|
|
263
234
|
}
|
|
264
|
-
const
|
|
265
|
-
lines.push(
|
|
266
|
-
|
|
235
|
+
const lines = ['官方管理器的供应链策略(minimumReleaseAge)将拦截本次安装,已提前拒绝(未触碰 profile 文件):'];
|
|
236
|
+
lines.push(`· 目标 ${input.pkg}@${input.version}:发布于 ${fmtLocal(publishedAt)},预计 ${fmtLocal(deadline)} 后满等待期`);
|
|
237
|
+
lines.push(`预计 ${fmtLocal(deadline)} 后可重试;等待期内也可由操作员修正 pnpm-workspace.yaml 的排除条目形态(包名级/复合)解除拦截`);
|
|
238
|
+
return {
|
|
239
|
+
blocked: true,
|
|
240
|
+
message: lines.join('\n'),
|
|
241
|
+
blockers: [{ entry: `${input.pkg}@${input.version}`, role: 'target', publishedAt, deadline }],
|
|
242
|
+
};
|
|
267
243
|
}
|
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -99,10 +99,10 @@
|
|
|
99
99
|
{
|
|
100
100
|
"id": "dsh-copilot-auth",
|
|
101
101
|
"name": "DSH Copilot Auth",
|
|
102
|
-
"description": "
|
|
102
|
+
"description": "内置 Copilot 登录注销页与开箱路由;目录兜底不覆盖定制,桌面版不支持目录刷新。已适配 0.1.7-rc.2 与 0.2.0-rc.1/rc.2。",
|
|
103
103
|
"category": "self-dev",
|
|
104
104
|
"tags": ["Copilot", "认证", "模型接入", "工具"],
|
|
105
|
-
"verified": ["0.1.7-rc.2", "0.2.0-rc.1"],
|
|
105
|
+
"verified": ["0.1.7-rc.2", "0.2.0-rc.1", "0.2.0-rc.2"],
|
|
106
106
|
"source": "npm",
|
|
107
107
|
"npm": "@inventec/dsh-copilot-auth",
|
|
108
108
|
"github": "iasiv5/dsh-copilot-auth",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"description": "GLM 与 Copilot 额度悬浮球:胶囊、详情面板与侧栏卡片,宿主解析凭据,适配 web 与 desktop。",
|
|
115
115
|
"category": "self-dev",
|
|
116
116
|
"tags": ["配额", "悬浮球", "GLM", "Copilot"],
|
|
117
|
-
"verified": ["0.1.7-rc.2", "0.
|
|
117
|
+
"verified": ["0.1.7-rc.2", "0.2.0-rc.2"],
|
|
118
118
|
"source": "npm",
|
|
119
119
|
"npm": "@iasiv5/dsh-quota-watch",
|
|
120
120
|
"github": "iasiv5/dsh-quota-watch",
|