@morehao/dsh-skills-console 0.5.4 → 0.5.5

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/README.en.md CHANGED
@@ -24,7 +24,7 @@ It gathers the skills on your machine into one list: what you have, where it cam
24
24
  | --- | --- |
25
25
  | Inventory | Collects the skills on this machine — source, version, visibility and shared agents in one view; skills already in the shared directory are adopted automatically the first time you open it |
26
26
  | Install | Enter `owner/repo` or a GitHub URL; first detects which skills the repo has and what each does; install everything or pick a few |
27
- | Update | Checks upstream commits, lists the commit range and affected skills, updates in one click after you confirm; locally modified skills ask for confirmation first |
27
+ | Update | Checks upstream commits, lists the commit range and affected skills, updates in one click after you confirm; skills with local edits ask for confirmation first |
28
28
  | Remove | Pre-flight check (never touches what others created), then a snapshot into the trash (kept 7 days) |
29
29
  | Toggle | One-click on/off; once disabled, neither DSH's model invocations nor `/` manual invocations see it; other agents and files on disk are unaffected |
30
30
  | Share | Links only agents that read their own directory; nothing is shared by default; agents that read the shared directory directly are not listed |
@@ -61,7 +61,7 @@ Screenshots are from a real DSH Web session on v0.5.2 (dark theme). All assets l
61
61
 
62
62
  - **Two entry points**: by default it lives in the "Skills Console" section of DSH settings; turn on "Sidebar entry" in the header ⚙ and a standalone panel appears in the left sidebar, effective immediately.
63
63
  - **One page, two tabs**: Installed / Subscriptions. The header holds "Auto-share on install" and "Add skill / Add subscription" on the right.
64
- - **Five actions per row**: toggle, view, update, share settings, delete; the ✨ next to a skill name opens the AI intro; "View" is hidden on narrow panels, and the skill name itself remains the entry point. Status labels cover up to date / update available / locally modified / removed upstream / broken link / no upstream.
64
+ - **Five actions per row**: toggle, view, update, share settings, delete; the ✨ next to a skill name opens the AI intro; "View" is hidden on narrow panels, and the skill name itself remains the entry point. Status labels cover up to date / update available / changed since install / removed upstream / broken link / no upstream.
65
65
  - **No layout jumps on narrow panels**: the action group wraps naturally with panel width and the plugin name always stays whole; the list switches to two columns by width; scrolling happens only in the list area, so switching tabs or resizing never flips the whole page.
66
66
  - **Many agents don't crowd**: at most 3 are shown per row and the rest fold into `+n`; hover or focus reveals all.
67
67
  - **Findable**: search by name / source / agent, filter by "updates / not checked / disabled", view flat or grouped by agent.
@@ -73,7 +73,7 @@ While managing skills you may want to know "what does this skill say, and is it
73
73
 
74
74
  - **Drawer quick look**: the list stays put and a read-only drawer slides in from the right; close it with Esc / the overlay / ×, and you return to the previous filter and scroll position.
75
75
  - **Full page for a deep read**: "Open as page" at the bottom of the drawer switches to a full page (body + contents on the left), with a top bar to go back to the list.
76
- - **Three tabs**: Content (rendered / raw toggle; raw keeps the frontmatter), Files (directory tree + single-file preview; `.md` renders by default and can switch to raw, other text shows its source directly), Info (source, ref, commit, skillPath, content hash, install / update time, shared agents, enabled state, local changes).
76
+ - **Three tabs**: Content (rendered / raw toggle; raw keeps the frontmatter), Files (directory tree + single-file preview; `.md` renders by default and can switch to raw, other text shows its source directly), Info (source, ref, commit, skillPath, content hash, install / update time, shared agents, enabled state, changes since install).
77
77
  - **Read-only**: no inputs and no save button. Update / share settings are enabled depending on writability; when the state file is corrupt you can still view, but management actions are disabled.
78
78
  - **What can be previewed**: text files that decode as UTF-8 (`.md` / `.html` / `.jsx` / `.css` / `.sh` / extension-less scripts…) show their source directly; the per-file cap is 256 KB (longer content is truncated with a notice); images, audio/video, archives and other binaries show "Preview not supported" with the file name / size.
79
79
 
@@ -89,6 +89,16 @@ Some skills only have an English description, and some titles alone don't tell y
89
89
  - **Subscription candidates too**: expanded subscriptions have `✨ Intro` on each entry; it fetches the remote `SKILL.md` at the snapshot `commit` and generates from that, with the commit in the cache key, and it never blocks "Install". Expanding also shows the author's own frontmatter description for free.
90
90
  - **How to turn it off**: `config.intro.enabled = false` (hides the ✨ entry). Other knobs are under "Data and configuration".
91
91
 
92
+ ## Check and update the plugin itself
93
+
94
+ The **refresh button** next to the identity capsule in the page header checks whether *Skills Console itself* has a new release. This is a different axis from skill updates — it checks this plugin's own npm releases:
95
+
96
+ - **How it checks**: follows the registry plan from the host's `pluginManager.registries()` (inheriting the profile's mirror / private source / token config) and asks `<registry>/<package>/latest` in turn (8s timeout, version only; falls back to the official `registry.npmjs.org` only if none answer), then compares with the installed version; the host caches for 5 minutes so repeated opens don't hit the network. About 20 seconds after startup it warms the cache once, silently.
97
+ - **One-click update**: when the desktop host exposes `pluginManager`, clicking "Update to x.y.z" reinstalls this plugin to the latest version (installed from the registry that reported it); **restart DSH to apply** (no auto-reload).
98
+ - **Manual fallback**: if one-click update is unavailable, the dialog shows a copyable command (`dsh plugin --profile <profile> add <package>@latest`).
99
+ - **When a new version exists**: a small dot lights up on the refresh button; open it to see current / latest version and the update button, plus the release's **GitHub Release notes** body pulled for that version (if unavailable, a "view release notes on GitHub" link instead).
100
+ - **Boundaries**: it only queries registries and only (re)installs this plugin itself, touching no other plugin's state directory; failures degrade silently to "could not reach the registry" and never affect any skill-management feature.
101
+
92
102
  ## Installation
93
103
 
94
104
  The plugin ships its own download and extraction code: **no Node / npm / npx / git required beforehand**; the first install needs network access to fetch dependencies.
@@ -113,7 +123,7 @@ The panel shows up in DSH Web's settings page / sidebar.
113
123
 
114
124
  1. **Add skill**: click "Add skill" in the top right, enter `owner/repo` or a GitHub URL, then click "Detect"; when the repo has several skills, tick the ones to install (multi-select, all selected by default, each with its description), and a single skill is selected automatically. If nothing is detected you can type skill names by hand; leaving them empty installs everything in the repo.
115
125
  2. **Share with other agents**: only needed for agents that read their own directory (for example Claude Code). Click "Share settings" on a row and tick the target agents; agents that already read the shared directory are not listed. To choose which agents new installs are shared with by default, use "Auto-share on install" in the header (nothing is ticked by default).
116
- 3. **Check for updates**: manual checks and the background auto-check are **complementary** paths sharing the same verdict — both land in the installed view's update status. Click the refresh icon in the list header to check now, light up "Update", or "Update all"; the background also runs a check every 6 hours (reusing DSH's timer service, which is silently skipped when unavailable — manual checks are unaffected), so the panel is usually already current when you open it. Locally modified skills ask for confirmation before updating.
126
+ 3. **Check for updates**: manual checks and the background auto-check are **complementary** paths sharing the same verdict — both land in the installed view's update status. Click the refresh icon in the list header to check now, light up "Update", or "Update all"; the background also runs a check every 6 hours (reusing DSH's timer service, which is silently skipped when unavailable — manual checks are unaffected), so the panel is usually already current when you open it. Skills with local edits ask for confirmation before updating.
117
127
  4. **Invocation policy**: click the "Auto / Manual only / Fully disabled" status button on a row and switch in the centered invocation-policy dialog. Auto = both the model and `/` can invoke it; Manual only = only the `disable-model-invocation` frontmatter key in `SKILL.md` changes and `/` still works; Fully disabled = `SKILL.md` is renamed to `SKILL.md.disabled` with a marker left behind, and restoring returns to the mode from before. When the author already declared manual-only in `SKILL.md`, the chip is read-only and is never overridden. While parked, install and update are rejected so the file cannot be silently written back.
118
128
 
119
129
  ## Safety and reliability
package/README.md CHANGED
@@ -61,7 +61,7 @@ DSH(DeepSeek Harness)的技能控制台:**安装、更新、删除、启
61
61
 
62
62
  - **两个入口**:默认在 DSH 设置页的「技能控制台」分区;页头 ⚙ 里打开「侧边栏入口」,左侧栏就会出现独立面板,切换立即生效。
63
63
  - **一页两 tab**:已安装 / 订阅。页头右侧是「新安装时自动共享」与「添加技能 / 添加订阅」。
64
- - **每行五个动作**:开关(启停)、查看、更新、共享设置、删除;技能名旁的 ✨ 打开 AI 简介;窄面板下隐藏「查看」,技能名本身仍是入口。状态标签覆盖最新 / 可更新 / 本地已改 / 上游已移除 / 软链断裂 / 无上游等。
64
+ - **每行五个动作**:开关(启停)、查看、更新、共享设置、删除;技能名旁的 ✨ 打开 AI 简介;窄面板下隐藏「查看」,技能名本身仍是入口。状态标签覆盖最新 / 可更新 / 安装后有改动 / 上游已移除 / 软链断裂 / 无上游等。
65
65
  - **窄面板不跳版**:动作组按面板宽度自然换行,插件名始终完整;列表按宽度切两列;滚动只发生在列表区,切 tab、调整窗口都不会让整页翻面。
