dsh-plugin-update 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  格式为 Keep-a-Changelog 子集:`Added/Fixed/Changed` 必写,`Deprecated/Removed/Security` 有则透传,`Unreleased` 面板忽略,禁止 git-log 直倒。面板缺日志时中性提示,不挡安装。
4
4
 
5
+ ## [0.4.0] - 2026-10-05
6
+
7
+ ### Added
8
+ - 更新日志无脑接入(#38):宿主新增 `updateChangelog` 电话(入参版本号,回该版 tarball 内全文,取不到回空;同源 + 精确路径 + `integrity` + 三墙复用,按版本记住结果);面板 `autoChangelog` 默认自动展示(有新版调一次,显式文本仍赢,`false` 退回手动);入口件透传跟上;批量详情行按行自动取(无新增批量电话,单行失败只影响该行);HTTP 版白名单放行,老三电话形状不变。
9
+ - 按钮交互反馈:查新版/安装在途置忙(禁用 + 文案 + `aria-busy` + 纯 CSS 转圈,并发连点只认第一次);默认主题补 hover / 按下下沉 / 过渡;busy 脉冲与 `prefers-reduced-motion` 关闭。
10
+ - 原生 `title` 说明:单面板全部动作按钮、批量宏按钮带悬停一句话(零成本 tooltip,不引入浮层组件)。
11
+ - Toast 语义:复制/重启类回执 5 秒后自动过期,不靠下次点击才消失。
12
+ - 首帧骨架:快照没到之前版本条画微光占位(`aria-hidden`,不进语义)。
13
+ - 批量「取消这一批」鼠标两步确认(第一次上膛红框,点别的自动卸膛;程序调 `act('cancel')` 仍一次即执行)。
14
+ - Esc 关弹窗:单面板 / 批量面板 dialog 与入口件 dialog 均支持(容器契约内,只用 innerHTML + 事件)。
15
+
16
+ ## [0.3.1] - 2026-10-05
17
+
18
+ ### Added
19
+ - 新增 `dsh-plugin-update/http`:`createHttpCall`(单三电话 + 批量五电话同一内核,`batchPrefix` 独立)+ `mountUpdatePanelHttp`/`mountUpdateEntryHttp`/`mountUpdateBatchPanelHttp`,只传 pluginId + prefix/batchPrefix + baseUrl/routes 即跑通检查/安装/待重启/失败与批量总账/明细/动作;queue/env/session/rows/progress/diag 全透传,传输失败走 `http-transport-failed` 异常通道,取消整批走 `batchCancel` 电话。
20
+
5
21
  ## [0.3.0] - 2026-10-04
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  给 DSH 插件加「检查更新 / 安装更新」能力的 npm 包。宿主侧一段接线,面板侧挂一个现成组件,装不上时给用户一条可复制的手工命令。
4
4
 
5
- 要求 Node 22 或更高,零运行时依赖。当前版本 `0.3.0`。
5
+ 要求 Node 22 或更高,零运行时依赖。当前版本 `0.4.0`。
6
6
 
7
7
  装上它你会拿到六样东西:
8
8
 
9
- - **三个电话**:查状态(只读本地)、查新版(用户点了才联网一次)、装更新(拿凭证提交)。电话指宿主对外提供的方法。
9
+ - **四个电话**:查状态(只读本地)、查新版(用户点了才联网一次)、装更新(拿凭证提交)、取日志(按版取更新日志,全文或空)。电话指宿主对外提供的方法。
10
10
  - **一套落盘**:任务状态、安装锁、回滚凭据,按「插件标识 + 使用范围」隔离,多插件互不干扰。
11
11
  - **面板要的派生取值**:电话名与轮询间隔,构建期从本包生成,面板里不写死。
12
- - **一个现成整组件**:默认内嵌、可切弹窗,调用者传参指定;轮询、安装门控、中文一句话、待重启横幅、手工命令展示与复制、排队可见开关、跳过与恢复、诊断一键复制全在组件内部消化。
12
+ - **一个现成整组件**:默认内嵌、可切弹窗,调用者传参指定;轮询、安装门控、中文一句话、待重启横幅、手工命令展示与复制、排队可见开关、跳过与恢复、诊断一键复制、**更新日志自动展示**全在组件内部消化。
13
13
  - **一个更新入口件**:配置页一行挂上就是「检查更新」按钮(或只给状态点的徽标、或整块内嵌),按钮文案随状态自己变;见第 2.5 节。
14
14
  - **一套多目标批量更新**:一个插件管 N 个插件的更新(总账 + 明细 + 动作,一家收尾才起下一家,会话落盘可断点续跑);见第 2.6 节。
15
15
 
@@ -52,7 +52,7 @@ for (const [name, handler] of Object.entries(update.handlers)) {
52
52
  }
53
53
  ```
54
54
 
55
- 这一步得到三个电话名:`notes.updateStatus`、`notes.updateCheck`、`notes.updateInstall`。
55
+ 这一步得到四个电话名:`notes.updateStatus`、`notes.updateCheck`、`notes.updateInstall`、`notes.updateChangelog`(取日志:给版本号,回该版 tarball 里的更新日志全文,取不到回空)。
56
56
 
57
57
  `pluginId` 必填(非空字符串,不含路径分隔符)。单例复用键强制含插件标识,多插件不串内存状态与锁。
58
58
  除 `pluginId` 之外的配置都可选并带默认值,不传即走默认(见第 3 节)。
@@ -78,6 +78,7 @@ createHostUpdate(
78
78
  | `….updateStatus` | `{}` | `{ ok: true, snapshot, manual, receipt: null }` | `{ ok: false, error, errorKind, diag? }` |
79
79
  | `….updateCheck` | `{}` | `{ ok: true, snapshot, manual, receipt }` | 同上 |
80
80
  | `….updateInstall` | `{ checkId, requestId }` | `{ ok: true, snapshot, manual, receipt: null }` | 同上 |
81
+ | `….updateChangelog` | `{ version }` | `{ ok: true, version, markdown: string \| null }` | 同上 |
81
82
 
82
83
  - `snapshot` 恒为第 5.1 节那六个字段;`manual` 是第 5.3 节那条手工命令(能给则给,不能给为 `null`)。
83
84
  - `receipt` 只有查新版给(`{ checkId, checkedAt, expiresAt }`);装更新时把 `checkId` 原样带回来,`requestId` 由面板自己生成(同一个编号重复提交直接返回旧结果)。
@@ -144,6 +145,21 @@ mountUpdatePanel(slot, { pluginId: 'my-notes-plugin', prefix: 'notes', call: hos
144
145
 
145
146
  类型定义随包分发(`dsh-plugin-update/panel` 的 `.d.ts`),不用自编译;面板离线可读,与包版本绑定。
146
147
 
148
+ 整组件 HTTP 版(无 `host.call` 环境即跑:浏览器面板经 HTTP POST 直达宿主网关,只传三样):
149
+
150
+ ```js
151
+ import { mountUpdatePanelHttp } from 'dsh-plugin-update/http'
152
+
153
+ mountUpdatePanelHttp(document.querySelector('#upd'), {
154
+ pluginId: 'my-plugin',
155
+ prefix: 'myplug',
156
+ baseUrl: 'https://host.local:3000/upd',
157
+ showOthers: false,
158
+ pollMs: 1000,
159
+ })
160
+ // 离开时 panel.unmount():只停轮询,安装在宿主侧继续跑。
161
+ ```
162
+
147
163
  ### 第 2.5 节:更新入口件(配置页上那一颗按钮)
148
164
 
149
165
  目标只有一句:**让用户不用点开就知道有没有事。** 一行挂上:
@@ -221,10 +237,23 @@ const panel = mountUpdateBatchPanel(el, {
221
237
  })
222
238
  ```
223
239
 
240
+ 批量面板 HTTP 版(无 `host.call` 环境即跑:同一传输内核,五电话走同一映射,取消走 `batchCancel`):
241
+
242
+ ```js
243
+ import { mountUpdateBatchPanelHttp } from 'dsh-plugin-update/http'
244
+
245
+ mountUpdateBatchPanelHttp(document.querySelector('#batch'), {
246
+ batchPrefix: 'life',
247
+ baseUrl: 'https://host.local:3000/upd',
248
+ pollMs: 1500,
249
+ })
250
+ // 取消整批走 batchCancel 电话;unmount 只停轮询,宿主侧照跑。
251
+ ```
252
+
224
253
  三条硬约束(都在实现里):**一行只回答一个问题**(这家的下一步是什么,状态词全中文可执行);
225
254
  **行内动作只作用于该行**,「全部更新」是宏而不是第二个状态机;**待重启与失败常驻横幅**,不藏进展开里。
226
255
 
227
- 点任意一行展开该家详情:**内容是单插件那套五章内核的只读渲染,动作由批量面板自己提供**。
256
+ 点任意一行展开该家详情:**内容是单插件那套五章内核的只读渲染,动作由批量面板自己提供**。第 02 章日志由批量面板按行自动取该行自己的 `updateChangelog` 电话(按行+版本记住结果,取不到即中性提示,不挡安装;`autoChangelog: false` 可关)。
228
257
  内核渲染时传 `actions: 'none'`——五章内容、进度条、「已跳过」提示一字不减,**动作按钮一个都不画**
229
258
  (内核里那些按钮带的是 `data-action`,批量面板只认 `data-act`;照搬 markup 而不接管行为,
230
259
  就会得到「可点却没反应」的死按钮——这条缝现在由渲染开关焊死)。详情里的动作行是批量面板自己的:
@@ -246,11 +275,33 @@ const panel = mountUpdateBatchPanel(el, {
246
275
  **跨使用范围如实拒绝**:`web` 与 `desktop` 各自排队(装的是不同落点),混在一起的目标会回
247
276
  `cross-scope`,本包不会替你跨范围抢锁。
248
277
 
278
+ ### 第 2.7 节:更新日志自动展示(告诉用户更新了什么)
279
+
280
+ 任何插件的升级能力都是两件事:**① 给一套面板**(上面第 4 步 / 2.5 / 2.6,挂载一行即跑);**② 告诉用户这次更新了什么**(本节,写 md 即显示,零新增代码)。
281
+
282
+ ```js
283
+ import { mountUpdatePanel } from 'dsh-plugin-update/panel'
284
+
285
+ // 与第 4 步同一行,不用加任何参数:有新版时第 02 章“更新日志”自动出现
286
+ const panel = mountUpdatePanel(document.getElementById('update-slot'), {
287
+ pluginId: 'my-notes-plugin',
288
+ prefix: 'notes',
289
+ call: (name, args) => host.call(name, args),
290
+ })
291
+ ```
292
+
293
+ 作者侧只写文件,不写代码(发布后用户自动看到):
294
+
295
+ 1. 包根放 `CHANGELOG.md`(文件名全大写,小写视为没有),版本节形如 `## [1.2.0] - 2026-10-05`(最新在前),分类用 `### Added/Fixed/Changed`(面板展开;`Deprecated/Removed/Security` 折叠;`Unreleased` 与空节忽略)。
296
+ 2. `package.json` 的 `files` 白名单加上 `CHANGELOG.md`(否则发出去的包里没有它,面板永远读不到),发布前跑一次 `npm publish --dry-run` 核对。
297
+
298
+ 行为:有新版时面板按新版号自动取一次该版 tarball 里的全文(复用官方源 + `integrity` 校验,同一版本只取一次);删了文件或取不到即中性提示,**安装永远不受影响**。想自己接管就传 `autoChangelog: false` 退回手动(备好文本后 `mountUpdatePanel({ changelogMarkdown })` 或 `setChangelogMarkdown` 传入,显式文本永不被覆盖)。入口件与批量面板同样自动:前者透传,后者展开行按行取。细节与边界见第 5.8 节。
299
+
249
300
  ### 升级本包(已经接入过的项目)
250
301
 
251
302
  宿主种类与安装出口都由本包自己探测和选择,**升级依赖即可,宿主侧与面板侧都不用改代码**:
252
303
 
253
- 1. 依赖版本提到 `^0.3.0`。
304
+ 1. 依赖版本提到 `^0.4.0`。
254
305
  2. 重新装 / 发一版你自己的插件,让新依赖进当前使用范围(运行时用的是 `node_modules` 里那份)。
255
306
  3. 如果你自己接过一版宿主安装出口,把它删掉——它会挡在本包的路由前面。
256
307
 
@@ -398,7 +449,8 @@ dsh plugin --profile my-web add --save-exact my-notes-plugin@1.2.3 --registry=ht
398
449
 
399
450
  - 有新版时面板在横幅下方展示“更新说明(当前版 → 新版):”,按目标包内 `CHANGELOG.md`(Keep-a-Changelog 子集)渲染:`Added/Fixed/Changed` 展开,`Deprecated/Removed/Security` 折叠,`Unreleased` 与空节不展示。
400
451
  - 作者未提供说明时显示“作者未提供更新说明,安装不受影响。”——缺日志永不挡安装,不改变 `canInstall` 与 `blockedReason`。
401
- - 说明文本由集成方备好后传入:已装版离线读本机 `node_modules/<目标包>/CHANGELOG.md`,新版按需取新版 tarball 内同名文件(复用官方源与 `integrity` 校验,取不到即回落中性提示;参考包根导出的 `readInstalledChangelogText` / `fetchReleaseChangelogText`),经 `mountUpdatePanel({ changelogMarkdown })` 或 `setChangelogMarkdown` 交给面板。
452
+ - 默认自动:面板看到有新版即按新版号调一次 `….updateChangelog`,回来自己填进第 02 章;同一版本只取一次,取不到即中性提示。作者侧只要写好包根 `CHANGELOG.md` 并随包发布,零新增代码。
453
+ - 手动模式(老用法照旧):传 `autoChangelog: false` 即退回手动——已装版离线读本机 `node_modules/<目标包>/CHANGELOG.md`,新版按需取新版 tarball 内同名文件(复用官方源与 `integrity` 校验,取不到即回落中性提示;参考包根导出的 `readInstalledChangelogText` / `fetchReleaseChangelogText`),经 `mountUpdatePanel({ changelogMarkdown })` 或 `setChangelogMarkdown` 交给面板;显式传过的文本自动链路永不覆盖。
402
454
 
403
455
  ## 6. 排错
404
456
 
@@ -438,6 +490,25 @@ import { mountUpdatePanel } from 'dsh-plugin-update/panel'
438
490
  mountUpdatePanel(slot, { pluginId: 'notes', prefix: 'notes', call: host.call })
439
491
  ```
440
492
 
493
+ **更新日志自动**(#38:写 md 即显示,零新增代码):
494
+
495
+ ```js
496
+ // 宿主侧:createHostUpdate 顺手注册第 4 个电话,无新增配置
497
+ // 入参 { version }(发行版号,先验 validReleaseVersion + 通道门禁)
498
+ // 成功 { ok: true, version, markdown: string | null }(取不到即 null,中性提示)
499
+ // 失败复用 error / errorKind 体系;回包无路径、无快照;按版本记住结果
500
+ import { buildChangelogPhoneName } from 'dsh-plugin-update'
501
+
502
+ buildChangelogPhoneName('notes') // 'notes.updateChangelog',与宿主侧同一套拼法
503
+
504
+ // 面板侧:默认自动,有新版调一次;显式文本仍赢,false 退回手动
505
+ mountUpdatePanel(slot, { pluginId: 'my-notes-plugin', prefix: 'notes', call: host.call })
506
+ // mountUpdatePanel(slot, { pluginId, prefix, call, autoChangelog: false })
507
+ // mountUpdatePanel(slot, { pluginId, prefix, call, changelogMarkdown }) // 手动模式
508
+ ```
509
+
510
+ - 入口件(`dsh-plugin-update/entry`)把 `autoChangelog / changelogMarkdown` 透传给面板 dialog/inline;批量面板(`dsh-plugin-update/panel-batch`)展开行自动调该行自己的 `updateChangelog`(无新增批量电话,`autoChangelog: false` 可关整批);HTTP 版(`dsh-plugin-update/http`)白名单已放行,默认路径即达。
511
+
441
512
  **客户端入口**(`dist/client.js`,构建期打包用):
442
513
 
443
514
  ```js
@@ -478,6 +549,6 @@ queuePositionOf(queueState, 'my-plugin', requestId) // 0 = 在装,1..n = 顺
478
549
 
479
550
  ## 9. 兼容与稳定性
480
551
 
481
- 这些形状稳定,可以放心依赖:三个电话名与入参回参、快照六字段、任务公开形状、配置只经函数入参注入、安装配方五键、日志事件字段基线(三个事件各带必填 `pluginId`)、历史落盘路径。
552
+ 这些形状稳定,可以放心依赖:四个电话名与入参回参(老三电话一字不动,`updateChangelog` 是新增)、快照六字段、任务公开形状、配置只经函数入参注入、安装配方五键、日志事件字段基线(三个事件各带必填 `pluginId`)、历史落盘路径。
482
553
 
483
554
  向后兼容的扩展:新增可选配置键、新增可选 `readerOverrides`(如 `targetPackageDir`)、新增宿主种类与路由取值。调用方不认新取值时按普通宿主处理即可,不会因此报错。
package/dist/client.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { type PhoneAction } from './config.js';
2
- export { buildPhoneName, buildPhoneNames } from './config.js';
3
- export type { PhoneAction } from './config.js';
1
+ import { type ChangelogPhoneAction, type PhoneAction } from './config.js';
2
+ export { buildChangelogPhoneName, buildPhoneName, buildPhoneNames } from './config.js';
3
+ export type { ChangelogPhoneAction, PhoneAction } from './config.js';
4
4
  export { manualCommand } from './commands.js';
5
5
  export { QUEUE_INTENT_TTL_MS, cancelEnqueuedInQueue, emptyQueueState, enqueueInQueue, isHeadOfQueue, isQueueBusy, normalizeQueueState, pruneExpiredIntents, queuePositionOf, releaseOwnerInQueue, setOwnerIfFree, visibleQueueFor, } from './queue.js';
6
6
  export type { QueuedEntry, QueueOwner, UpdateQueueState, VisibleQueue, VisibleQueueOwner } from './queue.js';
@@ -12,5 +12,5 @@ export declare const CLIENT_POLL: {
12
12
  readonly defaultMs: 1000;
13
13
  readonly minMs: 250;
14
14
  };
15
- export declare function buildClientPhoneNames(prefix: string): Record<PhoneAction, string>;
15
+ export declare function buildClientPhoneNames(prefix: string): Record<PhoneAction, string> & Record<ChangelogPhoneAction, string>;
16
16
  export declare function assertPollInterval(ms: unknown): number;
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // AUTO-GENERATED by node build.mjs — DO NOT EDIT. Source: src/client.ts
2
2
  import { buildPhoneNames, DEFAULT_PANEL_POLL_MS, MIN_PANEL_POLL_MS } from "./config.js";
3
- import { buildPhoneName, buildPhoneNames as buildPhoneNames2 } from "./config.js";
3
+ import { buildChangelogPhoneName, buildPhoneName, buildPhoneNames as buildPhoneNames2 } from "./config.js";
4
4
  import { manualCommand as manualCommand2 } from "./commands.js";
5
5
  import {
6
6
  QUEUE_INTENT_TTL_MS,
@@ -66,6 +66,7 @@ export {
66
66
  batchEntryOf,
67
67
  batchProgress,
68
68
  batchRequestId,
69
+ buildChangelogPhoneName,
69
70
  buildClientPhoneNames,
70
71
  buildPhoneName,
71
72
  buildPhoneNames2 as buildPhoneNames,
package/dist/config.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export declare const DEFAULT_PREFIX = "wf";
2
2
  export declare const PHONE_ACTIONS: readonly ["updateStatus", "updateCheck", "updateInstall"];
3
3
  export type PhoneAction = (typeof PHONE_ACTIONS)[number];
4
+ export declare const CHANGELOG_PHONE_ACTION: "updateChangelog";
5
+ export type ChangelogPhoneAction = typeof CHANGELOG_PHONE_ACTION;
4
6
  export declare const DEFAULT_TARGET_PACKAGE = "dsh-mattpocock-skills-deck";
5
7
  export declare const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
6
8
  export declare const LEGACY_PLUGIN_ID = "dsh-mattpocock-skills-deck";
@@ -44,5 +46,6 @@ export interface ResolvedUpdateConfig {
44
46
  export declare function assertPluginId(value: unknown): string;
45
47
  export declare function assertPrefix(value: unknown, role: string): string;
46
48
  export declare function resolveUpdateConfig(input: UpdateConfigInput): ResolvedUpdateConfig;
47
- export declare function buildPhoneNames(prefix: string): Record<PhoneAction, string>;
49
+ export declare function buildPhoneNames(prefix: string): Record<PhoneAction, string> & Record<ChangelogPhoneAction, string>;
50
+ export declare function buildChangelogPhoneName(prefix: string): string;
48
51
  export declare function buildPhoneName(prefix: string, action: PhoneAction): string;
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // AUTO-GENERATED by node build.mjs — DO NOT EDIT. Source: src/config.ts
2
2
  const DEFAULT_PREFIX = "wf";
3
3
  const PHONE_ACTIONS = ["updateStatus", "updateCheck", "updateInstall"];
4
+ const CHANGELOG_PHONE_ACTION = "updateChangelog";
4
5
  const DEFAULT_TARGET_PACKAGE = "dsh-mattpocock-skills-deck";
5
6
  const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
6
7
  const LEGACY_PLUGIN_ID = "dsh-mattpocock-skills-deck";
@@ -73,14 +74,19 @@ function buildPhoneNames(prefix) {
73
74
  return {
74
75
  updateStatus: checked + ".updateStatus",
75
76
  updateCheck: checked + ".updateCheck",
76
- updateInstall: checked + ".updateInstall"
77
+ updateInstall: checked + ".updateInstall",
78
+ updateChangelog: checked + ".updateChangelog"
77
79
  };
78
80
  }
81
+ function buildChangelogPhoneName(prefix) {
82
+ return buildPhoneNames(prefix).updateChangelog;
83
+ }
79
84
  function buildPhoneName(prefix, action) {
80
85
  return buildPhoneNames(prefix)[action];
81
86
  }
82
87
  export {
83
88
  BACKUP_FILE,
89
+ CHANGELOG_PHONE_ACTION,
84
90
  DEFAULT_CHECK_TIMEOUT_MS,
85
91
  DEFAULT_CONFIRMATION_TTL_MS,
86
92
  DEFAULT_INSTALL_TIMEOUT_MS,
@@ -99,6 +105,7 @@ export {
99
105
  STATE_FILE,
100
106
  assertPluginId,
101
107
  assertPrefix,
108
+ buildChangelogPhoneName,
102
109
  buildPhoneName,
103
110
  buildPhoneNames,
104
111
  resolveUpdateConfig
package/dist/entry.d.ts CHANGED
@@ -31,6 +31,12 @@ export interface UpdateEntryOptions {
31
31
  label?: string;
32
32
  profileName?: string;
33
33
  pollMs?: number;
34
+ /**
35
+ * 更新日志自动/手动(#38):透传给面板(dialog 与 inline 同走 mountPanel),默认自动;
36
+ * 显式文本仍赢,false 退回手动。入口件自己不取数。
37
+ */
38
+ autoChangelog?: boolean;
39
+ changelogMarkdown?: string | null;
34
40
  /** variant='badge' 或 openOn='manual' 时,点击交给接入方(自己跳自己的页面)。 */
35
41
  onActivate?: (state: {
36
42
  hasUpdate: boolean;
package/dist/entry.js CHANGED
@@ -48,6 +48,13 @@ const UPDATE_ENTRY_CSS = [
48
48
  ".dsh-upd-entry-btn{font:inherit;border:1px solid var(--dsh-upd-line,#d1d5db);border-radius:6px;",
49
49
  "background:var(--dsh-upd-btn,#f9fafb);color:inherit;padding:4px 12px;cursor:pointer}",
50
50
  ".dsh-upd-entry-btn:hover{border-color:var(--dsh-upd-primary,#2563eb)}",
51
+ ".dsh-upd-entry-btn,.dsh-upd-entry-dot{transition:background-color .15s ease,border-color .15s ease,color .15s ease,transform .06s ease}",
52
+ ".dsh-upd-entry-btn:active:not(:disabled){transform:translateY(1px)}",
53
+ ".dsh-upd-entry-btn:disabled,.dsh-upd-entry-dot:disabled{opacity:.55;cursor:wait}",
54
+ '.dsh-upd-entry-btn[aria-busy="true"]::after{content:"";display:inline-block;width:10px;height:10px;margin-left:7px;vertical-align:-1px;',
55
+ "border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:dsh-upd-entry-spin .8s linear infinite}",
56
+ "@keyframes dsh-upd-entry-spin{to{transform:rotate(360deg)}}",
57
+ '@media (prefers-reduced-motion: reduce){.dsh-upd-entry-btn,.dsh-upd-entry-dot{transition:none}.dsh-upd-entry-btn[aria-busy="true"]::after{animation:none}}',
51
58
  ".dsh-upd-entry-btn:focus-visible,.dsh-upd-entry-dot:focus-visible{outline:2px solid var(--dsh-upd-focus,#2563eb);outline-offset:1px}",
52
59
  '.dsh-upd-entry[data-state="update"] .dsh-upd-entry-btn{border-color:var(--dsh-upd-ok-line,#059669);color:var(--dsh-upd-ok-line,#059669)}',
53
60
  '.dsh-upd-entry[data-state="restart"] .dsh-upd-entry-btn{border-color:var(--dsh-upd-warn-line,#d97706);color:var(--dsh-upd-warn-line,#d97706)}',
@@ -121,6 +128,7 @@ function mountUpdateEntry(container, options) {
121
128
  let snapshot = null;
122
129
  let error = null;
123
130
  let note = null;
131
+ let activating = false;
124
132
  let mounted = true;
125
133
  let panel = null;
126
134
  let panelMode = null;
@@ -156,9 +164,10 @@ function mountUpdateEntry(container, options) {
156
164
  }
157
165
  function entryHTML() {
158
166
  const kind = entryStateKind(stateOf());
159
- const text = currentLabel();
167
+ const text = activating ? "\u6B63\u5728\u67E5\u65B0\u7248\u2026" : currentLabel();
168
+ const busyAttr = activating ? ' disabled aria-busy="true"' : "";
160
169
  const themeAttr = theme === "d5-paper" ? ' data-theme="d5-paper"' : "";
161
- const control = variant === "badge" ? `<button type="button" class="dsh-upd-entry-dot" ${ENTRY_ATTR}="activate" title="${escapeHtml(text)}" aria-label="${escapeHtml(text)}"></button>` : `<button type="button" class="dsh-upd-entry-btn" ${ENTRY_ATTR}="activate">${escapeHtml(text)}</button>`;
170
+ const control = variant === "badge" ? `<button type="button" class="dsh-upd-entry-dot" ${ENTRY_ATTR}="activate" title="${escapeHtml(text)}" aria-label="${escapeHtml(text)}"${busyAttr}></button>` : `<button type="button" class="dsh-upd-entry-btn" ${ENTRY_ATTR}="activate"${busyAttr}>${escapeHtml(text)}</button>`;
162
171
  const noteHTML = note ? `<span class="dsh-upd-entry-note" data-dsh-upd-note="1">${escapeHtml(note)}</span>` : "";
163
172
  return `<style>${UPDATE_ENTRY_CSS}</style>
164
173
  <span class="dsh-upd-entry" data-variant="${variant}" data-state="${kind}"${themeAttr}>${control}${noteHTML}</span>`;
@@ -208,6 +217,8 @@ function mountUpdateEntry(container, options) {
208
217
  theme,
209
218
  pollMs,
210
219
  profileName,
220
+ autoChangelog: options.autoChangelog,
221
+ changelogMarkdown: options.changelogMarkdown ?? null,
211
222
  call: panelCall
212
223
  });
213
224
  panelMode = mode;
@@ -233,10 +244,15 @@ function mountUpdateEntry(container, options) {
233
244
  void refresh();
234
245
  }
235
246
  async function activate() {
236
- if (!mounted || panelMode !== null) return;
247
+ if (!mounted || panelMode !== null || activating) return;
248
+ activating = true;
237
249
  note = null;
238
250
  render();
239
- await checkNow();
251
+ try {
252
+ await checkNow();
253
+ } finally {
254
+ activating = false;
255
+ }
240
256
  if (!mounted) return;
241
257
  if (variant === "badge" || openOn === "manual") {
242
258
  if (onActivate) onActivate({ hasUpdate: hasUpdate(), latestVersion: snapshot?.latestVersion ?? null });
@@ -273,6 +289,14 @@ function mountUpdateEntry(container, options) {
273
289
  } catch {
274
290
  }
275
291
  }
292
+ function onKeyDown(ev) {
293
+ try {
294
+ const e = ev;
295
+ if (!mounted || panelMode !== "dialog" || !e || e.key !== "Escape") return;
296
+ close();
297
+ } catch {
298
+ }
299
+ }
276
300
  function unmount() {
277
301
  if (!mounted) return;
278
302
  mounted = false;
@@ -287,11 +311,19 @@ function mountUpdateEntry(container, options) {
287
311
  container.removeEventListener?.("click", onClick);
288
312
  } catch {
289
313
  }
314
+ try {
315
+ container.removeEventListener?.("keydown", onKeyDown);
316
+ } catch {
317
+ }
290
318
  }
291
319
  try {
292
320
  container.addEventListener?.("click", onClick);
293
321
  } catch {
294
322
  }
323
+ try {
324
+ container.addEventListener?.("keydown", onKeyDown);
325
+ } catch {
326
+ }
295
327
  if (variant === "inline") mountPanel("embedded");
296
328
  else render();
297
329
  if (autoCheck === "mount") void refresh();
package/dist/host.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { type PhoneAction, type UpdateConfigInput } from './config.js';
1
+ import { type ChangelogPhoneAction, type PhoneAction, type UpdateConfigInput } from './config.js';
2
2
  import type { EnvironmentKind, ReleaseChannel } from './ports.js';
3
3
  import { type UpdateQueueState } from './queue.js';
4
- export { buildPhoneName, buildPhoneNames, resolveUpdateConfig } from './config.js';
5
- export type { PhoneAction, UpdateConfigInput } from './config.js';
4
+ export { buildChangelogPhoneName, buildPhoneName, buildPhoneNames, resolveUpdateConfig } from './config.js';
5
+ export type { ChangelogPhoneAction, PhoneAction, UpdateConfigInput } from './config.js';
6
6
  export { parseEventListManifest, checkEventFields, checkEventCounts } from './gate.js';
7
7
  export type { GateEventLevel, GateEventKind, GateEventEntry, GateEventCounts, GateEventList, GateFieldCheck, GateCountCheck } from './gate.js';
8
8
  export { containingPackage, defaultHomeDir, profileNameValid, registrySpec, resolveTargetPackage } from './reader.js';
@@ -111,7 +111,7 @@ type LogCtx = {
111
111
  fire: (level: string, event: string, fields: Record<string, unknown>) => void;
112
112
  } | null;
113
113
  export interface HostUpdate {
114
- phoneNames: Record<PhoneAction, string>;
114
+ phoneNames: Record<PhoneAction, string> & Record<ChangelogPhoneAction, string>;
115
115
  handlers: Record<string, (args: Record<string, unknown>) => Promise<Record<string, unknown>>>;
116
116
  }
117
117
  /**
package/dist/host.js CHANGED
@@ -10,8 +10,9 @@ import {
10
10
  resolveUpdateConfig
11
11
  } from "./config.js";
12
12
  import { manualCommand } from "./commands.js";
13
+ import { fetchReleaseChangelogText } from "./changelog-io.js";
13
14
  import { buildDiag } from "./diag.js";
14
- import { isVersionAllowedInChannel, validRequestId } from "./service.js";
15
+ import { fetchNpmVersionRelease, isVersionAllowedInChannel, updateError, validReleaseVersion, validRequestId } from "./service.js";
15
16
  import { createUpdateDiskPorts, createUpdateExecutor, createUpdateQueuePorts } from "./store.js";
16
17
  import { createUpdateReader, defaultHomeDir, resolveProfileName, resolveTargetPackage } from "./reader.js";
17
18
  import {
@@ -27,7 +28,7 @@ import {
27
28
  setOwnerIfFree,
28
29
  visibleQueueFor
29
30
  } from "./queue.js";
30
- import { buildPhoneName, buildPhoneNames as buildPhoneNames2, resolveUpdateConfig as resolveUpdateConfig2 } from "./config.js";
31
+ import { buildChangelogPhoneName, buildPhoneName, buildPhoneNames as buildPhoneNames2, resolveUpdateConfig as resolveUpdateConfig2 } from "./config.js";
31
32
  import { parseEventListManifest, checkEventFields, checkEventCounts } from "./gate.js";
32
33
  import { containingPackage, defaultHomeDir as defaultHomeDir2, profileNameValid, registrySpec, resolveTargetPackage as resolveTargetPackage2 } from "./reader.js";
33
34
  import {
@@ -87,7 +88,7 @@ import {
87
88
  CHANGELOG_TAR_MAX_BYTES,
88
89
  CHANGELOG_TARBALL_MAX_BYTES,
89
90
  extractChangelogFromTar,
90
- fetchReleaseChangelogText,
91
+ fetchReleaseChangelogText as fetchReleaseChangelogText2,
91
92
  readInstalledChangelogText
92
93
  } from "./changelog-io.js";
93
94
  import { createUpdateDiskPorts as createUpdateDiskPorts2, createUpdateExecutor as createUpdateExecutor2, pathsForUpdate, resolveCliEntry } from "./store.js";
@@ -105,7 +106,7 @@ import {
105
106
  compareReleaseVersions,
106
107
  isPrereleaseVersion,
107
108
  isVersionAllowedInChannel as isVersionAllowedInChannel2,
108
- validReleaseVersion,
109
+ validReleaseVersion as validReleaseVersion2,
109
110
  validVersion,
110
111
  compareVersions
111
112
  } from "./service.js";
@@ -524,6 +525,49 @@ function loggedPhone(method, kind, pluginId, fn, diagCtx) {
524
525
  }
525
526
  };
526
527
  }
528
+ function loggedChangelogPhone(method, pluginId, fn, diagCtx) {
529
+ return async function(args) {
530
+ const t0 = Date.now();
531
+ const emit = (level, event, fields) => {
532
+ try {
533
+ if (phoneLogCtx && typeof phoneLogCtx.fire === "function") phoneLogCtx.fire(level, event, fields);
534
+ } catch {
535
+ }
536
+ };
537
+ const safeArgs = args && typeof args === "object" ? args : {};
538
+ try {
539
+ const out = await fn(safeArgs);
540
+ emit("info", "host.call", { method, latencyMs: Date.now() - t0, ok: true, kind: "update-changelog", pluginId });
541
+ return { ok: true, version: out.version, markdown: out.markdown };
542
+ } catch (error) {
543
+ const payload = toUpdateErrorPayload(error);
544
+ emit("warn", "host.call.fail", { method, kind: "update-changelog", errorHash: hash8(String(error?.message || payload.error)), pluginId });
545
+ let diag;
546
+ try {
547
+ const cfg = diagCtx?.config;
548
+ const overrides = diagCtx?.readerOverrides ?? {};
549
+ const envRaw = overrides?.environmentKind;
550
+ const runningRaw = overrides?.runningVersion;
551
+ const built = buildDiag({
552
+ errorCode: payload.error,
553
+ phoneKind: "update-changelog",
554
+ error,
555
+ args: safeArgs,
556
+ targetPackageName: cfg?.targetPackageName,
557
+ registryUrl: cfg?.registryUrl,
558
+ runningVersion: typeof runningRaw === "string" ? runningRaw : void 0,
559
+ latestVersion: void 0,
560
+ environmentKind: typeof envRaw === "string" ? envRaw : void 0,
561
+ latencyMs: Date.now() - t0
562
+ });
563
+ if (built && typeof built === "object") diag = built;
564
+ } catch {
565
+ diag = void 0;
566
+ }
567
+ return { ok: false, ...payload, ...diag ? { diag } : {} };
568
+ }
569
+ };
570
+ }
527
571
  function createHostUpdate(deps = {}, configInput) {
528
572
  const config = resolveUpdateConfig(configInput);
529
573
  const pluginId = config.pluginId;
@@ -533,6 +577,28 @@ function createHostUpdate(deps = {}, configInput) {
533
577
  else watchDesktopPnpm(ctx);
534
578
  const readerOverrides = { ...deps.readerOverrides ?? {}, ctx, ...deps.pluginManager ? { pluginManager: deps.pluginManager } : {} };
535
579
  const phoneNames = buildPhoneNames(config.prefix);
580
+ const changelogCache = /* @__PURE__ */ new Map();
581
+ async function readChangelog(args) {
582
+ const raw = args ? args["version"] : void 0;
583
+ if (!validReleaseVersion(raw)) throw updateError("invalid-release");
584
+ const version = raw;
585
+ if (!isVersionAllowedInChannel(version, config.releaseChannel)) throw updateError("invalid-release");
586
+ if (changelogCache.has(version)) return { version, markdown: changelogCache.get(version) ?? null };
587
+ const fetchImpl = readerOverrides.fetchImpl ?? globalThis.fetch;
588
+ const release = await fetchNpmVersionRelease(fetchImpl, config.checkTimeoutMs, {
589
+ targetPackageName: config.targetPackageName,
590
+ registryUrl: config.registryUrl,
591
+ releaseChannel: config.releaseChannel,
592
+ version
593
+ });
594
+ const markdown = await fetchReleaseChangelogText(
595
+ fetchImpl,
596
+ { tarball: release.tarball, integrity: release.integrity, version },
597
+ { targetPackageName: config.targetPackageName, registryUrl: config.registryUrl, timeoutMs: config.checkTimeoutMs }
598
+ );
599
+ changelogCache.set(version, markdown);
600
+ return { version, markdown };
601
+ }
536
602
  async function readStatus(args) {
537
603
  const reader = await getSharedReader(pluginId, config, {
538
604
  ...readerOverrides,
@@ -630,6 +696,10 @@ function createHostUpdate(deps = {}, configInput) {
630
696
  [phoneNames.updateInstall]: loggedPhone(phoneNames.updateInstall, "update-install", pluginId, runInstall, {
631
697
  config,
632
698
  readerOverrides
699
+ }),
700
+ [phoneNames.updateChangelog]: loggedChangelogPhone(phoneNames.updateChangelog, pluginId, readChangelog, {
701
+ config,
702
+ readerOverrides
633
703
  })
634
704
  };
635
705
  return { phoneNames, handlers };
@@ -682,6 +752,7 @@ export {
682
752
  SKIPPED_FILE,
683
753
  __resetSharedUpdateReaderForTests,
684
754
  addSkipped,
755
+ buildChangelogPhoneName,
685
756
  buildDiag2 as buildDiag,
686
757
  buildPhoneName,
687
758
  buildPhoneNames2 as buildPhoneNames,
@@ -709,7 +780,7 @@ export {
709
780
  enforceBudget as enforceDiagBudgetForPhone,
710
781
  enqueueInQueue2 as enqueueInQueue,
711
782
  extractChangelogFromTar,
712
- fetchReleaseChangelogText,
783
+ fetchReleaseChangelogText2 as fetchReleaseChangelogText,
713
784
  hasVisibleSections,
714
785
  isHeadOfQueue2 as isHeadOfQueue,
715
786
  isPrereleaseVersion,
@@ -741,7 +812,7 @@ export {
741
812
  setOwnerIfFree2 as setOwnerIfFree,
742
813
  skipKey,
743
814
  truncateToWordBoundary,
744
- validReleaseVersion,
815
+ validReleaseVersion2 as validReleaseVersion,
745
816
  validVersion,
746
817
  visibleQueueFor2 as visibleQueueFor
747
818
  };