dsh-m 0.2.10 → 0.2.12
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 +6 -3
- package/README.md +6 -3
- package/docs/DESIGN.md +3 -3
- package/lib/client.js +43 -17
- package/lib/core/host-api.js +2 -1
- package/lib/core/restart.js +149 -27
- package/lib/host.js +7 -2
- package/lib/tools.js +5 -4
- package/package.json +2 -2
package/README.en.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/dsh-m)
|
|
5
5
|
[](../../actions/workflows/registry.yml)
|
|
6
6
|
[](./LICENSE)
|
|
7
|
-
[](#faq)
|
|
8
8
|
|
|
9
9
|
English · [中文](./README.md)
|
|
10
10
|
|
|
@@ -22,7 +22,7 @@ A DeepSeek Harness (DSH) plugin marketplace with a customizable registry: **brow
|
|
|
22
22
|
```text
|
|
23
23
|
Install and enable the DSH plugin dsh-m:
|
|
24
24
|
1. Run `dsh plugin --profile web add dsh-m`
|
|
25
|
-
2. Restart DSH Web to load the plugin (
|
|
25
|
+
2. Restart DSH Web to load the plugin (restart the DSH user unit used by your deployment; do not hardcode a unit name)
|
|
26
26
|
3. Poll `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3080` until it returns 200 again
|
|
27
27
|
4. Run `curl -s -X POST http://127.0.0.1:3080/dshm -H 'content-type: application/json' -d '{"method":"ping"}'` and confirm the reply has `plugin: dsh-m`
|
|
28
28
|
5. Then remind me to refresh the page and click "Plugin Marketplace" at the bottom of the sidebar
|
|
@@ -40,7 +40,7 @@ The default registry includes DSH Skins, ModSearch, the Lark / QQ / Weixin / WeC
|
|
|
40
40
|
| **Installed** | What your web profile actually has, annotated "via market / non-market"; update badges, upgrade, two-step confirm uninstall; 📖 README preview (64KB cap) |
|
|
41
41
|
| **Settings** | Registry address draft with "Validate & apply / Restore default / Download default registry.json / Check entries reachability"; configured vs active address and status at a glance; force refresh; dsh-m self-update |
|
|
42
42
|
|
|
43
|
-
After any mutation a "⚡ Restart" banner appears —
|
|
43
|
+
After any mutation, the already-open Market and Installed views refresh the profile state together, keeping badges and cards in sync without closing and reopening the marketplace; a "⚡ Restart" banner appears — under systemd, the DSH launcher's `appExit` hook hands the restart back to a unit configured with `Restart=on-failure` or `Restart=always`, avoiding a `systemctl` helper inside the unit cgroup that is about to stop; if `appExit` is unavailable, the fallback uses a manager-owned transient `systemd-run` service and only then a detached helper. The client confirms the replacement by boot id and dismisses the banner, leaving DSH Web's own background connection recovery in control; it does not force a full-page reload during the auth/route handoff. The current DSH Web `0.1.5-rc.1` runtime has now live-loaded dsh-m `0.2.11`; `/dshm` ping returned version `0.2.11`, and an authenticated page request completed `303 → 200`. The reported 404 was not reproducible once the service stabilized; journal evidence showed repeated `status=75/TEMPFAIL` restarts during the incident. Live DSH `0.1.2-rc.1`, transient-fallback, and repeated install/uninstall E2E experiments remain pre-release gaps. Installs stream live pnpm progress (resolve → download → link → build).
|
|
44
44
|
|
|
45
45
|
## Agent tools (7)
|
|
46
46
|
|
|
@@ -110,6 +110,9 @@ Listings over 200 entries trigger a performance notice. The market list is serve
|
|
|
110
110
|
**4. What if my custom source goes down?**
|
|
111
111
|
dsh-m serves its last successful cache for that source and marks it as cached; with no cache at all the market shows "registry unavailable" while installed plugins stay manageable. Fix the address or restore the default anytime.
|
|
112
112
|
|
|
113
|
+
**5. Which DSH Web versions does the restart button support?**
|
|
114
|
+
The public package contracts for `0.1.2-rc.1` and `0.1.5-rc.1` are checked; both expose the dsh-m `appExit` launcher hook. The current `openbmc-dsh.service` runtime has live-loaded dsh-m `0.2.11`; `/dshm` ping returned `0.2.11`, and an authenticated page request completed `303 → 200`. The reported 404 was not reproducible after service stabilization; journal evidence recorded repeated `status=75/TEMPFAIL` restarts during the incident. Live DSH `0.1.2-rc.1`, transient-fallback, and repeated install/uninstall E2E experiments remain pre-release gaps. Systemd deployments must configure `Restart=on-failure` or `Restart=always`; otherwise use the deployment's manual restart procedure.
|
|
115
|
+
|
|
113
116
|
## License
|
|
114
117
|
|
|
115
118
|
MIT
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/dsh-m)
|
|
5
5
|
[](../../actions/workflows/registry.yml)
|
|
6
6
|
[](./LICENSE)
|
|
7
|
-
[](#faq)
|
|
8
8
|
|
|
9
9
|
[English](./README.en.md) · 中文
|
|
10
10
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
```text
|
|
23
23
|
安装并启用 DSH 插件 dsh-m:
|
|
24
24
|
1. 执行 `dsh plugin --profile web add dsh-m`
|
|
25
|
-
2. 重启 DSH Web
|
|
25
|
+
2. 重启 DSH Web 使插件加载(按部署方式重启对应的 DSH user unit;unit 名称不要硬编码)
|
|
26
26
|
3. 轮询 `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3080`,直到恢复 200
|
|
27
27
|
4. 执行 `curl -s -X POST http://127.0.0.1:3080/dshm -H 'content-type: application/json' -d '{"method":"ping"}'`,确认返回 `plugin: dsh-m`
|
|
28
28
|
5. 完成后提醒我刷新页面,点击侧栏底部的「插件市场」
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
| **已装** | web profile 实装列表,标注「市场安装 / 非市场安装」;可升级徽标、升级、两段式确认卸载;📖 README 预览(64KB 截断) |
|
|
41
41
|
| **设置** | registry 地址草稿 +「校验并应用 / 恢复默认 / 下载默认 registry.json / 检查条目可达性」;配置地址、生效来源与状态一目了然;强制刷新;dsh-m 自更新 |
|
|
42
42
|
|
|
43
|
-
安装 / 卸载 /
|
|
43
|
+
安装 / 卸载 / 升级完成后,当前已打开的市场页与已装页会一起重新读取 profile 状态并同步徽标/卡片,不需要关闭后重新打开插件市场;随后出现「⚡ 一键重启」横幅——受 systemd 管理时通过 DSH launcher 的 `appExit` 交给服务的 `Restart` 策略,避免在待停止 unit 的 cgroup 内启动 `systemctl` helper;无 `appExit` 的 systemd 兜底改用 manager-owned transient `systemd-run`,最后才退回 detached-helper。客户端按 boot id 确认新进程已恢复后关闭横幅,交由 DSH Web 自身的后台连接重试恢复页面,不强制整页刷新,避免认证/路由切换期间白屏。已完成当前 DSH Web `0.1.5-rc.1` 运行时的 live 核验:web profile 已加载 dsh-m `0.2.11`,重启后 `/dshm` ping 返回 `version: 0.2.11`,携带当前认证 token 的页面请求流程为 `303 → 200`;无认证请求会被拒绝。此次白屏截图对应的 404 在服务稳定后未复现;journal 显示截图时段发生多次 `status=75/TEMPFAIL` 重启,当前暂判定为重启/认证过渡窗口现象,未发现 dsh-m Host 路由崩溃。DSH `0.1.2-rc.1` live E2E、transient fallback live E2E,以及连续安装/卸载实验仍是正式发布前的验证 gap。安装过程实时显示 pnpm 进度(解析 → 下载 → 链接 → 构建)。
|
|
44
44
|
|
|
45
45
|
## Agent 工具(7 个)
|
|
46
46
|
|
|
@@ -110,6 +110,9 @@ main 上的中间提交可能不稳定。dsh-m 只跟踪 **release / tag**(优
|
|
|
110
110
|
**4. 自定义源挂了怎么办?**
|
|
111
111
|
优先使用该源最近一次成功的缓存并标记「缓存来源」;完全没有缓存时市场显示「收录清单不可用」,已安装插件仍可正常管理。修正地址或恢复默认即可。
|
|
112
112
|
|
|
113
|
+
**5. 重启按钮支持哪些 DSH Web 版本?**
|
|
114
|
+
已核对 `0.1.2-rc.1` 与 `0.1.5-rc.1` 的公开包契约;两版都提供 dsh-m 使用的 `appExit` launcher hook。当前 `openbmc-dsh.service` 的 0.1.5 运行时已实际加载 dsh-m `0.2.11` 并完成 `/dshm` ping 与带认证页面的 `303 → 200` 核验;白屏期间的多次 `status=75/TEMPFAIL` 已记录,当前稳定服务未复现 404。0.1.2 live E2E、transient fallback live E2E 和连续插件安装/卸载实验仍需发布前补做。systemd 部署需要 unit 配置 `Restart=on-failure` 或 `Restart=always`,否则请使用部署方的手动重启方式。
|
|
115
|
+
|
|
113
116
|
## License
|
|
114
117
|
|
|
115
118
|
MIT
|
package/docs/DESIGN.md
CHANGED
|
@@ -83,7 +83,7 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
|
|
|
83
83
|
- **卸载**(事务定序写死):validate(严格读取:NOT_INSTALLED / NOT_DSH_PLUGIN / PLUGIN_METADATA_UNREADABLE,全部零写入零快照)→ 快照 → live-disable(先让 client bundle 下线,避免 404;回滚时尽力反向)→ 摘除该包补丁条目(`pnpm-workspace.yaml` 顶层 `patchedDependencies` 与 `package.json#pnpm.patchedDependencies`;残留条目会令 pnpm 以 `ERR_PNPM_UNUSED_PATCH` 整单失败,只精确匹配 `pkg` / `pkg@ver`,补丁文件本体保留并计入残留报告)→ remove → verify gone(仍在则回滚)。**不清理插件产生的数据/配置**,但把检测到的疑似残留路径(如 `~/.dsh/<plugin>.json`)列出报告。
|
|
84
84
|
- **升级**:**按需检查**(`dshm_outdated` / `dshm_list` 时实时比对本地版本 vs npm latest / GitHub release,半自动——展示升级计划,确认后执行,即重开一次安装事务)。**不做后台定时器**。
|
|
85
85
|
- **自更新**:dsh-m 对自己同样做版本比对 + 提示升级(设置页呈现);执行即 `install-npm` 事务(integrity 缺失直接拒绝,不进事务)。
|
|
86
|
-
-
|
|
86
|
+
- **重启**:内置**一键重启**。在服务管理器托管的 DSH 进程内,只有确认 unit 的 `Restart=on-failure` / `Restart=always` 且 `75` 未被 `SuccessExitStatus` / `RestartPreventExitStatus` 覆盖时,才调用 launcher 提供的 `appExit(75)`;策略未知或不满足时改用 manager-owned transient `systemd-run` 调用 `systemctl`,不猜测或硬编码 unit 名称,也不在即将停止的 cgroup 内 detached spawn `systemctl`。无 systemd/appExit 时再退回 detached-helper 兼容路径。安装/卸载/升级完成后 GUI 弹「需重启生效 [一键重启]」横幅;客户端以 boot id 确认替换进程后关闭横幅,交由 DSH Web 自身后台连接恢复,不强制整页刷新;工具返回重启提示。
|
|
87
87
|
- **安全基线(5 条)**:
|
|
88
88
|
1. 所有拉取仅 HTTPS + 响应大小上限 + 超时;
|
|
89
89
|
2. npm 安装校验 integrity;
|
|
@@ -95,7 +95,7 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
|
|
|
95
95
|
|
|
96
96
|
3 个视图,卡片展开式详情(不做独立详情页),**中文优先**,跟随 DSH Web 深色主题:
|
|
97
97
|
|
|
98
|
-
1. **市场页**(默认):registry 卡片流;搜索/分类为**服务端过滤**(Host 强制 `withLatest=true`、每页 50、1,000 条清单第一页只探测当前页);`MarketPanel` 是市场/已装数据唯一 owner(请求 generation + AbortController
|
|
98
|
+
1. **市场页**(默认):registry 卡片流;搜索/分类为**服务端过滤**(Host 强制 `withLatest=true`、每页 50、1,000 条清单第一页只探测当前页);`MarketPanel` 是市场/已装数据唯一 owner(请求 generation + AbortController 丢弃旧响应);安装/卸载/升级完成后通过统一协调器同时刷新市场与已装快照,不要求关闭并重新打开面板;分页控件 + 超过 200 条性能提示 + 默认/自定义/缓存/不可用短提示(不含本地路径);卡片详情保留 README markdown 预览与 npm/GitHub 官方外链。
|
|
99
99
|
2. **已装页**:profile 实际安装列表,标注来源(市场/非市场/未知);registry 不可用时仍列出已装并标记;「可升级 → x.y.z」徽标 +「升级」;「卸载」。
|
|
100
100
|
3. **设置页**:registry 地址草稿 +「校验并应用」(先校验候选再写 settings,失败不落盘)/「恢复默认」/「下载默认 registry.json」(不改当前配置)/「检查条目可达性」(probe 统计 + 最多 100 条 issue,只读不改配置);同时展示配置地址、当前生效配置、生效来源与 configStatus(含 rejected/回滚原因与维护性 warnings);完整本地路径仅在此页显示。registry 配置为 **live 生效,不出现安装类重启横幅**;安装/卸载/升级仍保留重启横幅。
|
|
101
101
|
|
|
@@ -182,5 +182,5 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
|
|
|
182
182
|
|
|
183
183
|
- profile 根:`$DSH_HOME/profiles/web`(默认 `~/.dsh/profiles/web`);已装插件 = 其 `package.json` dependencies + `dsh.profile.bundles`。
|
|
184
184
|
- 新装插件需**重启 dsh web** 才加载;HMR 仅在 `pnpm run dev:web` watcher 存活时有效。
|
|
185
|
-
- 禁止创建监听 3080
|
|
185
|
+
- 禁止创建监听 3080 的进程;部署 unit 名称(例如 `openbmc-dsh.service`、`deepseek-harness.service` 或 `dsh-web.service`)只作运行时事实,不硬编码到 dsh-m;managed host 的重启由 `appExit` + unit `Restart` 策略完成。
|
|
186
186
|
- 插件包协议要点:`type: module`、`main` host 入口、`exports["./client"]` 指向打包产物、`dsh.client.platform: "web"`、`dsh.bundle.patch: ./cordis.patch.yml`(`- insert: - id: dsh-m; name: dsh-m`)。
|
package/lib/client.js
CHANGED
|
@@ -388,6 +388,7 @@ var restart_wait_exports = {};
|
|
|
388
388
|
__export(restart_wait_exports, {
|
|
389
389
|
RESTART_DEADLINE_MS: () => RESTART_DEADLINE_MS,
|
|
390
390
|
RESTART_POLL_MS: () => RESTART_POLL_MS,
|
|
391
|
+
isAmbiguousRestartRequestError: () => isAmbiguousRestartRequestError,
|
|
391
392
|
nextRestartWait: () => nextRestartWait
|
|
392
393
|
});
|
|
393
394
|
function nextRestartWait({ phase, now = 0, deadlineAt = Infinity, bootChanged = false }) {
|
|
@@ -396,6 +397,10 @@ function nextRestartWait({ phase, now = 0, deadlineAt = Infinity, bootChanged =
|
|
|
396
397
|
}
|
|
397
398
|
return bootChanged ? "done" : "continue";
|
|
398
399
|
}
|
|
400
|
+
function isAmbiguousRestartRequestError(error) {
|
|
401
|
+
const name = error && typeof error === "object" ? error.name : "";
|
|
402
|
+
return name === "TypeError" || name === "AbortError" || name === "NetworkError";
|
|
403
|
+
}
|
|
399
404
|
var RESTART_POLL_MS, RESTART_DEADLINE_MS;
|
|
400
405
|
var init_restart_wait = __esm({
|
|
401
406
|
"src/client/restart-wait.js"() {
|
|
@@ -405,6 +410,23 @@ var init_restart_wait = __esm({
|
|
|
405
410
|
}
|
|
406
411
|
});
|
|
407
412
|
|
|
413
|
+
// src/client/view-refresh.js
|
|
414
|
+
var view_refresh_exports = {};
|
|
415
|
+
__export(view_refresh_exports, {
|
|
416
|
+
refreshAfterMutation: () => refreshAfterMutation
|
|
417
|
+
});
|
|
418
|
+
async function refreshAfterMutation({ marketReload, installedReload }) {
|
|
419
|
+
await Promise.all([
|
|
420
|
+
marketReload(false),
|
|
421
|
+
installedReload()
|
|
422
|
+
]);
|
|
423
|
+
}
|
|
424
|
+
var init_view_refresh = __esm({
|
|
425
|
+
"src/client/view-refresh.js"() {
|
|
426
|
+
"use strict";
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
408
430
|
// src/client/main.jsx
|
|
409
431
|
var React = require("react");
|
|
410
432
|
var rd = require("react-dom");
|
|
@@ -417,7 +439,8 @@ var { createMarkdown: createMarkdown2 } = (init_markdown(), __toCommonJS(markdow
|
|
|
417
439
|
var { ExtLink, MdImg, renderMarkdown } = createMarkdown2(h);
|
|
418
440
|
var { installedViewModel: installedViewModel2, registrySourceKey: registrySourceKey2 } = (init_installed_view(), __toCommonJS(installed_view_exports));
|
|
419
441
|
var { pickPayload: pickPayload2, parseToolArgs: parseToolArgs2 } = (init_tool_view(), __toCommonJS(tool_view_exports));
|
|
420
|
-
var { RESTART_POLL_MS: RESTART_POLL_MS2, RESTART_DEADLINE_MS: RESTART_DEADLINE_MS2, nextRestartWait: nextRestartWait2 } = (init_restart_wait(), __toCommonJS(restart_wait_exports));
|
|
442
|
+
var { RESTART_POLL_MS: RESTART_POLL_MS2, RESTART_DEADLINE_MS: RESTART_DEADLINE_MS2, nextRestartWait: nextRestartWait2, isAmbiguousRestartRequestError: isAmbiguousRestartRequestError2 } = (init_restart_wait(), __toCommonJS(restart_wait_exports));
|
|
443
|
+
var { refreshAfterMutation: refreshAfterMutation2 } = (init_view_refresh(), __toCommonJS(view_refresh_exports));
|
|
421
444
|
var ZH = {
|
|
422
445
|
"market.title": "\u63D2\u4EF6\u5E02\u573A",
|
|
423
446
|
"tab.market": "\u5E02\u573A",
|
|
@@ -567,7 +590,7 @@ var ZH = {
|
|
|
567
590
|
"restart.now": "\u26A1 \u4E00\u952E\u91CD\u542F",
|
|
568
591
|
"restart.failed": "\u91CD\u542F\u5931\u8D25\uFF1A{err}",
|
|
569
592
|
"restart.timeout": "\u91CD\u542F\u8D85\u65F6\uFF0C\u8BF7\u624B\u52A8\u68C0\u67E5 dsh web \u670D\u52A1\u72B6\u6001",
|
|
570
|
-
"restart.hint.done": "\u5DF2\u8BF7\u6C42\u91CD\u542F DSH web\uFF08via {via}\uFF09\u3002\u670D\u52A1\
|
|
593
|
+
"restart.hint.done": "\u5DF2\u8BF7\u6C42\u91CD\u542F DSH web\uFF08via {via}\uFF09\u3002\u670D\u52A1\u6062\u590D\u540E DSH Web \u4F1A\u5728\u540E\u53F0\u81EA\u52A8\u91CD\u8FDE\u3002",
|
|
571
594
|
"phase.resolving": "\u89E3\u6790\u4F9D\u8D56",
|
|
572
595
|
"phase.downloading": "\u4E0B\u8F7D",
|
|
573
596
|
"phase.linking": "\u94FE\u63A5\u5B89\u88C5",
|
|
@@ -586,8 +609,6 @@ var ZH = {
|
|
|
586
609
|
};
|
|
587
610
|
var EN = {
|
|
588
611
|
"market.title": "Plugin Marketplace",
|
|
589
|
-
"title.panel": "Plugin Marketplace",
|
|
590
|
-
"title.full": "DeepSeek Harness Plugin Marketplace",
|
|
591
612
|
"tab.market": "Market",
|
|
592
613
|
"tab.installed": "Installed",
|
|
593
614
|
"tab.settings": "Settings",
|
|
@@ -735,7 +756,7 @@ var EN = {
|
|
|
735
756
|
"restart.now": "\u26A1 Restart",
|
|
736
757
|
"restart.failed": "Restart failed: {err}",
|
|
737
758
|
"restart.timeout": "Restart timed out \u2014 check the dsh web service manually",
|
|
738
|
-
"restart.hint.done": "Restart requested (via {via}).
|
|
759
|
+
"restart.hint.done": "Restart requested (via {via}). DSH Web will reconnect in the background after the service returns.",
|
|
739
760
|
"phase.resolving": "Resolving",
|
|
740
761
|
"phase.downloading": "Downloading",
|
|
741
762
|
"phase.linking": "Linking",
|
|
@@ -1012,7 +1033,11 @@ function RestartBanner({ note, onDone }) {
|
|
|
1012
1033
|
setPhase("restarting");
|
|
1013
1034
|
try {
|
|
1014
1035
|
const ping0 = await api("ping");
|
|
1015
|
-
|
|
1036
|
+
try {
|
|
1037
|
+
await api("restart");
|
|
1038
|
+
} catch (requestError) {
|
|
1039
|
+
if (!isAmbiguousRestartRequestError2(requestError)) throw requestError;
|
|
1040
|
+
}
|
|
1016
1041
|
setPhase("waiting");
|
|
1017
1042
|
const deadlineAt = Date.now() + RESTART_DEADLINE_MS2;
|
|
1018
1043
|
for (; ; ) {
|
|
@@ -1049,7 +1074,7 @@ function RestartBanner({ note, onDone }) {
|
|
|
1049
1074
|
phase === "idle" && !err ? h("button", { className: "dshm-btn sm", onClick: () => onDone(false) }, lookup("common.later")) : null
|
|
1050
1075
|
);
|
|
1051
1076
|
}
|
|
1052
|
-
function MarketTab({ notify, market }) {
|
|
1077
|
+
function MarketTab({ notify, market, onMutation }) {
|
|
1053
1078
|
const { data, loading, error, reload, query, updateQuery } = market;
|
|
1054
1079
|
const [openId, setOpenId] = useState(null);
|
|
1055
1080
|
const [busyId, setBusyId] = useState(null);
|
|
@@ -1077,7 +1102,7 @@ function MarketTab({ notify, market }) {
|
|
|
1077
1102
|
needsRestart: true,
|
|
1078
1103
|
text: lookup("notify.installed", { pkg: res.pkg, version: res.version ? ` v${res.version}` : "" }) + (res.usedAllowAllBuilds ? lookup("notify.allowbuilds") : "")
|
|
1079
1104
|
});
|
|
1080
|
-
await reload(false);
|
|
1105
|
+
await (onMutation ? onMutation() : reload(false));
|
|
1081
1106
|
} catch (e) {
|
|
1082
1107
|
notify({ kind: "err", text: lookup("failed.install", { err: e && e.message || e }) });
|
|
1083
1108
|
} finally {
|
|
@@ -1235,7 +1260,7 @@ function ReadmeBlock({ pkg }) {
|
|
|
1235
1260
|
state.truncated ? h("div", { className: "dshm-md-note" }, lookup("readme.truncated")) : null
|
|
1236
1261
|
);
|
|
1237
1262
|
}
|
|
1238
|
-
function InstalledTab({ notify, installed }) {
|
|
1263
|
+
function InstalledTab({ notify, installed, onMutation }) {
|
|
1239
1264
|
const { loading, data, error, reload } = installed;
|
|
1240
1265
|
const [openPkg, setOpenPkg] = useState(null);
|
|
1241
1266
|
const [readmePkg, setReadmePkg] = useState(null);
|
|
@@ -1249,7 +1274,7 @@ function InstalledTab({ notify, installed }) {
|
|
|
1249
1274
|
needsRestart: true,
|
|
1250
1275
|
text: lookup("notify.uninstalled", { pkg: res.pkg }) + (res.liveDisabled ? lookup("notify.livedisabled") : "") + (res.leftovers && res.leftovers.length ? lookup("notify.leftovers", { paths: res.leftovers.join(", ") }) : "")
|
|
1251
1276
|
});
|
|
1252
|
-
await reload();
|
|
1277
|
+
await (onMutation ? onMutation() : reload());
|
|
1253
1278
|
} catch (e) {
|
|
1254
1279
|
notify({ kind: "err", text: lookup("failed.uninstall", { err: e && e.message || e }) });
|
|
1255
1280
|
} finally {
|
|
@@ -1269,7 +1294,7 @@ function InstalledTab({ notify, installed }) {
|
|
|
1269
1294
|
to: res.version ? `v${res.version}` : res.sha ? res.sha.slice(0, 7) : "latest"
|
|
1270
1295
|
}) + (res.usedAllowAllBuilds ? lookup("notify.upgradehint") : "")
|
|
1271
1296
|
});
|
|
1272
|
-
await reload();
|
|
1297
|
+
await (onMutation ? onMutation() : reload());
|
|
1273
1298
|
} catch (e) {
|
|
1274
1299
|
notify({ kind: "err", text: lookup("failed.upgrade", { err: e && e.message || e }) });
|
|
1275
1300
|
} finally {
|
|
@@ -1625,10 +1650,11 @@ function MarketPanel({ onClose }) {
|
|
|
1625
1650
|
const [tab, setTab] = useState("market");
|
|
1626
1651
|
const market = useMarketData();
|
|
1627
1652
|
const installed = useAsync(() => api("installed"), []);
|
|
1628
|
-
const
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1653
|
+
const refreshViews = useCallback(
|
|
1654
|
+
() => refreshAfterMutation2({ marketReload: market.reload, installedReload: installed.reload }),
|
|
1655
|
+
[market.reload, installed.reload]
|
|
1656
|
+
);
|
|
1657
|
+
const onRegistryChanged = refreshViews;
|
|
1632
1658
|
const counts = {
|
|
1633
1659
|
market: market.data ? market.data.total : null,
|
|
1634
1660
|
installed: installed.data ? installed.data.items.length : null
|
|
@@ -1686,8 +1712,8 @@ function MarketPanel({ onClose }) {
|
|
|
1686
1712
|
h(
|
|
1687
1713
|
"div",
|
|
1688
1714
|
{ className: "dshm-body" },
|
|
1689
|
-
tab === "market" ? h(MarketTab, { notify, market }) : null,
|
|
1690
|
-
tab === "installed" ? h(InstalledTab, { notify, installed }) : null,
|
|
1715
|
+
tab === "market" ? h(MarketTab, { notify, market, onMutation: refreshViews }) : null,
|
|
1716
|
+
tab === "installed" ? h(InstalledTab, { notify, installed, onMutation: refreshViews }) : null,
|
|
1691
1717
|
tab === "settings" ? h(SettingsTab, { notify, onRegistryChanged }) : null
|
|
1692
1718
|
),
|
|
1693
1719
|
toast ? h(
|
package/lib/core/host-api.js
CHANGED
|
@@ -161,6 +161,7 @@ export function createApiDispatcher(ctx) {
|
|
|
161
161
|
checkRegistryEntries: ctx.deps?.checkRegistryEntries ?? checkRegistryEntries,
|
|
162
162
|
npmLatest: ctx.deps?.npmLatest ?? npmLatest,
|
|
163
163
|
runTransaction: ctx.deps?.runTransaction ?? runProfileTransaction,
|
|
164
|
+
scheduleRestart: ctx.deps?.scheduleRestart ?? scheduleRestart,
|
|
164
165
|
};
|
|
165
166
|
const cfg = () => ctx.controller.config;
|
|
166
167
|
return async function handleApi(req, res) {
|
|
@@ -273,7 +274,7 @@ export function createApiDispatcher(ctx) {
|
|
|
273
274
|
break;
|
|
274
275
|
}
|
|
275
276
|
case 'restart': {
|
|
276
|
-
const result = scheduleRestart(servingPort(req));
|
|
277
|
+
const result = d.scheduleRestart(servingPort(req));
|
|
277
278
|
payload = { ...result };
|
|
278
279
|
break;
|
|
279
280
|
}
|
package/lib/core/restart.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* 自重启:优先使用 DSH launcher 提供的 appExit 生命周期钩子,把重启交给
|
|
3
|
+
* systemd 等服务管理器的 Restart 策略;没有受管服务时再退回 detached helper。
|
|
4
|
+
* 只有无法使用 appExit 且能从 cgroup 识别服务时,才通过 manager-owned transient
|
|
5
|
+
* systemd-run 任务调用 systemctl 作为兼容兜底,避免 helper 留在待停止 unit cgroup。
|
|
5
6
|
* 安全:restart 端点必须通过 trustedRestartRequest(Origin 与 Host 同源)。
|
|
6
7
|
*/
|
|
7
|
-
import { spawn } from 'node:child_process';
|
|
8
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
8
9
|
import { readFileSync } from 'node:fs';
|
|
9
10
|
import { tmpdir } from 'node:os';
|
|
10
11
|
import { join } from 'node:path';
|
|
@@ -96,17 +97,32 @@ export function systemdUnitName(cgroupText) {
|
|
|
96
97
|
}
|
|
97
98
|
return null;
|
|
98
99
|
}
|
|
100
|
+
function shellQuote(part) {
|
|
101
|
+
return `'${part.replace(/'/g, "'\\''")}'`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Build a transient systemd-run command for callers that do not have appExit.
|
|
105
|
+
* The transient service is owned by the manager, not by the DSH unit that it
|
|
106
|
+
* will restart, so KillMode=control-group cannot kill the restart command.
|
|
107
|
+
*/
|
|
99
108
|
export function systemdRestartArgv(opts) {
|
|
100
109
|
const unit = systemdUnitName(opts.cgroup);
|
|
101
110
|
if (unit === null)
|
|
102
111
|
return null;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
const userManager = opts.cgroup.includes('/user.slice/');
|
|
113
|
+
const targetArgs = userManager
|
|
114
|
+
? ['systemctl', '--user', 'restart', '--no-block', unit]
|
|
115
|
+
: ['systemctl', 'restart', '--no-block', unit];
|
|
116
|
+
const script = `sleep 0.2; exec ${targetArgs.map(shellQuote).join(' ')}`;
|
|
117
|
+
const transientArgs = [
|
|
118
|
+
'--unit', `dshm-restart-${opts.pid ?? process.pid}-${Date.now()}.service`,
|
|
119
|
+
'--collect', '--service-type=exec', '/bin/sh', '-c', script,
|
|
120
|
+
];
|
|
121
|
+
if (userManager)
|
|
122
|
+
return { file: 'systemd-run', args: ['--user', ...transientArgs] };
|
|
123
|
+
if (opts.uid === 0)
|
|
124
|
+
return { file: 'systemd-run', args: transientArgs };
|
|
125
|
+
return { file: 'sudo', args: ['-n', 'systemd-run', ...transientArgs] };
|
|
110
126
|
}
|
|
111
127
|
export function restartLaunch() {
|
|
112
128
|
const launch = dshArgv();
|
|
@@ -129,6 +145,79 @@ function respawnInvocation(launch, platform = process.platform) {
|
|
|
129
145
|
detached: false,
|
|
130
146
|
};
|
|
131
147
|
}
|
|
148
|
+
/** DSH's launcher treats a non-zero appExit as a service-manager restart. */
|
|
149
|
+
export const MANAGED_RESTART_EXIT_CODE = 75;
|
|
150
|
+
/**
|
|
151
|
+
* Detect a process started by a service manager without depending on the unit
|
|
152
|
+
* name or on a particular DSH release's cgroup layout.
|
|
153
|
+
*/
|
|
154
|
+
export function serviceManagedEnv(env = process.env) {
|
|
155
|
+
const present = (value) => typeof value === 'string' && value.trim() !== '';
|
|
156
|
+
return typeof env === 'object' && env !== null
|
|
157
|
+
&& (present(env.INVOCATION_ID) || present(env.NOTIFY_SOCKET));
|
|
158
|
+
}
|
|
159
|
+
function statusContainsExitCode(status, code) {
|
|
160
|
+
return String(status ?? '').split(/\s+/u).some((token) => token === String(code) || token === `STATUS=${code}`);
|
|
161
|
+
}
|
|
162
|
+
export function restartPolicyAllowsExit(policy, code = MANAGED_RESTART_EXIT_CODE) {
|
|
163
|
+
if (policy === null || policy === undefined)
|
|
164
|
+
return false;
|
|
165
|
+
if (policy.restart !== 'on-failure' && policy.restart !== 'always')
|
|
166
|
+
return false;
|
|
167
|
+
if (statusContainsExitCode(policy.successExitStatus, code))
|
|
168
|
+
return false;
|
|
169
|
+
if (statusContainsExitCode(policy.restartPreventExitStatus, code))
|
|
170
|
+
return false;
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
/** Query policy before voluntarily exiting; failure fails over to transient restart. */
|
|
174
|
+
export function readSystemdRestartPolicy(opts) {
|
|
175
|
+
const unit = systemdUnitName(opts.cgroup);
|
|
176
|
+
if (unit === null)
|
|
177
|
+
return null;
|
|
178
|
+
const userManager = opts.cgroup.includes('/user.slice/');
|
|
179
|
+
const file = userManager || opts.uid === 0 ? 'systemctl' : 'sudo';
|
|
180
|
+
const args = userManager
|
|
181
|
+
? ['--user', 'show', '--value', '--property=Restart', '--property=SuccessExitStatus', '--property=RestartPreventExitStatus', unit]
|
|
182
|
+
: opts.uid === 0
|
|
183
|
+
? ['show', '--value', '--property=Restart', '--property=SuccessExitStatus', '--property=RestartPreventExitStatus', unit]
|
|
184
|
+
: ['-n', 'systemctl', 'show', '--value', '--property=Restart', '--property=SuccessExitStatus', '--property=RestartPreventExitStatus', unit];
|
|
185
|
+
try {
|
|
186
|
+
const output = execFileSync(file, args, {
|
|
187
|
+
encoding: 'utf8',
|
|
188
|
+
timeout: 1_000,
|
|
189
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
190
|
+
env: process.env,
|
|
191
|
+
});
|
|
192
|
+
const [restart = '', successExitStatus = '', restartPreventExitStatus = ''] = String(output).split(/\r?\n/u);
|
|
193
|
+
return { restart: restart.trim(), successExitStatus: successExitStatus.trim(), restartPreventExitStatus: restartPreventExitStatus.trim() };
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function reportRestartError(scope, error) {
|
|
200
|
+
try {
|
|
201
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
202
|
+
process.stderr.write(`[dsh-m] ${scope} failed: ${message}\n`);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* stderr may already be closed while the host is shutting down */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Read the optional launcher hook without coupling core code to Cordis types. */
|
|
209
|
+
export function appExitFromContext(context) {
|
|
210
|
+
const getter = context?.get;
|
|
211
|
+
if (typeof getter !== 'function')
|
|
212
|
+
return undefined;
|
|
213
|
+
try {
|
|
214
|
+
const candidate = getter.call(context, 'appExit');
|
|
215
|
+
return typeof candidate === 'function' ? candidate : undefined;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
132
221
|
function restartHelperSource(spawned, launch, logs, port) {
|
|
133
222
|
return [
|
|
134
223
|
"const { spawn } = require('node:child_process')",
|
|
@@ -181,19 +270,35 @@ function restartHelperSource(spawned, launch, logs, port) {
|
|
|
181
270
|
}
|
|
182
271
|
export function scheduleRestart(port = null, deps = {}) {
|
|
183
272
|
const pid = deps.pid ?? process.pid;
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
273
|
+
const cgroup = deps.cgroup ?? readProcCgroup();
|
|
274
|
+
const uid = deps.uid ?? (typeof process.getuid === 'function' ? process.getuid() : 1);
|
|
275
|
+
const systemd = systemdRestartArgv({ cgroup, uid, pid });
|
|
276
|
+
const managed = typeof deps.appExit === 'function' && serviceManagedEnv(deps.env ?? process.env);
|
|
277
|
+
const policy = deps.restartPolicy ?? (managed && systemd !== null ? readSystemdRestartPolicy({ cgroup, uid }) : null);
|
|
278
|
+
if (managed && (systemd === null || restartPolicyAllowsExit(policy))) {
|
|
279
|
+
const timer = (deps.setTimeout ?? setTimeout)(() => deps.appExit(MANAGED_RESTART_EXIT_CODE), 150);
|
|
280
|
+
timer.unref?.();
|
|
281
|
+
return { pid, helperPid: undefined, via: 'app-exit' };
|
|
282
|
+
}
|
|
188
283
|
if (systemd !== null) {
|
|
189
284
|
;
|
|
190
285
|
(deps.setTimeout ?? setTimeout)(() => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
286
|
+
try {
|
|
287
|
+
const helper = (deps.spawn ?? spawn)(systemd.file, systemd.args, {
|
|
288
|
+
detached: true,
|
|
289
|
+
stdio: 'ignore',
|
|
290
|
+
env: process.env,
|
|
291
|
+
});
|
|
292
|
+
helper.once?.('error', (error) => reportRestartError('transient restart helper', error));
|
|
293
|
+
helper.once?.('exit', (code, signal) => {
|
|
294
|
+
if (code !== 0)
|
|
295
|
+
reportRestartError('transient restart helper', `exit=${code ?? 'null'} signal=${signal ?? 'none'}`);
|
|
296
|
+
});
|
|
297
|
+
helper.unref();
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
reportRestartError('transient restart helper', error);
|
|
301
|
+
}
|
|
197
302
|
}, 500);
|
|
198
303
|
return { pid, helperPid: undefined, via: 'systemd' };
|
|
199
304
|
}
|
|
@@ -202,12 +307,29 @@ export function scheduleRestart(port = null, deps = {}) {
|
|
|
202
307
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
203
308
|
const logOut = join(tmpdir(), `dshm-restart-${stamp}.out.log`);
|
|
204
309
|
const logErr = join(tmpdir(), `dshm-restart-${stamp}.err.log`);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
310
|
+
let helper;
|
|
311
|
+
try {
|
|
312
|
+
helper = (deps.spawn ?? spawn)((deps.nodeExecutable ?? nodeExecutable)(), ['-e', restartHelperSource(spawned, launch, { out: logOut, err: logErr }, port)], {
|
|
313
|
+
detached: true,
|
|
314
|
+
stdio: 'ignore',
|
|
315
|
+
env: process.env,
|
|
316
|
+
});
|
|
317
|
+
helper.once?.('error', (error) => reportRestartError('detached restart helper', error));
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
// Do not terminate the live host if the replacement could not be started.
|
|
321
|
+
reportRestartError('detached restart helper', error);
|
|
322
|
+
return { pid, helperPid: undefined, via: 'helper' };
|
|
323
|
+
}
|
|
210
324
|
helper.unref();
|
|
211
|
-
(deps.setTimeout ?? setTimeout)(() =>
|
|
325
|
+
(deps.setTimeout ?? setTimeout)(() => {
|
|
326
|
+
try {
|
|
327
|
+
;
|
|
328
|
+
(deps.kill ?? process.kill)(pid, 'SIGTERM');
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
reportRestartError('detached host shutdown', error);
|
|
332
|
+
}
|
|
333
|
+
}, 500);
|
|
212
334
|
return { pid, helperPid: helper.pid, via: 'helper' };
|
|
213
335
|
}
|
package/lib/host.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createApiDispatcher } from './core/host-api.js';
|
|
|
4
4
|
import { bindLoaderHost } from './core/live-plugin.js';
|
|
5
5
|
import { createRegistryController } from './core/registry-controller.js';
|
|
6
6
|
import { registerTools } from './tools.js';
|
|
7
|
+
import { appExitFromContext, scheduleRestart } from './core/restart.js';
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const pkg = require('../package.json');
|
|
9
10
|
export const name = 'dshm';
|
|
@@ -20,7 +21,11 @@ export function apply(ctx, config) {
|
|
|
20
21
|
// registry controller:active config / configured / pending / rejected 分离 + generation fence;
|
|
21
22
|
// tools 与 Host API 共用同一 active config object(apply 原地更新字段,live 生效)
|
|
22
23
|
const controller = createRegistryController(config);
|
|
23
|
-
|
|
24
|
+
// DSH 0.1.2-rc.1 and 0.1.5-rc.1 both expose appExit through dsh-cmdline;
|
|
25
|
+
// using it avoids guessing the service unit from release-specific cgroups.
|
|
26
|
+
const appExit = appExitFromContext(ctx);
|
|
27
|
+
const restart = (port = null) => scheduleRestart(port, { appExit });
|
|
28
|
+
registerTools(ctx, controller.config, { restart });
|
|
24
29
|
// 设置页(GUI 设置卡片的宿主命名空间):applies 'live',scope 提供 get/update/watch
|
|
25
30
|
ctx.inject(['settings'], (c) => {
|
|
26
31
|
const settings = c.settings;
|
|
@@ -35,7 +40,7 @@ export function apply(ctx, config) {
|
|
|
35
40
|
// 本地 API:单路由 + method 分发(防护与状态映射在 core/host-api.ts)
|
|
36
41
|
ctx.inject(['webServer'], (c) => {
|
|
37
42
|
const server = c.webServer;
|
|
38
|
-
const handleApi = createApiDispatcher({ controller, pkg });
|
|
43
|
+
const handleApi = createApiDispatcher({ controller, pkg, deps: { scheduleRestart: restart } });
|
|
39
44
|
server.register({
|
|
40
45
|
kind: 'exact',
|
|
41
46
|
path: '/dshm',
|
package/lib/tools.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import { installTimeoutMs } from './core/env.js';
|
|
3
3
|
import { installFromRegistry, listInstalledWithMeta, listMarket, uninstallPlugin, upgradePlugin, } from './core/market.js';
|
|
4
|
-
import { scheduleRestart } from './core/restart.js';
|
|
4
|
+
import { appExitFromContext, scheduleRestart } from './core/restart.js';
|
|
5
5
|
export const CATEGORY_LABELS = {
|
|
6
6
|
market: '市场',
|
|
7
7
|
tools: '工具',
|
|
@@ -24,6 +24,7 @@ export function registerTools(ctx, cfg, deps = {}) {
|
|
|
24
24
|
uninstallPlugin: deps.uninstallPlugin ?? uninstallPlugin,
|
|
25
25
|
upgradePlugin: deps.upgradePlugin ?? upgradePlugin,
|
|
26
26
|
};
|
|
27
|
+
const restart = deps.restart ?? ((port = null) => scheduleRestart(port, { appExit: appExitFromContext(ctx) }));
|
|
27
28
|
ctx.tools.register(defineTool({
|
|
28
29
|
name: 'dshm_search',
|
|
29
30
|
description: 'Search your personal DSH plugin marketplace (dsh-m) and show clickable plugin cards. ALWAYS call this instead of web_search or bash when the user wants to find/recommend/browse their curated DSH plugins (插件). Call EXACTLY ONCE per user message; extract a real keyword (主题, 搜索) rather than pasting the whole sentence. Omit query to browse all listings. After cards appear, reply with AT MOST one short sentence. Do not print install commands.',
|
|
@@ -240,13 +241,13 @@ export function registerTools(ctx, cfg, deps = {}) {
|
|
|
240
241
|
}));
|
|
241
242
|
ctx.tools.register(defineTool({
|
|
242
243
|
name: 'dshm_restart',
|
|
243
|
-
description: 'Restart DSH web so newly installed/uninstalled/upgraded plugins take effect. ONLY call after the user agrees (用户同意重启后).
|
|
244
|
+
description: 'Restart DSH web so newly installed/uninstalled/upgraded plugins take effect. ONLY call after the user agrees (用户同意重启后). DSH Web reconnects in the background after the service comes back.',
|
|
244
245
|
parameters: {},
|
|
245
246
|
output: {
|
|
246
247
|
schema: { type: 'object', additionalProperties: true },
|
|
247
248
|
render: (_args, value) => [{
|
|
248
249
|
type: 'text',
|
|
249
|
-
text: `已请求重启 DSH web(via ${value.via}
|
|
250
|
+
text: `已请求重启 DSH web(via ${value.via})。服务恢复后 DSH Web 会在后台自动重连。对用户最多一句短话。`,
|
|
250
251
|
}],
|
|
251
252
|
presentationMeta: (_args, value) => ({ kind: 'dshm-restart', ...value }),
|
|
252
253
|
},
|
|
@@ -258,7 +259,7 @@ export function registerTools(ctx, cfg, deps = {}) {
|
|
|
258
259
|
}),
|
|
259
260
|
timeoutMs: 15_000,
|
|
260
261
|
async execute() {
|
|
261
|
-
return cloneJson(
|
|
262
|
+
return cloneJson(restart(null));
|
|
262
263
|
},
|
|
263
264
|
}));
|
|
264
265
|
ctx.inject(['systemPrompt'], (c) => {
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-m",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"description": "DSH Marketplace — 个人自用的 DeepSeek Harness 插件市场:收录、安装、卸载、升级 DSH 插件",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/host.js",
|
|
8
8
|
"bin": {
|
|
9
|
-
"dshm": "
|
|
9
|
+
"dshm": "lib/cli.js"
|
|
10
10
|
},
|
|
11
11
|
"exports": {
|
|
12
12
|
".": "./lib/host.js",
|