66
66
  - **Agent 多了不挤**:每行最多展示 3 个,其余折成 `+n`,悬浮或聚焦即可看全。
67
67
  - **找得到**:支持按名称 / 来源 / Agent 搜索,按「可更新 / 待检查 / 已停用」筛选,平铺或按 Agent 分组。
@@ -73,7 +73,7 @@ DSH(DeepSeek Harness)的技能控制台:**安装、更新、删除、启
73
73
 
74
74
  - **抽屉速览**:列表不动,右侧滑出只读抽屉;Esc / 点遮罩 / × 关闭,关闭后回到原来的筛选与滚动位置。
75
75
  - **整页深读**:抽屉底部「在整页打开」切到整页阅读(正文 + 左侧目录),顶部返回条回列表。
76
- - **三个 tab**:正文(渲染 / 原文切换,原文保留 frontmatter)、文件(目录树 + 单文件预览;`.md` 默认渲染、可切原文,其他文本直接出源码)、信息(来源、ref、commit、skillPath、内容哈希、安装 / 更新时间、共享至哪些 Agent、启停、本地改动)。
76
+ - **三个 tab**:正文(渲染 / 原文切换,原文保留 frontmatter)、文件(目录树 + 单文件预览;`.md` 默认渲染、可切原文,其他文本直接出源码)、信息(来源、ref、commit、skillPath、内容哈希、安装 / 更新时间、共享至哪些 Agent、启停、安装后改动)。
77
77
  - **只读**:没有输入框和保存按钮。更新 / 共享设置按可写状态决定是否可用;状态文件损坏时仍可查看,管理动作禁用。
78
78
  - **能预览什么**:能按 UTF-8 解码的文本文件(`.md` / `.html` / `.jsx` / `.css` / `.sh` / 无扩展名脚本…)直接显示源码;单文件上限 256 KB(超出截断并提示);图片、音视频、压缩包等二进制显示「暂不支持预览」和文件名 / 大小。
79
79
 
@@ -89,6 +89,16 @@ DSH(DeepSeek Harness)的技能控制台:**安装、更新、删除、启
89
89
  - **订阅候选也能看**:展开订阅后,条目行同样有 `✨ 简介`;按快照 `commit` 拉远端 `SKILL.md` 再生成,缓存 key 带 commit,不挡「安装」。展开时还会显示作者自己的 frontmatter 描述,不花额度。
90
90
  - **关闭方式**:`config.intro.enabled = false`(界面隐藏 ✨ 入口)。其他可调项见「数据与配置」。
91
91
 
92
+ ## 检查与更新插件自身
93
+
94
+ 页头身份胶囊旁的**刷新按钮**用来检查「技能控制台」这个插件自己有没有新版本,而管的是另一个维度——它不是检查技能的上游,而是检查本插件在 npm 上的发布:
95
+
96
+ - **怎么查**:按宿主 `pluginManager.registries()` 给的 registry 计划(继承 profile 的镜像 / 私有源 / token 配置)依次问 `<registry>/<包名>/latest`(8 秒超时,只取版本号,取不到才回退官方 `registry.npmjs.org`),与本机安装的版本比较;Host 端 5 分钟缓存,连续打开不重复打网络。启动后约 20 秒会静默热身一次,不打扰你。
97
+ - **一键更新**:桌面端有 `pluginManager` 服务时,弹窗里点「更新到 x.y.z」即可把本插件重装到最新版(装到查到最新版的那个 registry);更新成功后**重启 DSH 生效**(不自动刷新)。
98
+ - **手动兜底**:环境不支持一键更新时,弹窗给出可复制的手动命令(`dsh plugin --profile <profile> add <包名>@latest`)。
99
+ - **有新版本时**:刷新按钮上会亮一个小圆点,点开即见当前版本 / 最新版本与更新按钮;弹窗还会拉取该版本在 GitHub 上的 **Release 发布说明**正文一并展示(拉不到时给「在 GitHub 查看发布说明」链接)。
100
+ - **边界**:只问 registry、只装本插件自身,不读写其他插件的状态目录;失败静默降级为「暂时无法获取最新版本」,不影响技能管理的任何功能。
101
+
92
102
  ## 安装
93
103
 
94
104
  插件自带下载与解压能力,**不需要预装 Node / npm / npx / git**;首次安装需要联网拉取依赖。
package/client.js CHANGED
@@ -69,6 +69,15 @@ async function apiCall(method, path, body, options = {}) {
69
69
  return json;
70
70
  }
71
71
  var GITHUB_URL = "https://github.com/morehao/dsh-skills-console";
72
+ function checkPluginUpdate({ fresh = false } = {}) {
73
+ return apiCall("GET", `/plugin-update${fresh ? "?fresh=1" : ""}`);
74
+ }
75
+ function applyPluginUpdate() {
76
+ return apiCall("POST", "/plugin-update/apply", {});
77
+ }
78
+ function fetchPluginRelease(version) {
79
+ return apiCall("GET", `/plugin-update/release?version=${encodeURIComponent(version || "")}`);
80
+ }
72
81
 
73
82
  // client/copy.js
74
83
  var DICT = {
@@ -117,10 +126,33 @@ var DICT = {
117
126
  "placement.off": "已关闭",
118
127
  "placement.hint": "开启后侧栏出现「技能控制台」入口;关闭则只在「设置 → 技能」显示。",
119
128
  "github.open": "打开 GitHub 仓库",
129
+ "pluginUpdate.check": "检查插件更新",
130
+ "pluginUpdate.title": "检查插件更新",
131
+ "pluginUpdate.current": "当前版本",
132
+ "pluginUpdate.latest": "最新版本",
133
+ "pluginUpdate.checking": "正在检查…",
134
+ "pluginUpdate.upToDate": "已是最新版本",
135
+ "pluginUpdate.available": "有新版本可用",
136
+ "pluginUpdate.checkFailed": "暂时无法获取最新版本,请稍后再试",
137
+ "pluginUpdate.recheck": "重新检查",
138
+ "pluginUpdate.update": "更新到 {version}",
139
+ "pluginUpdate.applying": "正在更新…",
140
+ "pluginUpdate.updated": "已更新到 {version},重启 DSH 后生效",
141
+ "pluginUpdate.restartHint": "更新已安装,请重启 DSH 使新版本生效。",
142
+ "pluginUpdate.manual": "当前环境不支持一键更新,请手动执行:",
143
+ "pluginUpdate.copy": "复制命令",
144
+ "pluginUpdate.copied": "已复制",
145
+ "pluginUpdate.close": "关闭",
146
+ "pluginUpdate.whatsNew": "v{version} 更新内容",
147
+ "pluginUpdate.releaseLoading": "正在加载更新内容…",
148
+ "pluginUpdate.viewReleaseNotes": "在 GitHub 查看发布说明",
149
+ "pluginUpdate.releaseUnavailable": "暂无该版本的发布说明。",
120
150
  "status.current": "最新",
121
151
  "status.update": "可更新",
122
- "status.local": "本地已改",
123
- "status.conflict": "本地已改 + 上游已变",
152
+ "status.local": "安装后有改动",
153
+ "status.localHint": "安装或更新后,这个技能的文件被改过;再次更新会覆盖这些改动,会先请你确认。可在「查看 → 信息」里核对",
154
+ "status.conflict": "有改动 · 可更新",
155
+ "status.conflictHint": "本地文件有改动,上游也有新提交;更新会覆盖你的改动(旧内容有备份,可回滚)",
124
156
  "status.deleted": "上游已移除",
125
157
  "status.broken": "异常:软链断裂",
126
158
  "status.unmanaged": "未纳入追踪",
@@ -295,9 +327,9 @@ var DICT = {
295
327
  "viewer.info.updatedAt": "更新",
296
328
  "viewer.info.size": "大小",
297
329
  "viewer.info.local": "本地",
298
- "viewer.info.localChange": "本地改动",
330
+ "viewer.info.localChange": "安装后改动",
299
331
  "viewer.info.localChangeNone": "无(与安装时一致)",
300
- "viewer.info.localChangeYes": "有本地改动",
332
+ "viewer.info.localChangeYes": "有改动",
301
333
  "viewer.info.links": "共享",
302
334
  "viewer.info.exported": "已共享",
303
335
  "viewer.info.visibility": "可见性",
@@ -376,10 +408,33 @@ var DICT = {
376
408
  "placement.off": "off",
377
409
  "placement.hint": "On: a Skills Console entry appears in the sidebar. Off: it only shows under Settings → Skills.",
378
410
  "github.open": "Open GitHub repository",
411
+ "pluginUpdate.check": "Check for plugin updates",
412
+ "pluginUpdate.title": "Check for plugin updates",
413
+ "pluginUpdate.current": "Current version",
414
+ "pluginUpdate.latest": "Latest version",
415
+ "pluginUpdate.checking": "Checking…",
416
+ "pluginUpdate.upToDate": "You are on the latest version",
417
+ "pluginUpdate.available": "A new version is available",
418
+ "pluginUpdate.checkFailed": "Could not reach the registry; please try again later",
419
+ "pluginUpdate.recheck": "Check again",
420
+ "pluginUpdate.update": "Update to {version}",
421
+ "pluginUpdate.applying": "Updating…",
422
+ "pluginUpdate.updated": "Updated to {version}; restart DSH to apply",
423
+ "pluginUpdate.restartHint": "The update is installed. Restart DSH to use the new version.",
424
+ "pluginUpdate.manual": "One-click update is unavailable here; run this manually:",
425
+ "pluginUpdate.copy": "Copy command",
426
+ "pluginUpdate.copied": "Copied",
427
+ "pluginUpdate.close": "Close",
428
+ "pluginUpdate.whatsNew": "What's new in v{version}",
429
+ "pluginUpdate.releaseLoading": "Loading release notes…",
430
+ "pluginUpdate.viewReleaseNotes": "View release notes on GitHub",
431
+ "pluginUpdate.releaseUnavailable": "No release notes for this version.",
379
432
  "status.current": "Up to date",
380
433
  "status.update": "Update available",
381
- "status.local": "Locally modified",
382
- "status.conflict": "Local + upstream changed",
434
+ "status.local": "Changed since install",
435
+ "status.localHint": "Files were changed after install or update. Updating overwrites them and asks for confirmation first. Check “View → Info” to compare",
436
+ "status.conflict": "Changed · update available",
437
+ "status.conflictHint": "Local files changed and upstream has new commits; updating overwrites your edits (old content is backed up and can be rolled back)",
383
438
  "status.deleted": "Removed upstream",
384
439
  "status.broken": "Broken link",
385
440
  "status.unmanaged": "Untracked",
@@ -554,9 +609,9 @@ var DICT = {
554
609
  "viewer.info.updatedAt": "Updated",
555
610
  "viewer.info.size": "Size",
556
611
  "viewer.info.local": "Local",
557
- "viewer.info.localChange": "Local changes",
612
+ "viewer.info.localChange": "Changes since install",
558
613
  "viewer.info.localChangeNone": "None (matches install)",
559
- "viewer.info.localChangeYes": "Modified locally",
614
+ "viewer.info.localChangeYes": "Changed",
560
615
  "viewer.info.links": "Shared",
561
616
  "viewer.info.exported": "Shared",
562
617
  "viewer.info.visibility": "Visibility",
@@ -1040,7 +1095,7 @@ function FolderIcon({ size = 12 }) {
1040
1095
  }
1041
1096
 
1042
1097
  // client/section.js
1043
- var import_react9 = __toESM(require("react"), 1);
1098
+ var import_react10 = __toESM(require("react"), 1);
1044
1099
 
1045
1100
  // client/scroll.js
1046
1101
  function isScroller(node, tolerance = 4) {
@@ -1121,6 +1176,10 @@ var CSS = `
1121
1176
  .dshsc-tool:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
1122
1177
  .dshsc-tool:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
1123
1178
  .dshsc-tool[aria-expanded="true"] { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
1179
+ /* 刷新按钮:检查到新版本时右上角点个品牌色圆点 */
1180
+ .dshsc-tool-dot { position: relative; color: var(--dsw-alias-brand-primary); }
1181
+ .dshsc-tool-dot::after { content: ""; position: absolute; top: 3px; right: 3px; width: 6px; height: 6px;
1182
+ border-radius: 50%; background: var(--dsw-alias-state-warning-primary, #e8b15a); box-shadow: 0 0 0 1.5px var(--dsw-alias-bg-base); }
1124
1183
  .dshsc-settings { position: relative; display: inline-flex; }
1125
1184
  /* 设置面板留在 Host 对话框内(焦点陷阱 / 层级都跟着走);开启时按左右余量选边和宽度 */
1126
1185
  .dshsc-pop { position: absolute; top: calc(100% + 8px); z-index: 30; box-sizing: border-box;
@@ -1483,6 +1542,10 @@ var CSS = `
1483
1542
  /* 64ch 是主页面长文的舒适行长;弹窗说明只有一两句,用满弹窗宽度,避免没必要的折行 */
1484
1543
  .dshsc-modal .dshsc-sub { max-width: none; }
1485
1544
  .dshsc-modal h3 { margin: 0; font-size: 15px; font-weight: 600; padding-right: 26px; }
1545
+ /* 插件更新弹窗的「当前 / 最新」键值对 */
1546
+ .dshsc-kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; margin: 0; font-size: 12px; align-items: baseline; }
1547
+ .dshsc-kv dt { color: var(--dsw-alias-label-secondary); }
1548
+ .dshsc-kv dd { margin: 0; }
1486
1549
  .dshsc-modal-close { position: absolute; top: 10px; right: 10px; width: 26px; height: 26px; border: 0; border-radius: 7px;
1487
1550
  background: transparent; color: var(--dsw-alias-label-secondary); font-size: 18px; line-height: 1; cursor: pointer; }
1488
1551
  .dshsc-modal-close:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
@@ -1532,6 +1595,14 @@ var CSS = `
1532
1595
  .dshsc-panel .dshsc-check.locked:hover { background: transparent; }
1533
1596
 
1534
1597
  .dshsc-modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding-top: 12px; border-top: 1px solid var(--dsw-alias-border-l1); }
1598
+ /* 弹窗页脚按钮可能是「图标 + 文字」:统一成水平排列,避免 svg(display:block) 把文字挤到下一行 */
1599
+ .dshsc-modal-foot .dshsc-btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; }
1600
+ .dshsc-modal-foot .dshsc-btn svg { display: block; }
1601
+ /* 插件更新弹窗里的「更新内容」(GitHub Release 正文):限高滚动,长说明不撑爆弹窗 */
1602
+ .dshsc-release-head { font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary); margin-bottom: 6px; }
1603
+ .dshsc-release-body { max-height: 260px; overflow: auto; padding: 10px 12px; border-radius: 10px;
1604
+ background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); }
1605
+ .dshsc-release-body .dshsc-md { font-size: 12.5px; }
1535
1606
  /* ---------- 查看器:抽屉(B1)/ 整页(A1)共用 ---------- */
1536
1607
  .dshsc-drawer-wrap { position: fixed; inset: 0; z-index: 55; }
1537
1608
  .dshsc-drawer-scrim { position: absolute; inset: 0; background: rgba(4,6,9,.55); }
@@ -2254,6 +2325,64 @@ function useSkillIntro({ name, lang, stamp, enabled = true, endpoint = null }) {
2254
2325
  }, [name, lang, base]);
2255
2326
  return { ...state, generating, genError, generate };
2256
2327
  }
2328
+ function usePluginUpdate() {
2329
+ const [status, setStatus] = import_react2.default.useState(null);
2330
+ const [checking, setChecking] = import_react2.default.useState(false);
2331
+ const [applying, setApplying] = import_react2.default.useState(false);
2332
+ const [checkError, setCheckError] = import_react2.default.useState(null);
2333
+ const [applyError, setApplyError] = import_react2.default.useState(null);
2334
+ const [release, setRelease] = import_react2.default.useState({ loading: false, data: null, version: null });
2335
+ const inFlight = import_react2.default.useRef(false);
2336
+ const check = import_react2.default.useCallback(async (fresh = false) => {
2337
+ if (inFlight.current) return null;
2338
+ inFlight.current = true;
2339
+ setChecking(true);
2340
+ setCheckError(null);
2341
+ try {
2342
+ const next = await checkPluginUpdate({ fresh });
2343
+ setStatus(next);
2344
+ return next;
2345
+ } catch (error) {
2346
+ setCheckError({ code: error?.code || null, message: error?.message || "检查失败" });
2347
+ return null;
2348
+ } finally {
2349
+ inFlight.current = false;
2350
+ setChecking(false);
2351
+ }
2352
+ }, []);
2353
+ const apply2 = import_react2.default.useCallback(async () => {
2354
+ setApplying(true);
2355
+ setApplyError(null);
2356
+ try {
2357
+ const next = await applyPluginUpdate();
2358
+ setStatus(next);
2359
+ return next;
2360
+ } catch (error) {
2361
+ setApplyError({
2362
+ code: error?.code || null,
2363
+ message: error?.message || "更新失败",
2364
+ detail: error?.detail || null
2365
+ });
2366
+ return null;
2367
+ } finally {
2368
+ setApplying(false);
2369
+ }
2370
+ }, []);
2371
+ const loadRelease = import_react2.default.useCallback(async (version) => {
2372
+ const target = String(version || "").trim();
2373
+ if (!target) return null;
2374
+ setRelease({ loading: true, data: null, version: target });
2375
+ try {
2376
+ const data = await fetchPluginRelease(target);
2377
+ setRelease({ loading: false, data: data || null, version: target });
2378
+ return data;
2379
+ } catch {
2380
+ setRelease({ loading: false, data: null, version: target });
2381
+ return null;
2382
+ }
2383
+ }, []);
2384
+ return { status, checking, applying, checkError, applyError, check, apply: apply2, release, loadRelease };
2385
+ }
2257
2386
 
2258
2387
  // client/views/header.js
2259
2388
  function installSpec(plugin) {
@@ -2275,6 +2404,8 @@ function Header({
2275
2404
  placement,
2276
2405
  placementBusy,
2277
2406
  readOnly,
2407
+ pluginUpdate,
2408
+ onCheckUpdate,
2278
2409
  onToggleSettings,
2279
2410
  onChangePlacement,
2280
2411
  onOpenAutoLink,
@@ -2282,9 +2413,18 @@ function Header({
2282
2413
  onAddSubscription
2283
2414
  }) {
2284
2415
  const spec = installSpec(plugin);
2416
+ const updateAvailable = Boolean(pluginUpdate?.status?.updateAvailable);
2285
2417
  const headTools = h(
2286
2418
  "div",
2287
2419
  { className: "dshsc-tools" },
2420
+ // 刷新按钮放在 GitHub 前面:点一下检查插件自身版本,有新版本时高亮提示
2421
+ h("button", {
2422
+ className: `dshsc-tool${updateAvailable ? " dshsc-tool-dot" : ""}`,
2423
+ type: "button",
2424
+ title: t("pluginUpdate.check"),
2425
+ "aria-label": t("pluginUpdate.check"),
2426
+ onClick: onCheckUpdate
2427
+ }, h(RefreshIcon, { size: 15 })),
2288
2428
  // 先源码、后设置:GitHub 紧挨包名,齿轮留在工具组最外侧
2289
2429
  h("a", {
2290
2430
  className: "dshsc-tool",
@@ -2805,14 +2945,17 @@ function InstalledTab({
2805
2945
  const map = {
2806
2946
  current: ["status.current", "ok", true],
2807
2947
  "update-available": ["status.update", "warn"],
2808
- "locally-modified": ["status.local", "warn"],
2809
- conflict: ["status.conflict", "warn"],
2948
+ "locally-modified": ["status.local", "warn", false, "status.localHint"],
2949
+ conflict: ["status.conflict", "warn", false, "status.conflictHint"],
2810
2950
  "upstream-deleted": ["status.deleted", "warn"],
2811
2951
  "broken-link": ["status.broken", "err"],
2812
2952
  unmanaged: ["status.unmanaged", ""]
2813
2953
  };
2814
- const [key, cls, check] = map[row.status] || ["status.unknown", ""];
2815
- return h("span", { className: `dshsc-tag ${cls}` }, check ? `✓ ${t(key)}` : t(key));
2954
+ const [key, cls, check, hint] = map[row.status] || ["status.unknown", ""];
2955
+ return h("span", {
2956
+ className: `dshsc-tag ${cls}`,
2957
+ title: hint ? t(hint) : void 0
2958
+ }, check ? `✓ ${t(key)}` : t(key));
2816
2959
  }
2817
2960
  function sourceLine(row) {
2818
2961
  if (!row.source) {
@@ -4281,6 +4424,10 @@ var STATUS_KEY = {
4281
4424
  "broken-link": "status.broken",
4282
4425
  unmanaged: "status.unmanaged"
4283
4426
  };
4427
+ var STATUS_HINT = {
4428
+ "locally-modified": "status.localHint",
4429
+ conflict: "status.conflictHint"
4430
+ };
4284
4431
  function TabButton({ id, label, count, active, onSelect }) {
4285
4432
  return h("button", {
4286
4433
  type: "button",
@@ -4474,7 +4621,10 @@ function SkillReader({
4474
4621
  "div",
4475
4622
  { className: "dshsc-reader-title" },
4476
4623
  h("h2", { id: "dshsc-reader-name", tabIndex: -1 }, name || t("viewer.title")),
4477
- row?.status && STATUS_KEY[row.status] ? h("span", { className: `dshsc-tag ${row.status === "current" ? "ok" : "warn"}` }, t(STATUS_KEY[row.status])) : null,
4624
+ row?.status && STATUS_KEY[row.status] ? h("span", {
4625
+ className: `dshsc-tag ${row.status === "current" ? "ok" : "warn"}`,
4626
+ title: STATUS_HINT[row.status] ? t(STATUS_HINT[row.status]) : void 0
4627
+ }, t(STATUS_KEY[row.status])) : null,
4478
4628
  row?.enabled === false ? h("span", { className: "dshsc-tag off" }, t("status.off")) : null,
4479
4629
  variant === "drawer" ? h("button", {
4480
4630
  type: "button",
@@ -4848,25 +4998,122 @@ function SkillReader({
4848
4998
  ), overlayHost());
4849
4999
  }
4850
5000
 
5001
+ // client/views/plugin-update-modal.js
5002
+ var import_react9 = __toESM(require("react"), 1);
5003
+ function PluginUpdateModal({ t, update, onClose, repoUrl }) {
5004
+ const { status, checking, applying, checkError, applyError, check, apply: apply2, release, loadRelease } = update;
5005
+ const [copied, setCopied] = import_react9.default.useState(false);
5006
+ import_react9.default.useEffect(() => {
5007
+ void check();
5008
+ }, [check]);
5009
+ const latest = status?.latestVersion || null;
5010
+ const current = status?.currentVersion || "—";
5011
+ const available = Boolean(status?.updateAvailable);
5012
+ const canAuto = status?.canAutoUpdate !== false;
5013
+ const manual = status?.manualCommand || "";
5014
+ const restartRequired = status?.restartRequired === true;
5015
+ const updatedVersion = status?.updatedVersion || null;
5016
+ import_react9.default.useEffect(() => {
5017
+ if (available && latest) void loadRelease(latest);
5018
+ }, [available, latest, loadRelease]);
5019
+ const releaseData = release?.version === latest ? release.data : null;
5020
+ const releaseLoading = release?.version === latest && release.loading;
5021
+ const releaseBody = typeof releaseData?.body === "string" ? releaseData.body.trim() : "";
5022
+ const releaseUrl = releaseData?.htmlUrl || (repoUrl ? `${repoUrl}/releases` : null);
5023
+ const copy = () => {
5024
+ if (!manual || typeof navigator === "undefined" || !navigator.clipboard?.writeText) return;
5025
+ Promise.resolve(navigator.clipboard.writeText(manual)).then(() => {
5026
+ setCopied(true);
5027
+ setTimeout(() => setCopied(false), 1500);
5028
+ }).catch(() => {
5029
+ });
5030
+ };
5031
+ let stateLine;
5032
+ if (checking && !status) stateLine = t("pluginUpdate.checking");
5033
+ else if (checkError || status?.checkFailed) stateLine = t("pluginUpdate.checkFailed");
5034
+ else if (restartRequired) stateLine = t("pluginUpdate.updated", { version: updatedVersion || latest });
5035
+ else if (available) stateLine = t("pluginUpdate.available");
5036
+ else if (status) stateLine = t("pluginUpdate.upToDate");
5037
+ else stateLine = t("pluginUpdate.checking");
5038
+ const stateTone = checkError || status?.checkFailed ? "err" : available ? "update" : "ok";
5039
+ const footer = [
5040
+ h("button", {
5041
+ key: "check",
5042
+ className: "dshsc-btn ghost",
5043
+ disabled: checking || applying,
5044
+ onClick: () => void check(true)
5045
+ }, h(RefreshIcon, { size: 13 }), t("pluginUpdate.recheck"))
5046
+ ];
5047
+ if (available && canAuto) {
5048
+ footer.push(h("button", {
5049
+ key: "apply",
5050
+ className: "dshsc-btn primary",
5051
+ disabled: applying,
5052
+ onClick: () => void apply2()
5053
+ }, applying ? t("pluginUpdate.applying") : t("pluginUpdate.update", { version: latest })));
5054
+ }
5055
+ footer.push(h("button", { key: "close", className: "dshsc-btn ghost", onClick: onClose }, t("pluginUpdate.close")));
5056
+ return h(
5057
+ Overlay,
5058
+ { t, title: t("pluginUpdate.title"), onCancel: onClose, footer },
5059
+ h(
5060
+ "dl",
5061
+ { className: "dshsc-kv" },
5062
+ h("dt", null, t("pluginUpdate.current")),
5063
+ h("dd", null, h("span", { className: "dshsc-code" }, current)),
5064
+ h("dt", null, t("pluginUpdate.latest")),
5065
+ h("dd", null, h("span", { className: "dshsc-code" }, latest || "—"))
5066
+ ),
5067
+ h("p", { className: "dshsc-sub", "data-kind": stateTone, role: "status", style: stateTone === "err" ? { color: "var(--dsw-alias-state-error-primary)" } : null }, stateLine),
5068
+ // 有新版本:展示该版本的发布说明(拉了 GitHub Release 正文就渲染,拉不到给外链)
5069
+ available ? h(
5070
+ "div",
5071
+ { className: "dshsc-release" },
5072
+ h("div", { className: "dshsc-release-head" }, t("pluginUpdate.whatsNew", { version: latest })),
5073
+ releaseLoading ? h("p", { className: "dshsc-sub" }, t("pluginUpdate.releaseLoading")) : releaseBody ? h("div", { className: "dshsc-release-body" }, h(Markdown, { text: releaseBody })) : h(
5074
+ "p",
5075
+ { className: "dshsc-sub" },
5076
+ releaseUrl ? h("a", { className: "dshsc-md-link", href: releaseUrl, target: "_blank", rel: "noopener noreferrer" }, t("pluginUpdate.viewReleaseNotes")) : t("pluginUpdate.releaseUnavailable")
5077
+ )
5078
+ ) : null,
5079
+ applyError ? h("p", { className: "dshsc-sub", role: "alert", style: { color: "var(--dsw-alias-state-error-primary)" } }, applyError.message) : null,
5080
+ restartRequired ? h("p", { className: "dshsc-sub" }, t("pluginUpdate.restartHint")) : null,
5081
+ // 一键更新不可用(或本次安装失败后想手动兜底):给命令 + 复制
5082
+ !canAuto && manual ? h(
5083
+ "div",
5084
+ { className: "dshsc-field" },
5085
+ h("span", null, t("pluginUpdate.manual")),
5086
+ h(
5087
+ "div",
5088
+ { className: "dshsc-inline", style: { display: "flex", gap: 8, alignItems: "center" } },
5089
+ h("code", { className: "dshsc-code", style: { flex: 1, wordBreak: "break-all" } }, manual),
5090
+ h("button", { className: "dshsc-btn ghost", onClick: copy }, copied ? t("pluginUpdate.copied") : t("pluginUpdate.copy"))
5091
+ )
5092
+ ) : null
5093
+ );
5094
+ }
5095
+
4851
5096
  // client/section.js
4852
5097
  var UPDATABLE2 = /* @__PURE__ */ new Set(["update-available", "locally-modified", "conflict"]);
4853
5098
  function Section({ t, page = false, pickDirectory, onPlacementChange }) {
4854
5099
  const { data, error, busy, toast, setToast, setBusy, reload, run } = useSkillsData();
4855
- const [tab, setTab] = import_react9.default.useState("installed");
4856
- const [view, setView] = import_react9.default.useState("flat");
4857
- const [modal, setModal] = import_react9.default.useState(null);
4858
- const [expanded, setExpanded] = import_react9.default.useState({});
4859
- const [placementBusy, setPlacementBusy] = import_react9.default.useState(null);
4860
- const [placementOverride, setPlacementOverride] = import_react9.default.useState(null);
4861
- const [query, setQuery] = import_react9.default.useState("");
4862
- const [viewer, setViewer] = import_react9.default.useState(null);
4863
- const rootRef = import_react9.default.useRef(null);
4864
- const scrollPos = import_react9.default.useRef(null);
4865
- const restoreScroll = import_react9.default.useRef(null);
4866
- const [filter, setFilter] = import_react9.default.useState("all");
4867
- const [builtinOpen, setBuiltinOpen] = import_react9.default.useState(false);
5100
+ const [tab, setTab] = import_react10.default.useState("installed");
5101
+ const [view, setView] = import_react10.default.useState("flat");
5102
+ const [modal, setModal] = import_react10.default.useState(null);
5103
+ const [expanded, setExpanded] = import_react10.default.useState({});
5104
+ const [placementBusy, setPlacementBusy] = import_react10.default.useState(null);
5105
+ const [placementOverride, setPlacementOverride] = import_react10.default.useState(null);
5106
+ const [query, setQuery] = import_react10.default.useState("");
5107
+ const [viewer, setViewer] = import_react10.default.useState(null);
5108
+ const rootRef = import_react10.default.useRef(null);
5109
+ const scrollPos = import_react10.default.useRef(null);
5110
+ const restoreScroll = import_react10.default.useRef(null);
5111
+ const [filter, setFilter] = import_react10.default.useState("all");
5112
+ const [builtinOpen, setBuiltinOpen] = import_react10.default.useState(false);
4868
5113
  const { settingsOpen, settingsRef, settingsPlacement, closeSettings, toggleSettings } = useSettingsPopover();
4869
5114
  const { agentTip, agentTipPos, agentTipRef, openAgentTip, closeAgentTip } = useAgentTip();
5115
+ const pluginUpdate = usePluginUpdate();
5116
+ const [updateOpen, setUpdateOpen] = import_react10.default.useState(false);
4870
5117
  const rows = data?.installs || [];
4871
5118
  const actions = useSkillActions({ t, rows, reload, run, setBusy, setToast, setModal });
4872
5119
  const writable = Boolean(data) && !data?.readOnly && data?.cli?.usable;
@@ -4887,7 +5134,7 @@ function Section({ t, page = false, pickDirectory, onPlacementChange }) {
4887
5134
  const agentLabels = row.exports.map((item) => item.label);
4888
5135
  return [row.name, row.source || "", ...agentLabels].join(" ").toLowerCase().includes(queryText);
4889
5136
  });
4890
- import_react9.default.useEffect(() => {
5137
+ import_react10.default.useEffect(() => {
4891
5138
  if (!viewer || !data) return;
4892
5139
  if (rows.some((row) => row.name === viewer.name)) return;
4893
5140
  setViewer(null);
@@ -4921,7 +5168,7 @@ function Section({ t, page = false, pickDirectory, onPlacementChange }) {
4921
5168
  if (viewer?.mode === "page") restoreScroll.current = scrollPos.current;
4922
5169
  setViewer(null);
4923
5170
  }
4924
- import_react9.default.useLayoutEffect(() => {
5171
+ import_react10.default.useLayoutEffect(() => {
4925
5172
  if (restoreScroll.current === null) return;
4926
5173
  const target = restoreScroll.current;
4927
5174
  restoreScroll.current = null;
@@ -4990,7 +5237,7 @@ function Section({ t, page = false, pickDirectory, onPlacementChange }) {
4990
5237
  onToast: (next) => setToast(next)
4991
5238
  }) : null;
4992
5239
  const listNode = h(
4993
- import_react9.default.Fragment,
5240
+ import_react10.default.Fragment,
4994
5241
  null,
4995
5242
  h(Header, {
4996
5243
  t,
@@ -5006,6 +5253,8 @@ function Section({ t, page = false, pickDirectory, onPlacementChange }) {
5006
5253
  placement,
5007
5254
  placementBusy,
5008
5255
  readOnly: Boolean(data?.readOnly),
5256
+ pluginUpdate,
5257
+ onCheckUpdate: () => setUpdateOpen(true),
5009
5258
  onToggleSettings: toggleSettings,
5010
5259
  onChangePlacement: (next) => void changePlacement(next),
5011
5260
  onOpenAutoLink: () => setModal({ type: "autolink" }),
@@ -5075,7 +5324,8 @@ function Section({ t, page = false, pickDirectory, onPlacementChange }) {
5075
5324
  onClose: () => setModal(null),
5076
5325
  onSaveAutoLink: saveAutoLink
5077
5326
  }) : null,
5078
- agentTip ? h(AgentTip, { t, agentTip, agentTipPos, nodeRef: agentTipRef }) : null
5327
+ agentTip ? h(AgentTip, { t, agentTip, agentTipPos, nodeRef: agentTipRef }) : null,
5328
+ updateOpen ? h(PluginUpdateModal, { t, update: pluginUpdate, repoUrl: GITHUB_URL, onClose: () => setUpdateOpen(false) }) : null
5079
5329
  );
5080
5330
  return page ? h("div", { className: "dshsc-page" }, root) : root;
5081
5331
  }
package/lib/core/hash.js CHANGED
@@ -22,8 +22,28 @@ function collectFiles(dir, out) {
22
22
  }
23
23
 
24
24
  /**
25
- * 逐字复刻 skills CLI 的内容哈希(F6):
26
- * 只收普通文件 → 跳过 `.git` / `node_modules` → 相对路径 `/` 归一 → localeCompare 排序
25
+ * 排序口径:UTF-16 code unit 序(等价 `Array#sort()` 默认序),**与进程 locale 无关**。
26
+ *
27
+ * 绝不能用 `localeCompare`:它按默认 locale 的 collation 比较,同一个目录在不同
28
+ * LANG / ICU 版本下会排出不同顺序,同一份内容就能算出两个哈希。真实案例:
29
+ * `script.md` 与 `什么是token.html` 在 en 下 script 在前、在 zh 下汉字在前,
30
+ * 于是「安装时 en、读时 zh」的技能被误报成「本地已改」。
31
+ */
32
+ export function comparePaths(a, b) {
33
+ return a < b ? -1 : a > b ? 1 : 0;
34
+ }
35
+
36
+ /**
37
+ * 旧口径(v0.6.x 之前):进程 / 显式 locale 的 collation。
38
+ * 只用于识别历史基线(`matchesLegacyHash`),不得用于写新基线。
39
+ */
40
+ export function localeComparator(locale) {
41
+ return (a, b) => a.localeCompare(b, locale);
42
+ }
43
+
44
+ /**
45
+ * 目录内容哈希(F6):
46
+ * 只收普通文件 → 跳过 `.git` / `node_modules` → 相对路径 `/` 归一 → code unit 排序
27
47
  * → 先 update(路径) 再 update(内容) → sha256。
28
48
  *
29
49
  * 用途只有一个:进 L1「本地有没有被改过」的基线,绝不与上游 tree SHA 混用。
@@ -36,12 +56,13 @@ export function contentHash(dir) {
36
56
  * 同 `contentHash`,但允许按相对路径改写文件内容后再参与哈希。
37
57
  * `transform(rel, buffer)` 返回 Buffer;返回 `null` 表示无法归一化(调用方保守处理)。
38
58
  * 用于「仅手动」:把插件写入的 frontmatter 行按 state 还原后再与基线比较。
59
+ * `compare` 只给历史基线识别用,正常路径保持默认的 locale 无关排序。
39
60
  */
40
- export function contentHashWith(dir, transform = null) {
61
+ export function contentHashWith(dir, transform = null, compare = comparePaths) {
41
62
  const files = collectFiles(dir, []).map((file) =>
42
63
  path.relative(dir, file).split(path.sep).join('/'),
43
64
  );
44
- files.sort((a, b) => a.localeCompare(b));
65
+ files.sort(compare);
45
66
  const hash = crypto.createHash('sha256');
46
67
  for (const rel of files) {
47
68
  const raw = fs.readFileSync(path.join(dir, rel));
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
- import { contentHashWith } from './hash.js';
5
+ import { contentHashWith, localeComparator } from './hash.js';
6
6
 
7
7
  /**
8
8
  * 「自动 / 仅手动 / 完全停用」的落盘层。
@@ -527,7 +527,11 @@ export function unparkSkill({ dir, name, fallbackMode = 'auto', keep = null }) {
527
527
  let hashMismatch = false;
528
528
  if (marker?.canonicalHash) {
529
529
  const current = contentHashWith(dir);
530
- hashMismatch = current !== null && current !== marker.canonicalHash;
530
+ // 旧版 marker 里的哈希是按 locale collation 排出来的;换 locale 后同一份内容
531
+ // 也会对不上,先按旧口径复核一次,避免把「排序变了」报成「内容漂移」。
532
+ hashMismatch = current !== null
533
+ && current !== marker.canonicalHash
534
+ && !matchesLegacyHash(dir, null, marker.canonicalHash);
531
535
  }
532
536
  const previousMode = (marker?.previousMode === 'manual' || marker?.previousMode === 'auto')
533
537
  ? marker.previousMode
@@ -547,9 +551,11 @@ export function unparkSkill({ dir, name, fallbackMode = 'auto', keep = null }) {
547
551
  * 归一化哈希:把插件写入的 `disable-model-invocation: true` 按 state 还原后再算目录哈希。
548
552
  * 只有 origin='plugin' 才归一化;author 声明属于上游内容,必须原样计入。
549
553
  * 解析失败 / 当前值不是 true 时返回 null —— 调用方保守判为「本地有改动」。
554
+ *
555
+ * `compare` 仅供 `matchesLegacyHash` 复核旧口径使用;新基线一律用默认的 locale 无关排序。
550
556
  */
551
- export function hashWithoutInvocation(dir, manual) {
552
- if (!manual || manual.origin !== 'plugin') return contentHashWith(dir);
557
+ export function hashWithoutInvocation(dir, manual, compare) {
558
+ if (!manual || manual.origin !== 'plugin') return contentHashWith(dir, null, compare);
553
559
  const expected = manual.original;
554
560
  if (expected !== 'absent' && expected !== 'false') return null;
555
561
  return contentHashWith(dir, (relative, buffer) => {
@@ -565,7 +571,27 @@ export function hashWithoutInvocation(dir, manual) {
565
571
  if (expected === 'absent') lines.splice(parsed.keyIndex, 1);
566
572
  else lines[parsed.keyIndex] = renderLine(lines[parsed.keyIndex], 'false');
567
573
  return Buffer.from(join(parsed, lines), 'utf8');
568
- });
574
+ }, compare);
575
+ }
576
+
577
+ /**
578
+ * 旧基线识别:v0.6.x 之前的内容哈希按进程 locale 的 collation 排序,同一份内容
579
+ * 换 locale / ICU 版本就会得到不同 digest。用几个最常见的 collation 反查,
580
+ * 命中即说明「内容没变、只是排序口径变了」,可以安全地把基线改写成新口径。
581
+ *
582
+ * 只是识别手段:某个旧口径能精确复现记录值,等价于内容逐字节相同。
583
+ */
584
+ export function matchesLegacyHash(dir, manual, digest) {
585
+ if (typeof digest !== 'string' || !digest) return false;
586
+ const defaultLocale = new Intl.Collator().resolvedOptions().locale;
587
+ // 覆盖最常见的几类 collation:拉丁(en)、中文(zh)、日文(ja)。
588
+ // 都不命中就保守地维持「本地已改」,让用户确认 —— 绝不靠猜去改基线。
589
+ const locales = new Set([defaultLocale, 'en', 'zh', 'ja']);
590
+ for (const locale of locales) {
591
+ const compare = locale === defaultLocale ? undefined : localeComparator(locale);
592
+ if (hashWithoutInvocation(dir, manual, compare) === digest) return true;
593
+ }
594
+ return false;
569
595
  }
570
596
 
571
597
  /** 供 Host 层判断「这行到底是不是被 park 了」,不读 state。 */
@@ -0,0 +1,323 @@
1
+ /**
2
+ * 插件自更新:检查 npm 上的最新版本,并按需把**本插件自己**重装到最新版。
3
+ *
4
+ * 与 `updates.js` 的区别:那是「技能」的上游检测(走 GitHub Trees),这里是
5
+ * 「插件包自身」的版本检查(走 npm registry),两条链路互不相干。
6
+ *
7
+ * 为什么不用宿主 `pluginManager.inspect` 查最新版:`inspect` 对**已安装的包名**会直接
8
+ * 拒绝 `already-installed`(它只答「我马上要装的 spec 指向什么」,不是「查已装包的最新版」),
9
+ * 宿主 `listBundles` 也不为第三方插件解析最新版(`installTarget` 只给官方目录条目)。
10
+ * 所以最新版只能自己查 registry —— 但**走宿主 `pluginManager.registries()` 拿到的 registry
11
+ * 计划**(继承 profile 的镜像 / 私有源 / token 配置),拿不到才退回官方 registry。
12
+ *
13
+ * 设计取舍(对齐本机同类插件 archive-manager / ds-bot 的轻量做法,但更保守):
14
+ * - 依次问 registry 计划里的 `<base><pkg>/latest`,8s 超时,只取 `version` 字段;失败不抛,返回 null。
15
+ * - 版本比较手写(本仓 runtime 依赖只有 tar,不引入 semver);结构非法一律判「不更新」。
16
+ * - 安装只走宿主 `pluginManager.installBundle`(桌面端官方能力);拿不到就抛
17
+ * `error.plugin-update.manual`,把手动命令放进 detail 让前端展示/复制。
18
+ * - 不落盘、不加锁:一次点击只触发一次,`applying` 由路由层单飞守住;重启由用户决定。
19
+ */
20
+ import { readFileSync } from 'node:fs';
21
+
22
+ import { ServiceError } from './service.js';
23
+
24
+ export const OFFICIAL_REGISTRY = 'https://registry.npmjs.org/';
25
+ const FETCH_TIMEOUT_MS = 8000;
26
+ const CACHE_TTL_MS = 5 * 60 * 1000;
27
+ const RELEASE_CACHE_TTL_MS = 10 * 60 * 1000;
28
+
29
+ /** 插件身份:包名与当前版本。从 package.json 读一次,避免和 page 头硬编码漂移。 */
30
+ export const PLUGIN_META = (() => {
31
+ try {
32
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
33
+ const repo = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
34
+ const match = /github\.com[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/i.exec(String(repo || ''));
35
+ return {
36
+ name: String(pkg.name || ''),
37
+ version: String(pkg.version || ''),
38
+ repo: match ? match[1] : null,
39
+ };
40
+ } catch {
41
+ return { name: '', version: '', repo: null };
42
+ }
43
+ })();
44
+
45
+ /** 解析 semver:`v1.2.3` / `1.2.3-beta.1` / `1.2.3+build`。非法返回 undefined。 */
46
+ function parseSemver(value) {
47
+ const text = String(value ?? '').trim();
48
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text);
49
+ if (match === null) return undefined;
50
+ return {
51
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
52
+ prerelease: match[4] ? match[4].split('.') : [],
53
+ };
54
+ }
55
+
56
+ function comparePrerelease(left, right) {
57
+ if (left.length === 0 || right.length === 0) {
58
+ return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
59
+ }
60
+ const length = Math.max(left.length, right.length);
61
+ for (let index = 0; index < length; index += 1) {
62
+ const a = left[index];
63
+ const b = right[index];
64
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
65
+ if (a === b) continue;
66
+ const aNumeric = /^\d+$/.test(a);
67
+ const bNumeric = /^\d+$/.test(b);
68
+ if (aNumeric && bNumeric) {
69
+ const an = BigInt(a);
70
+ const bn = BigInt(b);
71
+ if (an !== bn) return an > bn ? 1 : -1;
72
+ continue;
73
+ }
74
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
75
+ return a > b ? 1 : -1;
76
+ }
77
+ return 0;
78
+ }
79
+
80
+ /** `candidate` 是否严格新于 `current`。任一方不是合法 semver 都判 false(宁可不更新)。 */
81
+ export function isNewerVersion(current, candidate) {
82
+ const a = parseSemver(current);
83
+ const b = parseSemver(candidate);
84
+ if (a === undefined || b === undefined) return false;
85
+ for (let index = 0; index < 3; index += 1) {
86
+ if (b.core[index] !== a.core[index]) return b.core[index] > a.core[index];
87
+ }
88
+ return comparePrerelease(b.prerelease, a.prerelease) > 0;
89
+ }
90
+
91
+ /** npm 包名转成 registry 路径:`@scope/name` → `@scope%2Fname`。 */
92
+ function encodePackageName(name) {
93
+ return name.startsWith('@') ? `@${encodeURIComponent(name.slice(1))}` : encodeURIComponent(name);
94
+ }
95
+
96
+ /** 手动更新命令(前端展示/复制用),按已发布包 + 当前 profile 拼。 */
97
+ function manualCommand(meta, profileName) {
98
+ const spec = `${meta.name}@latest`;
99
+ return profileName
100
+ ? `dsh plugin --profile ${profileName} add ${spec}`
101
+ : `dsh plugin add ${spec}`;
102
+ }
103
+
104
+ /** registry 归一成 `https://host/` 形式;非法返回 null。 */
105
+ function normalizeRegistry(value) {
106
+ if (typeof value !== 'string' || value.trim() === '') return null;
107
+ const text = value.trim().replace(/\/+$/, '');
108
+ try {
109
+ const url = new URL(`${text}/`);
110
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
111
+ return url.href;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * 由宿主 `registries()` 结果拼出要问的 registry 顺序:
119
+ * `resolved`(pnpm 实际用的)→ `registry` → `fallbackRegistries`,去重;空则回退官方 registry。
120
+ */
121
+ function registryPlan(registries) {
122
+ const ordered = [];
123
+ const push = (value) => {
124
+ const normalized = normalizeRegistry(value);
125
+ if (normalized && !ordered.includes(normalized)) ordered.push(normalized);
126
+ };
127
+ if (registries && typeof registries === 'object') {
128
+ push(registries.resolved);
129
+ push(registries.registry);
130
+ for (const item of Array.isArray(registries.fallbackRegistries) ? registries.fallbackRegistries : []) push(item);
131
+ }
132
+ if (ordered.length === 0) ordered.push(OFFICIAL_REGISTRY);
133
+ return ordered;
134
+ }
135
+
136
+ /**
137
+ * @param {object} deps
138
+ * @param {() => any} deps.getPluginManager 取宿主 `pluginManager`(可能 undefined)
139
+ * @param {() => string | null} [deps.getProfileName] 取当前 profile 名(仅用于手动命令)
140
+ * @param {typeof fetch} [deps.fetchImpl] 测试注入
141
+ * @param {() => number} [deps.now] 测试注入
142
+ */
143
+ export function createPluginUpdate({ getPluginManager, getProfileName, fetchImpl = globalThis.fetch, now = Date.now, meta = PLUGIN_META } = {}) {
144
+ let cache = null; // { version, expiresAt }
145
+ let applying = false; // 路由层单飞:同一时刻只允许一次安装
146
+
147
+ /**
148
+ * 依次问 registry 计划里的 `<base><pkg>/latest`;第一个给出合法版本的为准。
149
+ * 全部失败返回 null(不抛,交给视图标 checkFailed)。
150
+ */
151
+ async function latestVersion() {
152
+ if (!meta.name) return null;
153
+ if (cache && now() < cache.expiresAt) return cache.version;
154
+ const plan = registryPlan(await readRegistries());
155
+ for (const base of plan) {
156
+ try {
157
+ const response = await fetchImpl(`${base}${encodePackageName(meta.name)}/latest`, {
158
+ headers: { accept: 'application/json' },
159
+ redirect: 'error',
160
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
161
+ });
162
+ if (!response.ok) continue;
163
+ const value = await response.json();
164
+ const version = typeof value?.version === 'string' ? value.version.trim() : '';
165
+ if (parseSemver(version) === undefined) continue;
166
+ cache = { version, expiresAt: now() + CACHE_TTL_MS, registry: base };
167
+ return version;
168
+ } catch {
169
+ // 这个 registry 不通 / 超时:试下一个
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /** 从宿主 `pluginManager.registries()` 取 registry 配置;拿不到返回 null(走官方 registry)。 */
176
+ async function readRegistries() {
177
+ const service = rawService();
178
+ if (service === null || typeof service.registries !== 'function') return null;
179
+ try {
180
+ return await service.registries();
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /** 宿主的 `pluginManager` 服务(任意能力);拿不到返回 null。 */
187
+ function rawService() {
188
+ try {
189
+ const value = typeof getPluginManager === 'function' ? getPluginManager() : undefined;
190
+ return value && typeof value === 'object' ? value : null;
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ /** 能做安装的 `pluginManager`(需 `installBundle`);拿不到返回 null。 */
197
+ function manager() {
198
+ const service = rawService();
199
+ return service && typeof service.installBundle === 'function' ? service : null;
200
+ }
201
+
202
+ function profileName() {
203
+ try {
204
+ const value = typeof getProfileName === 'function' ? getProfileName() : null;
205
+ return typeof value === 'string' && value ? value : null;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ /** 组一份给前端的视图;`canAutoUpdate=false` 时前端只展示手动命令。 */
212
+ function view({ latest, checkFailed = false } = {}) {
213
+ const current = meta.version;
214
+ const updateAvailable = latest !== null && latest !== undefined && isNewerVersion(current, latest);
215
+ return {
216
+ packageName: meta.name,
217
+ currentVersion: current,
218
+ latestVersion: latest ?? null,
219
+ updateAvailable,
220
+ checkFailed,
221
+ canAutoUpdate: manager() !== null,
222
+ manualCommand: manualCommand(meta, profileName()),
223
+ applying,
224
+ };
225
+ }
226
+
227
+ /** GET:读最新版本并返回视图。网络失败不抛,用 checkFailed 标记。 */
228
+ async function check({ fresh = false } = {}) {
229
+ if (fresh) cache = null;
230
+ const latest = await latestVersion();
231
+ return view({ latest, checkFailed: latest === null });
232
+ }
233
+
234
+ /**
235
+ * POST:把本插件重装到最新版。
236
+ * - 先再查一次,确认确实落后,避免拿过期信息装一个「已是最新」;
237
+ * - 没有 pluginManager → 抛 manual,带手动命令;
238
+ * - 安装中重复点击 → 抛 busy。
239
+ */
240
+ async function apply() {
241
+ if (applying) {
242
+ throw new ServiceError('error.plugin-update.busy', '插件正在更新,请稍候', null, 409);
243
+ }
244
+ const latest = await latestVersion();
245
+ if (latest === null) {
246
+ throw new ServiceError('error.plugin-update.check-failed', '暂时无法获取最新版本,请稍后再试', manualCommand(meta, profileName()), 503);
247
+ }
248
+ if (!isNewerVersion(meta.version, latest)) {
249
+ return { ...view({ latest }), updatedVersion: null };
250
+ }
251
+ const pm = manager();
252
+ if (pm === null) {
253
+ throw new ServiceError(
254
+ 'error.plugin-update.manual',
255
+ '当前环境不支持自动更新,请手动执行命令',
256
+ manualCommand(meta, profileName()),
257
+ 409,
258
+ );
259
+ }
260
+ applying = true;
261
+ try {
262
+ // 用查到最新版的那个 registry 去装,和「问」保持一致;没有就用宿主默认(不传 options)
263
+ const registry = cache?.version === latest ? cache.registry : undefined;
264
+ const result = await pm.installBundle(
265
+ `${meta.name}@${latest}`,
266
+ registry ? { registry } : undefined,
267
+ );
268
+ if (result?.error || !['applied', 'restart-required', 'overridden'].includes(result?.application)) {
269
+ const message = result?.error?.diagnostic || result?.error?.message || result?.error?.code || '更新失败';
270
+ throw new ServiceError('error.plugin-update.install-failed', String(message).slice(0, 600), manualCommand(meta, profileName()), 500);
271
+ }
272
+ cache = { version: latest, expiresAt: now() + CACHE_TTL_MS, registry };
273
+ return {
274
+ ...view({ latest }),
275
+ updateAvailable: false,
276
+ updatedVersion: latest,
277
+ restartRequired: true,
278
+ };
279
+ } finally {
280
+ applying = false;
281
+ }
282
+ }
283
+
284
+ // GitHub Release 正文缓存:key = version
285
+ const releaseCache = new Map();
286
+
287
+ /**
288
+ * 拉某个版本的 GitHub Release 正文(发布说明)。
289
+ * - tag 用 `vX.Y.Z`(本仓发布约定),请求 `api.github.com/repos/<repo>/releases/tags/vX`;
290
+ * - 失败(无 repo / 网络 / 404 / 超时)返回 null,不抛;前端据此退回「查看发布说明」链接;
291
+ * - 只读 GitHub 公开 API,不读本机任何文件。
292
+ */
293
+ async function releaseNotes(version) {
294
+ const target = String(version || '').trim();
295
+ if (!meta.repo || parseSemver(target) === undefined) return null;
296
+ const cached = releaseCache.get(target);
297
+ if (cached && now() < cached.expiresAt) return cached.value;
298
+ const tag = `v${target.replace(/^v/, '')}`;
299
+ try {
300
+ const response = await fetchImpl(`https://api.github.com/repos/${meta.repo}/releases/tags/${tag}`, {
301
+ headers: { accept: 'application/vnd.github+json' },
302
+ redirect: 'error',
303
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
304
+ });
305
+ const value = response.ok
306
+ ? await response.json().then((release) => ({
307
+ version: target,
308
+ body: typeof release?.body === 'string' ? release.body : '',
309
+ htmlUrl: typeof release?.html_url === 'string' ? release.html_url : null,
310
+ name: typeof release?.name === 'string' ? release.name : null,
311
+ }))
312
+ : null;
313
+ releaseCache.set(target, { value, expiresAt: now() + RELEASE_CACHE_TTL_MS });
314
+ return value;
315
+ } catch {
316
+ // 失败也短缓存,避免连点重复打网络;10 分钟后自然重试
317
+ releaseCache.set(target, { value: null, expiresAt: now() + RELEASE_CACHE_TTL_MS });
318
+ return null;
319
+ }
320
+ }
321
+
322
+ return { check, apply, releaseNotes };
323
+ }
@@ -18,6 +18,7 @@ import {
18
18
  backupInvocation,
19
19
  hashWithoutInvocation,
20
20
  inspectPark,
21
+ matchesLegacyHash,
21
22
  parkSkill,
22
23
  parseInvocation,
23
24
  planManualRestore,
@@ -906,6 +907,65 @@ export function createService({
906
907
  return records;
907
908
  }
908
909
 
910
+ /**
911
+ * 一次性重基线:v0.6.x 之前的内容哈希按进程 locale 的 collation 排序,换启动环境
912
+ * (或 Node / ICU 版本)后同一份内容会算出不同 digest,被误报成「本地已改」。
913
+ *
914
+ * 只改写**能证明没被改过**的基线:新口径对不上,但某个旧 collation 能精确复现
915
+ * 记录值。内容真的动过的技能一条都不碰 —— 继续按「本地已改」让用户确认覆盖。
916
+ * 停用(parked)期间的技能也跳过:那时 SKILL.md 已改名、目录里多了 marker,
917
+ * 基线由 unpark 重放。
918
+ */
919
+ function rebaselineHashes() {
920
+ const rebaselined = [];
921
+ const unresolved = [];
922
+ if (store.readOnly) return { rebaselined, unresolved, skipped: 'read-only' };
923
+ for (const install of state().installs) {
924
+ if (typeof install.installedHash !== 'string' || !install.installedHash) continue;
925
+ const dir = canonicalDir(install.name);
926
+ let stat;
927
+ try {
928
+ stat = fs.statSync(dir);
929
+ } catch {
930
+ continue;
931
+ }
932
+ if (!stat.isDirectory()) continue;
933
+ if (inspectPark(dir).parked) continue;
934
+ const current = hashWithoutInvocation(dir, install.manual);
935
+ if (!current || current === install.installedHash) continue;
936
+ if (matchesLegacyHash(dir, install.manual, install.installedHash)) {
937
+ rebaselined.push({ name: install.name, from: install.installedHash, to: current });
938
+ } else {
939
+ unresolved.push(install.name);
940
+ }
941
+ }
942
+ if (rebaselined.length > 0) {
943
+ const next = new Map(rebaselined.map((item) => [item.name, item.to]));
944
+ store.update((draft) => {
945
+ for (const row of draft.installs) {
946
+ if (next.has(row.name)) row.installedHash = next.get(row.name);
947
+ }
948
+ });
949
+ logger('state.hash-order.rebaseline', {
950
+ names: rebaselined.map((item) => item.name),
951
+ count: rebaselined.length,
952
+ });
953
+ }
954
+ return { rebaselined, unresolved };
955
+ }
956
+
957
+ // 每个进程只跑一次:写盘后新基线自洽,不必(也不该)每次请求都试旧口径。
958
+ let hashBaselineChecked = false;
959
+ function ensureHashBaseline() {
960
+ if (hashBaselineChecked || store.readOnly || enqueue.pending() > 0) return;
961
+ hashBaselineChecked = true;
962
+ try {
963
+ rebaselineHashes();
964
+ } catch (error) {
965
+ logger('state.hash-order.rebaseline.failed', { message: error?.message || String(error) });
966
+ }
967
+ }
968
+
909
969
  function canonicalDir(name) {
910
970
  return path.join(paths.canonical, name);
911
971
  }
@@ -1119,6 +1179,8 @@ export function createService({
1119
1179
  logger('state.migrate.failed', { message: error.message });
1120
1180
  }
1121
1181
  }
1182
+ // 旧版内容哈希按 locale 排序,升级后先做一次性重基线,别把「排序变了」显示成「本地已改」
1183
+ ensureHashBaseline();
1122
1184
  return store.state;
1123
1185
  },
1124
1186
 
@@ -1138,6 +1200,9 @@ export function createService({
1138
1200
  return reconcileInvocations();
1139
1201
  },
1140
1202
 
1203
+ /** 诊断/测试用:手动跑一次旧哈希口径的重基线(启动时已自动跑过一次)。 */
1204
+ rebaselineHashes,
1205
+
1141
1206
  async getState({ cwd, workspace } = {}) {
1142
1207
  const lock = readLock(paths);
1143
1208
  reconcileTracking(lock);
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@ import { createPolicyProvider, installPolicyProviders } from './core/policy.js';
6
6
  import { createService } from './core/service.js';
7
7
  import { normalizeIntroConfig } from './core/intro.js';
8
8
  import { createIntroStore } from './core/intro-store.js';
9
+ import { createPluginUpdate } from './core/plugin-update.js';
9
10
  import { StateStore } from './core/state.js';
10
11
  import { createRoutes, ROUTE_PREFIX } from './routes.js';
11
12
 
@@ -113,7 +114,19 @@ export function apply(ctx, config = {}) {
113
114
  logger('plugin.migrate.failed', { message: error?.message, code: error?.code });
114
115
  }
115
116
 
116
- const handler = createRoutes({ service, logger });
117
+ /**
118
+ * 插件自更新:检查 npm 最新版 / 一键重装自身。
119
+ * `pluginManager` 是桌面端官方能力(installBundle),按可选服务取;拿不到时路由只报手动命令。
120
+ */
121
+ const pluginUpdate = createPluginUpdate({
122
+ getPluginManager: () => ctx.get('pluginManager'),
123
+ getProfileName: () => {
124
+ const profile = ctx.get('profileContext');
125
+ return typeof profile?.name === 'string' ? profile.name : null;
126
+ },
127
+ });
128
+
129
+ const handler = createRoutes({ service, pluginUpdate, logger });
117
130
  ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler }), 'dsh-skills-console:routes');
118
131
  ctx.effect(() => () => policies.dispose(), 'dsh-skills-console:policy');
119
132
 
@@ -137,5 +150,20 @@ export function apply(ctx, config = {}) {
137
150
  logger('plugin.autoCheck.off', { reason: 'timer-service-missing' });
138
151
  }
139
152
 
153
+ // 插件自身的版本检查:启动后延迟一次(有 timer 用它,没有就 setTimeout),只热身缓存不打扰用户。
154
+ // 失败静默——真正的展示交给前端打开刷新按钮/弹窗时再查。
155
+ const warmPluginUpdate = () => {
156
+ void pluginUpdate.check().catch((error) => logger('plugin.update.warm.failed', { message: error?.message }));
157
+ };
158
+ if (timer && typeof timer.timeout === 'function') {
159
+ ctx.effect(() => timer.timeout(warmPluginUpdate, 20 * 1000), 'dsh-skills-console:plugin-update-warm');
160
+ } else {
161
+ ctx.effect(() => {
162
+ const handle = setTimeout(warmPluginUpdate, 20 * 1000);
163
+ handle.unref?.();
164
+ return () => clearTimeout(handle);
165
+ }, 'dsh-skills-console:plugin-update-warm');
166
+ }
167
+
140
168
  logger('plugin.apply', { stateDir: paths.stateDir, canonical: paths.canonical, readOnly: store.readOnly });
141
169
  }
package/lib/routes.js CHANGED
@@ -118,10 +118,20 @@ function matchRoute(method, pathname) {
118
118
  if (segments.length === 2 && segments[1] === 'rollback' && method === 'POST') return { name: 'updates.rollback' };
119
119
  }
120
120
 
121
+ // 插件自身版本检查 / 更新(与技能上游 updates.* 是两回事,见 core/plugin-update.js)
122
+ if (segments.length === 1 && segments[0] === 'plugin-update' && method === 'GET') return { name: 'plugin.update.check' };
123
+ if (segments.length === 2 && segments[0] === 'plugin-update' && segments[1] === 'apply' && method === 'POST') {
124
+ return { name: 'plugin.update.apply' };
125
+ }
126
+ // 拉某版本的发布说明(GitHub Release 正文):GET /plugin-update/release?version=x.y.z
127
+ if (segments.length === 2 && segments[0] === 'plugin-update' && segments[1] === 'release' && method === 'GET') {
128
+ return { name: 'plugin.update.release' };
129
+ }
130
+
121
131
  return null;
122
132
  }
123
133
 
124
- export function createRoutes({ service, logger }) {
134
+ export function createRoutes({ service, pluginUpdate = null, logger }) {
125
135
  return async function handler(req, res) {
126
136
  const url = new URL(req.url || '/', 'http://127.0.0.1');
127
137
  const pathname = url.pathname.startsWith(ROUTE_PREFIX)
@@ -153,7 +163,7 @@ export function createRoutes({ service, logger }) {
153
163
  service.reloadState();
154
164
  const body = method === 'GET' ? {} : await readBody(req);
155
165
  const query = Object.fromEntries(url.searchParams.entries());
156
- const result = await dispatch(route, { body, query, service, signal: abort.signal });
166
+ const result = await dispatch(route, { body, query, service, pluginUpdate, signal: abort.signal });
157
167
  // 生成期间客户端可能已经走了:往已关闭的响应写会抛 ERR_STREAM_WRITE_AFTER_END
158
168
  if (res.writableEnded || res.destroyed) return;
159
169
  sendJson(res, 200, result);
@@ -181,7 +191,7 @@ export function createRoutes({ service, logger }) {
181
191
  };
182
192
  }
183
193
 
184
- async function dispatch(route, { body, query, service, signal = null }) {
194
+ async function dispatch(route, { body, query, service, pluginUpdate = null, signal = null }) {
185
195
  switch (route.name) {
186
196
  case 'state': {
187
197
  const state = await service.getState({ cwd: query.cwd || body.cwd, workspace: query.workspace });
@@ -257,6 +267,19 @@ async function dispatch(route, { body, query, service, signal = null }) {
257
267
  return service.applyUpdate({ name: body.name, token: body.token, overwrite: Boolean(body.overwrite) });
258
268
  case 'updates.rollback':
259
269
  return service.rollback({ name: body.name });
270
+ case 'plugin.update.check': {
271
+ if (!pluginUpdate) throw new ServiceError('error.plugin-update.unavailable', '插件自更新不可用');
272
+ return pluginUpdate.check({ fresh: query.fresh === '1' || body.fresh === true });
273
+ }
274
+ case 'plugin.update.apply': {
275
+ if (!pluginUpdate) throw new ServiceError('error.plugin-update.unavailable', '插件自更新不可用');
276
+ return pluginUpdate.apply();
277
+ }
278
+ case 'plugin.update.release': {
279
+ if (!pluginUpdate) throw new ServiceError('error.plugin-update.unavailable', '插件自更新不可用');
280
+ const version = query.version || body.version;
281
+ return pluginUpdate.releaseNotes(version);
282
+ }
260
283
  default:
261
284
  throw new ServiceError('error.skills.no-route', `unhandled route ${route.name}`);
262
285
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morehao/dsh-skills-console",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "统一管理本地技能的安装、更新、删除与接入,让同一份技能在多个 Agent 间复用。A skill manager for DeepSeek Harness: install, update, remove, link and toggle agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "morehao <morehao@qq.com>",