dsh-plugin-admin 0.1.0 → 0.2.1
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.md +3 -1
- package/lib/client.js +173 -26
- package/lib/index.js +157 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,9 @@ dsh web UI 插件:在浏览器设置弹窗(侧边栏底部“设置”)中
|
|
|
5
5
|
- **🔌 插件管理**:
|
|
6
6
|
- 顶部输入框支持 npm 包名(如 `dsh-xxx`)或本地绝对路径安装插件(回车或点击提交);
|
|
7
7
|
- 列表展示当前 profile 下的全部 bundle 层(名称、版本,以及**内置 / 包安装 / 本地安装**标记;本地安装的插件额外显示其源路径,依据 profile 依赖清单中的 `link:` / `file:` / 绝对路径 spec 判定);
|
|
8
|
+
- **名称模糊搜索**:列表上方搜索框支持按插件名 / 版本号 / 本地路径模糊过滤(大小写不敏感子串匹配),可叠加「全部 / 扩展插件 / 系统内置」筛选胶囊,无结果时给出专属空状态提示;
|
|
8
9
|
- 支持扩展插件一键卸载(带有优雅的行内二次确认);
|
|
10
|
+
- **远程更新检测(自动)**:打开「插件管理」Tab 即自动按 profile 实际使用的 npm registry(`npm_config_registry` → 项目/用户 `.npmrc` → 官方源)查询每个 **registry 安装**的插件(跳过内置与本地路径安装)的 `latest` 版本,与本地版本比对——有新版时卡片显示琥珀色「⬆ 有新版本 vX.Y.Z」徽标并出现一键「⬆ 更新」按钮(`npm install <name>@latest`);工具栏「⬆ 检查更新」可强制手动刷新;查询带超时与 5 分钟缓存(host 侧缓存,重启 dsh 后首次打开自动重查),网络失败按条目提示而非报错;
|
|
9
11
|
- 安装/卸载在 host 侧编排 profile 目录下的 pnpm 并自动同步 `package.json` 的 `dsh.profile.bundles` 清单(变更在重启 dsh 后生效)。
|
|
10
12
|
- **💬 会话管理**:
|
|
11
13
|
- **标题与内容摘要**:自动解析会话标题(`session/title` 或首条用户提问)并渲染首条消息的文本摘要气泡预览(带折行省略与多行保护);
|
|
@@ -25,7 +27,7 @@ dsh web UI 插件:在浏览器设置弹窗(侧边栏底部“设置”)中
|
|
|
25
27
|
- **Host 端(`lib/index.js`,零 dsh 依赖)**:
|
|
26
28
|
- 注入 `['typert', 'workspaceRegistry', 'sessionPersistence']`;
|
|
27
29
|
- 提供并注册四个 RPC 命名空间:
|
|
28
|
-
- `pluginAdmin`(`list` / `install` / `remove`):异步 `spawn` pnpm(Windows 走 shell 解析 .cmd shim,5 分钟超时且**进程树强杀**(`taskkill /T /F`,避免超时后残留 pnpm/node 子进程继续写盘),Promise 尾链串行化防并发),镜像 CLI `reconcileBundles` 同步清单,清单写回为**原子写**(临时文件 + rename,崩溃不截断 profile 的 package.json);安装/卸载参数经**字符白名单**校验(`assertPnpmOperand`),从根上排除 `&` `|` `>` `<` `%` 引号等 cmd 元字符注入向量;`install` / `remove` 返回真实 pnpm 输出尾部(`output`
|
|
30
|
+
- `pluginAdmin`(`list` / `install` / `remove` / `checkUpdates`):异步 `spawn` pnpm(Windows 走 shell 解析 .cmd shim,5 分钟超时且**进程树强杀**(`taskkill /T /F`,避免超时后残留 pnpm/node 子进程继续写盘),Promise 尾链串行化防并发),镜像 CLI `reconcileBundles` 同步清单,清单写回为**原子写**(临时文件 + rename,崩溃不截断 profile 的 package.json);安装/卸载参数经**字符白名单**校验(`assertPnpmOperand`),从根上排除 `&` `|` `>` `<` `%` 引号等 cmd 元字符注入向量;`install` / `remove` 返回真实 pnpm 输出尾部(`output` 字段),前端作为提示气泡的悬浮诊断信息展示;`checkUpdates` 对 registry 安装的 bundle 并发查询 npm registry 的 `latest`(registry 解析:`npm_config_registry` env → 项目/用户 `.npmrc` → 官方源;有界并发 4 路、8s 超时、5 分钟缓存),返回 `updateAvailable` / `latest` / `error`,本地路径与内置插件跳过;
|
|
29
31
|
- `sessionAdmin` (`list` / `archive` / `unarchive` / `deleteSession` / `closeSession`):直接对接 `workspaceRegistry` 与 `sessionPersistence`,提供安全幂等的持久化日志清理与归档状态流转;`list` 采用**修订号驱动的摘要缓存**(`listSnapshots` 的 revision token,未变化会话不重读事件日志)与**有界并发**(最多 4 路并行 inspect),并设单会话事件扫描上限兜底;`archive` 校验会话真实存在,拒绝向归档集写入垃圾 id;对 registry 软私有写路径(`requireState` / `setState` / `enqueueOperation`)在 `apply()` 挂载时即做**兼容性探测**,dsh 版本变更会明确报出缺失成员,而不是首次归档时才静默失败。`closeSession` 使**在线会话免重启删除**成为可能:`installAgentHandleCapture` 在挂载时透明包装公开的 `ctx.agents.create` / `resume`(原样调用并返回,仅把返回的 `AgentHandle` 按 session id 存入插件私有 Map),删除在线会话时先 `handle.dispose()` 走 dsh 官方 teardown 链(停止 loop → 等待静止 → 注销 agent → 从 SessionStore 移除 → 触发 `session/disposed` → 持久化层 `retire()` flush 缓冲事件并释放写路径),再删日志——日志不会被下次 flush 复活;未被捕获 handle 的在线会话(如插件挂载前已创建)会明确报错并引导重启,绝不误删。
|
|
30
32
|
- `fsAdmin` (`reveal`):跨平台在系统文件管理器中定位一个绝对路径(Windows `explorer /select`、macOS `open -R`、Linux `xdg-open`),供工作区右键菜单「在资源管理器打开」使用;
|
|
31
33
|
- `mcpAdmin` (`list` / `upsert` / `remove` / `test`):管理 profile 的 `cordis.patch.yml` 中的 MCP 客户端实例(`@deepseek-ai/dsh-mcp-client`)。基于行级 YAML 块编辑(零依赖):识别顶层 `- id:` 块并仅改动 `name` 为 MCP 客户端插件的条目,`upsert` 按 id 原位替换或追加,`remove` 整块删除,写回走**原子写**;校验 id / serverName / transport / command / url,拒绝畸形输入;文件变更与插件安装共用同一**串行操作队列**(读-改-写不交错)。`test` 按条目 id 发起**连通性探测**:stdio 子进程(newline JSON-RPC,`initialize` → `initialized` → `tools/list`,超时 `taskkill /T /F` 强杀进程树,捕获 stderr 尾部)或 streamable-http(`fetch` POST `initialize`,兼容 SSE 与纯 JSON,超时 AbortController),返回服务器标识 / 工具数量 / 耗时,或失败原因(命令不存在、连接拒绝、超时等),全程不抛异常。
|
package/lib/client.js
CHANGED
|
@@ -21,7 +21,7 @@ var useEffect = React.useEffect
|
|
|
21
21
|
var CSS_TEXT = [
|
|
22
22
|
'@keyframes dsh-admin-spin { to { transform: rotate(360deg); } }',
|
|
23
23
|
'@keyframes dsh-admin-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.85); } }',
|
|
24
|
-
'[data-dsh-admin-section] { display: flex; flex-direction: column; width: 100%; gap: 14px; padding: 2px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-primary, #0f1115); }',
|
|
24
|
+
'[data-dsh-admin-section] { display: flex; flex-direction: column; width: 100%; gap: 14px; padding: 2px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-primary, #0f1115); max-height: calc(100vh - 140px); overflow: hidden; }',
|
|
25
25
|
'[data-dsh-admin-section] * { box-sizing: border-box; }',
|
|
26
26
|
'[data-dsh-admin-section] .tabs { display: flex; padding: 3px; background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.04)); border-radius: 9px; border: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.08)); gap: 3px; }',
|
|
27
27
|
'[data-dsh-admin-section] .tab { flex: 1; border: 0; background: transparent; border-radius: 7px; padding: 7px 14px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 7px; font-size: 12px; font-weight: 500; color: var(--dsw-alias-label-secondary, #666); transition: all 0.15s ease; font-family: inherit; }',
|
|
@@ -30,7 +30,11 @@ var CSS_TEXT = [
|
|
|
30
30
|
'[data-dsh-admin-section] .tab-count { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, #e5e7eb); color: var(--dsw-alias-label-secondary, #555); }',
|
|
31
31
|
'[data-dsh-admin-section] .tab.active .tab-count { background: var(--dsw-alias-button-primary-fill, #1677ff); color: var(--dsw-alias-label-primary-foreground, #fff); }',
|
|
32
32
|
'[data-dsh-admin-section] .toolbar { display: flex; gap: 8px; align-items: center; padding: 10px; border: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.08)); border-radius: 12px; background: var(--dsw-alias-bg-base, #fff); box-shadow: 0 1px 2px rgba(0,0,0,0.02); }',
|
|
33
|
-
'[data-dsh-admin-section] .
|
|
33
|
+
'[data-dsh-admin-section] .search-wrap { flex: 1.6; min-width: 0; position: relative; display: flex; align-items: center; }',
|
|
34
|
+
'[data-dsh-admin-section] .search-wrap .input { flex: 1; min-width: 0; padding-left: 30px; }',
|
|
35
|
+
'[data-dsh-admin-section] .install-wrap { flex: 1; min-width: 0; display: flex; align-items: center; }',
|
|
36
|
+
'[data-dsh-admin-section] .install-wrap .input { flex: 1; min-width: 0; }',
|
|
37
|
+
'[data-dsh-admin-section] .search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 12px; opacity: 0.7; pointer-events: none; z-index: 1; }',
|
|
34
38
|
'[data-dsh-admin-section] .input { width: 100%; padding: 7px 12px; border-radius: 9px; border: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.12)); background: var(--dsw-alias-bg-base, #fff); color: inherit; font: inherit; outline: none; transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; }',
|
|
35
39
|
'[data-dsh-admin-section] .input:hover { border-color: var(--dsw-alias-label-tertiary, rgba(0,0,0,0.28)); }',
|
|
36
40
|
'[data-dsh-admin-section] .input:focus { border-color: var(--dsw-alias-button-primary-fill, #1677ff); box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.14); }',
|
|
@@ -51,7 +55,7 @@ var CSS_TEXT = [
|
|
|
51
55
|
'[data-dsh-admin-section] .btn.danger-solid:hover:not(:disabled) { opacity: 0.9; }',
|
|
52
56
|
'[data-dsh-admin-section] .btn.sm { padding: 3px 8px; font-size: 11px; border-radius: 6px; }',
|
|
53
57
|
'[data-dsh-admin-section] .spinner { width: 12px; height: 12px; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: dsh-admin-spin 0.7s linear infinite; display: inline-block; flex: none; }',
|
|
54
|
-
'[data-dsh-admin-section] .list { display: flex; flex-direction: column; gap: 10px; max-height: 560px; overflow-y: auto; padding: 2px 4px 2px 2px; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-subtle, rgba(0,0,0,0.2)) transparent; }',
|
|
58
|
+
'[data-dsh-admin-section] .list { display: flex; flex-direction: column; gap: 10px; flex: 1 1 auto; min-height: 0; max-height: 560px; overflow-y: auto; padding: 2px 4px 2px 2px; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-subtle, rgba(0,0,0,0.2)) transparent; }',
|
|
55
59
|
'[data-dsh-admin-section] .list::-webkit-scrollbar { width: 6px; }',
|
|
56
60
|
'[data-dsh-admin-section] .list::-webkit-scrollbar-thumb { border-radius: 99px; background: var(--dsw-alias-border-subtle, rgba(0,0,0,0.2)); }',
|
|
57
61
|
'[data-dsh-admin-section] .session-group { display: flex; flex-direction: column; gap: 6px; }',
|
|
@@ -74,6 +78,8 @@ var CSS_TEXT = [
|
|
|
74
78
|
'[data-dsh-admin-section] .tag { font-size: 10px; font-weight: 500; padding: 1px 6px; border-radius: 4px; flex: none; display: inline-flex; align-items: center; gap: 4px; background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.04)); color: var(--dsw-alias-label-secondary, #666); }',
|
|
75
79
|
'[data-dsh-admin-section] .tag.plugin { background: rgba(16, 185, 129, 0.12); color: var(--dsw-alias-state-success-primary, #10b981); }',
|
|
76
80
|
'[data-dsh-admin-section] .tag.local { background: rgba(59, 130, 246, 0.1); color: var(--dsw-alias-button-primary-fill, #1677ff); }',
|
|
81
|
+
'[data-dsh-admin-section] .tag.update { background: rgba(245, 158, 11, 0.14); color: #d97706; font-weight: 600; border: 1px solid rgba(217, 119, 6, 0.25); }',
|
|
82
|
+
'[data-dsh-admin-section] .tag.update-error { background: rgba(239, 68, 68, 0.1); color: var(--dsw-alias-state-error-primary, #ef4444); }',
|
|
77
83
|
'[data-dsh-admin-section] .plugin-path { font-family: monospace; font-size: 11px; line-height: 1.4; color: var(--dsw-alias-label-secondary, #666); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 4px 9px; border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.025)); }',
|
|
78
84
|
'[data-dsh-admin-section] .tag.version { font-family: monospace; }',
|
|
79
85
|
'[data-dsh-admin-section] .tag.live { background: rgba(16, 185, 129, 0.12); color: var(--dsw-alias-state-success-primary, #10b981); font-weight: 600; }',
|
|
@@ -88,7 +94,7 @@ var CSS_TEXT = [
|
|
|
88
94
|
'[data-dsh-admin-section] .busy-banner { display: flex; align-items: center; gap: 8px; padding: 7px 11px; border-radius: 7px; background: rgba(22, 119, 255, 0.08); color: var(--dsw-alias-button-primary-fill, #1677ff); font-size: 11px; }',
|
|
89
95
|
'[data-dsh-admin-section] .empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 28px 12px; color: var(--dsw-alias-label-tertiary, #999); gap: 4px; font-size: 12px; text-align: center; }',
|
|
90
96
|
'[data-dsh-admin-section] .error { padding: 7px 11px; border-radius: 7px; color: var(--dsw-alias-state-error-primary, #ef4444); background: var(--dsw-alias-interactive-bg-hover-danger, rgba(239,68,68,0.08)); font-size: 12px; white-space: pre-wrap; max-height: 100px; overflow-y: auto; }',
|
|
91
|
-
'[data-dsh-admin-section] .footer { display: flex; justify-content: space-between; align-items: center; padding-top: 4px; font-size: 11px; color: var(--dsw-alias-label-tertiary, #888); border-top: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.06)); }',
|
|
97
|
+
'[data-dsh-admin-section] .footer { display: flex; justify-content: space-between; align-items: center; padding-top: 4px; font-size: 11px; color: var(--dsw-alias-label-tertiary, #888); border-top: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.06)); flex: none; }',
|
|
92
98
|
'[data-dsh-admin-section] .footer .path { max-width: 60%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
|
|
93
99
|
'[data-dsh-admin-section] .footer .hint { font-style: normal; }',
|
|
94
100
|
'[data-dsh-admin-section] .session-id-badge { font-family: monospace; font-size: 10px; opacity: 0.75; }',
|
|
@@ -485,6 +491,9 @@ function PluginsSection(props) {
|
|
|
485
491
|
note: '',
|
|
486
492
|
output: '',
|
|
487
493
|
filter: 'all',
|
|
494
|
+
needle: '',
|
|
495
|
+
checkingUpdates: false,
|
|
496
|
+
updates: {}, // name -> { latest, updateAvailable, error? }
|
|
488
497
|
})
|
|
489
498
|
var pView = pairPlugins[0]
|
|
490
499
|
var setPView = pairPlugins[1]
|
|
@@ -523,6 +532,69 @@ function PluginsSection(props) {
|
|
|
523
532
|
})
|
|
524
533
|
}
|
|
525
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Query the npm registry for newer versions of registry-installed bundles
|
|
537
|
+
* and stash per-plugin results. Runs concurrently with the list so the
|
|
538
|
+
* panel stays responsive; failures are per-plugin, never fatal.
|
|
539
|
+
*/
|
|
540
|
+
function checkUpdates() {
|
|
541
|
+
if (pView.checkingUpdates) return
|
|
542
|
+
patchPlugin({ checkingUpdates: true, note: '' })
|
|
543
|
+
callRemote('pluginAdmin/checkUpdates', {}).then(function (result) {
|
|
544
|
+
if (!alive.current) return
|
|
545
|
+
patchPlugin({ checkingUpdates: false })
|
|
546
|
+
if (!result.ok) {
|
|
547
|
+
patchPlugin({ error: '检查更新失败:' + messageOf(result.error) })
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
var list = (result.value && result.value.updates) || []
|
|
551
|
+
var map = {}
|
|
552
|
+
var updateCount = 0
|
|
553
|
+
for (var i = 0; i < list.length; i++) {
|
|
554
|
+
var u = list[i]
|
|
555
|
+
map[u.name] = { latest: u.latest, updateAvailable: u.updateAvailable === true, error: u.error || '' }
|
|
556
|
+
if (u.updateAvailable === true) updateCount++
|
|
557
|
+
}
|
|
558
|
+
var updated = map
|
|
559
|
+
patchPlugin({
|
|
560
|
+
updates: updated,
|
|
561
|
+
note: updateCount > 0
|
|
562
|
+
? '发现 ' + updateCount + ' 个插件有新版本'
|
|
563
|
+
: (list.length > 0 ? '已检查 ' + list.length + ' 个插件,均为最新版本' : ''),
|
|
564
|
+
})
|
|
565
|
+
}, function (failure) {
|
|
566
|
+
if (!alive.current) return
|
|
567
|
+
patchPlugin({ checkingUpdates: false, error: '检查更新调用失败:' + messageOf(failure) })
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Upgrade one plugin to its latest version (registry install by name). */
|
|
572
|
+
function upgradePlugin(name) {
|
|
573
|
+
if (pView.busy) return
|
|
574
|
+
var spec = name + '@latest'
|
|
575
|
+
patchPlugin({ busy: true, error: '', confirming: null, note: '' })
|
|
576
|
+
callRemote('pluginAdmin/install', { spec: spec }).then(function (result) {
|
|
577
|
+
if (!alive.current) return
|
|
578
|
+
if (result.ok) {
|
|
579
|
+
patchPlugin({
|
|
580
|
+
busy: false,
|
|
581
|
+
note: '已更新 ' + name + '。更改在重启 dsh 后生效',
|
|
582
|
+
output: (result.value && result.value.output) || '',
|
|
583
|
+
profileDir: (result.value && result.value.profileDir) || '',
|
|
584
|
+
plugins: (result.value && result.value.plugins) || [],
|
|
585
|
+
})
|
|
586
|
+
// Refresh the update map so the upgraded entry stops flagging.
|
|
587
|
+
checkUpdates()
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
patchPlugin({ busy: false, error: '更新失败:' + messageOf(result.error) })
|
|
591
|
+
reloadPlugins()
|
|
592
|
+
}, function (failure) {
|
|
593
|
+
if (!alive.current) return
|
|
594
|
+
patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
|
|
526
598
|
function installPlugin() {
|
|
527
599
|
if (pView.busy || pView.spec.trim() === '') return
|
|
528
600
|
var spec = pView.spec.trim()
|
|
@@ -573,11 +645,17 @@ function PluginsSection(props) {
|
|
|
573
645
|
useEffect(function () {
|
|
574
646
|
alive.current = true
|
|
575
647
|
reloadPlugins()
|
|
648
|
+
// Auto-check for remote updates on mount (the panel loads lazily when
|
|
649
|
+
// the 插件管理 tab is opened, so this runs once per open). The host
|
|
650
|
+
// caches registry answers for 5 minutes, so re-opening the tab shortly
|
|
651
|
+
// after does not re-hit the registry. The manual ⬆ 检查更新 button stays
|
|
652
|
+
// for forcing a fresh check.
|
|
653
|
+
checkUpdates()
|
|
576
654
|
return function () { alive.current = false }
|
|
577
655
|
}, [])
|
|
578
656
|
|
|
579
657
|
return createElement('div', { 'data-dsh-admin-section': '' },
|
|
580
|
-
renderPluginsView(pView, patchPlugin,
|
|
658
|
+
renderPluginsView(pView, patchPlugin, installPlugin, removePlugin, checkUpdates, upgradePlugin))
|
|
581
659
|
}
|
|
582
660
|
|
|
583
661
|
/**
|
|
@@ -921,15 +999,32 @@ function AdminCenterSection(props) {
|
|
|
921
999
|
/* Render Plugins View */
|
|
922
1000
|
/* ========================================================================== */
|
|
923
1001
|
|
|
924
|
-
function renderPluginsView(view, patch,
|
|
1002
|
+
function renderPluginsView(view, patch, install, remove, checkUpdates, upgrade) {
|
|
925
1003
|
var elements = []
|
|
926
1004
|
|
|
927
|
-
// Toolbar
|
|
1005
|
+
// Toolbar: search (primary, wide) + install input + actions in one row.
|
|
928
1006
|
elements.push(createElement('div', { className: 'toolbar', key: 'toolbar' },
|
|
929
|
-
createElement('div', { className: '
|
|
1007
|
+
createElement('div', { className: 'search-wrap', key: 'search-wrap' },
|
|
1008
|
+
createElement('span', { className: 'search-icon', key: 'icon' }, '🔍'),
|
|
1009
|
+
createElement('input', {
|
|
1010
|
+
className: 'input',
|
|
1011
|
+
key: 'search-input',
|
|
1012
|
+
placeholder: '搜索插件(名称/版本/路径)...',
|
|
1013
|
+
value: view.needle,
|
|
1014
|
+
onChange: function (e) { patch({ needle: e.target.value }) },
|
|
1015
|
+
}),
|
|
1016
|
+
view.needle !== '' ? createElement('button', {
|
|
1017
|
+
type: 'button',
|
|
1018
|
+
key: 'btn-clear-search',
|
|
1019
|
+
className: 'btn sm',
|
|
1020
|
+
title: '清空搜索',
|
|
1021
|
+
onClick: function () { patch({ needle: '' }) },
|
|
1022
|
+
}, '✕') : null
|
|
1023
|
+
),
|
|
1024
|
+
createElement('div', { className: 'input-wrap install-wrap', key: 'install-wrap' },
|
|
930
1025
|
createElement('input', {
|
|
931
1026
|
className: 'input',
|
|
932
|
-
placeholder: '
|
|
1027
|
+
placeholder: '安装包名/路径...',
|
|
933
1028
|
value: view.spec,
|
|
934
1029
|
disabled: view.busy,
|
|
935
1030
|
onChange: function (e) { patch({ spec: e.target.value }) },
|
|
@@ -948,11 +1043,14 @@ function renderPluginsView(view, patch, reload, install, remove) {
|
|
|
948
1043
|
),
|
|
949
1044
|
createElement('button', {
|
|
950
1045
|
type: 'button',
|
|
951
|
-
key: 'btn-
|
|
1046
|
+
key: 'btn-check-updates',
|
|
952
1047
|
className: 'btn',
|
|
953
|
-
disabled: view.busy,
|
|
954
|
-
onClick:
|
|
955
|
-
},
|
|
1048
|
+
disabled: view.busy || view.checkingUpdates,
|
|
1049
|
+
onClick: checkUpdates,
|
|
1050
|
+
},
|
|
1051
|
+
view.checkingUpdates ? createElement('span', { className: 'spinner', key: 'spin' }) : null,
|
|
1052
|
+
view.checkingUpdates ? '检查中...' : '⬆ 检查更新'
|
|
1053
|
+
),
|
|
956
1054
|
))
|
|
957
1055
|
|
|
958
1056
|
// Filter Pills
|
|
@@ -997,17 +1095,17 @@ function renderPluginsView(view, patch, reload, install, remove) {
|
|
|
997
1095
|
}
|
|
998
1096
|
|
|
999
1097
|
// Filter and render rows
|
|
1098
|
+
var needle = view.needle.trim().toLowerCase()
|
|
1099
|
+
var filtered = filterPlugins(view.plugins, view.filter, needle)
|
|
1000
1100
|
var rows = []
|
|
1001
|
-
for (var j = 0; j <
|
|
1002
|
-
var p =
|
|
1003
|
-
|
|
1004
|
-
if (view.filter === 'builtin' && p.removable) continue
|
|
1005
|
-
rows.push(renderPluginCard(p, view, remove, patch))
|
|
1101
|
+
for (var j = 0; j < filtered.length; j++) {
|
|
1102
|
+
var p = filtered[j]
|
|
1103
|
+
rows.push(renderPluginCard(p, view, remove, patch, upgrade))
|
|
1006
1104
|
}
|
|
1007
1105
|
|
|
1008
1106
|
if (rows.length === 0) {
|
|
1009
1107
|
rows.push(createElement('div', { className: 'empty', key: 'empty' },
|
|
1010
|
-
createElement('div', null, '📦 暂无匹配的插件层')
|
|
1108
|
+
createElement('div', null, needle !== '' ? '🔍 无匹配的插件(试试其他关键词)' : '📦 暂无匹配的插件层')
|
|
1011
1109
|
))
|
|
1012
1110
|
}
|
|
1013
1111
|
|
|
@@ -1327,7 +1425,7 @@ function mergeTestState(map, id, partial) {
|
|
|
1327
1425
|
for (var pk in partial) next[id][pk] = partial[pk]
|
|
1328
1426
|
return next
|
|
1329
1427
|
}
|
|
1330
|
-
function renderPluginCard(plugin, view, remove, patch) {
|
|
1428
|
+
function renderPluginCard(plugin, view, remove, patch, upgrade) {
|
|
1331
1429
|
var isConfirming = view.confirming === plugin.name
|
|
1332
1430
|
|
|
1333
1431
|
var titleChildren = [
|
|
@@ -1344,19 +1442,49 @@ function renderPluginCard(plugin, view, remove, patch) {
|
|
|
1344
1442
|
plugin.removable ? (isLocal ? '本地安装' : '包安装') : '内置'
|
|
1345
1443
|
))
|
|
1346
1444
|
|
|
1445
|
+
// Remote update badge: only for registry-installed bundles (removable and
|
|
1446
|
+
// not a local path) after a check ran. `updates` is keyed by plugin name.
|
|
1447
|
+
var updateInfo = view.updates && view.updates[plugin.name]
|
|
1448
|
+
if (updateInfo && updateInfo.updateAvailable && updateInfo.latest) {
|
|
1449
|
+
titleChildren.push(createElement('span', {
|
|
1450
|
+
className: 'tag update',
|
|
1451
|
+
key: 'update-badge',
|
|
1452
|
+
title: '远程 registry 有新版本:v' + updateInfo.latest + '(当前 v' + plugin.version + ')',
|
|
1453
|
+
}, '⬆ 有新版本 v' + updateInfo.latest))
|
|
1454
|
+
} else if (updateInfo && updateInfo.error) {
|
|
1455
|
+
titleChildren.push(createElement('span', {
|
|
1456
|
+
className: 'tag update-error',
|
|
1457
|
+
key: 'update-error',
|
|
1458
|
+
title: updateInfo.error,
|
|
1459
|
+
}, '⚠ 更新检查失败'))
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1347
1462
|
var headerChildren = [
|
|
1348
1463
|
createElement('div', { className: 'card-title', key: 'title' }, titleChildren),
|
|
1349
1464
|
]
|
|
1350
1465
|
|
|
1351
1466
|
if (plugin.removable && !isConfirming) {
|
|
1352
|
-
|
|
1353
|
-
|
|
1467
|
+
var actionChildren = []
|
|
1468
|
+
// 更新 button — only when an update is available and the plugin is a
|
|
1469
|
+
// registry install (not local path).
|
|
1470
|
+
if (updateInfo && updateInfo.updateAvailable && updateInfo.latest && !isLocal) {
|
|
1471
|
+
actionChildren.push(createElement('button', {
|
|
1354
1472
|
type: 'button',
|
|
1355
|
-
|
|
1473
|
+
key: 'btn-upgrade',
|
|
1474
|
+
className: 'btn sm',
|
|
1356
1475
|
disabled: view.busy,
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1476
|
+
title: '升级到 v' + updateInfo.latest + '(npm install ' + plugin.name + '@latest)',
|
|
1477
|
+
onClick: function () { upgrade(plugin.name) },
|
|
1478
|
+
}, '⬆ 更新'))
|
|
1479
|
+
}
|
|
1480
|
+
actionChildren.push(createElement('button', {
|
|
1481
|
+
type: 'button',
|
|
1482
|
+
key: 'btn-remove',
|
|
1483
|
+
className: 'btn danger sm',
|
|
1484
|
+
disabled: view.busy,
|
|
1485
|
+
onClick: function () { patch({ confirming: plugin.name }) },
|
|
1486
|
+
}, '卸载'))
|
|
1487
|
+
headerChildren.push(createElement('div', { className: 'card-actions', key: 'actions' }, actionChildren))
|
|
1360
1488
|
}
|
|
1361
1489
|
|
|
1362
1490
|
var header = createElement('div', { className: 'card-header', key: 'header' }, headerChildren)
|
|
@@ -1435,6 +1563,25 @@ function filterSessions(sessions, filter, needle) {
|
|
|
1435
1563
|
}
|
|
1436
1564
|
|
|
1437
1565
|
/**
|
|
1566
|
+
* Filter plugin layers by the type pill and a fuzzy name/version/path query.
|
|
1567
|
+
* The needle matches case-insensitively against the package name, the
|
|
1568
|
+
* installed version, and the local source path, so "tool" finds
|
|
1569
|
+
* dsh-custom-tool and "0.2" finds version rows.
|
|
1570
|
+
*/
|
|
1571
|
+
function filterPlugins(plugins, filter, needle) {
|
|
1572
|
+
var result = []
|
|
1573
|
+
for (var i = 0; i < plugins.length; i++) {
|
|
1574
|
+
var p = plugins[i]
|
|
1575
|
+
if (filter === 'plugin' && !p.removable) continue
|
|
1576
|
+
if (filter === 'builtin' && p.removable) continue
|
|
1577
|
+
if (needle !== '') {
|
|
1578
|
+
var hay = ((p.name || '') + ' ' + (p.version || '') + ' ' + (p.localPath || '')).toLowerCase()
|
|
1579
|
+
if (hay.indexOf(needle) === -1) continue
|
|
1580
|
+
}
|
|
1581
|
+
result.push(p)
|
|
1582
|
+
}
|
|
1583
|
+
return result
|
|
1584
|
+
}/**
|
|
1438
1585
|
* Group sessions by their accounting workspace in registry order; sessions
|
|
1439
1586
|
* without a workspace trail under an "未分组" bucket. Empty workspaces are
|
|
1440
1587
|
* omitted so the user only sees groups that currently hold a session.
|
package/lib/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync }
|
|
|
23
23
|
import { rm } from 'node:fs/promises'
|
|
24
24
|
import { createRequire } from 'node:module'
|
|
25
25
|
import { basename, dirname, join } from 'node:path'
|
|
26
|
+
import { homedir } from 'node:os'
|
|
26
27
|
import { fileURLToPath } from 'node:url'
|
|
27
28
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
28
29
|
|
|
@@ -41,6 +42,16 @@ const PACKAGE = 'dsh-plugin-admin'
|
|
|
41
42
|
const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
|
|
42
43
|
const PNPM_TIMEOUT_MS = 5 * 60_000
|
|
43
44
|
|
|
45
|
+
// Remote update check knobs: query the npm registry (the same registry npm
|
|
46
|
+
// uses — env override, .npmrc, or the official default) for the `latest`
|
|
47
|
+
// dist-tag of each registry-installed bundle and compare with the local
|
|
48
|
+
// version. Bounded concurrency, strict timeout, and a short-lived cache so
|
|
49
|
+
// the panel never hammers the registry on every refresh.
|
|
50
|
+
const UPDATE_CHECK_TIMEOUT_MS = 8_000
|
|
51
|
+
const UPDATE_CHECK_CONCURRENCY = 4
|
|
52
|
+
const UPDATE_CHECK_CACHE_TTL_MS = 5 * 60_000
|
|
53
|
+
const NPM_REGISTRY_DEFAULT = 'https://registry.npmjs.org'
|
|
54
|
+
|
|
44
55
|
// MCP connectivity probe knobs: the probe speaks the same newline-delimited
|
|
45
56
|
// JSON-RPC (stdio) / Streamable HTTP protocol the dsh-mcp-client plugin uses,
|
|
46
57
|
// but with a strict budget so a wedged server can never hang the panel.
|
|
@@ -147,6 +158,102 @@ function localSpecPath(spec) {
|
|
|
147
158
|
return null
|
|
148
159
|
}
|
|
149
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Resolve the npm registry the user actually installs from: the npm_config
|
|
163
|
+
* env override first, then a `registry=` line in the nearest .npmrc (user or
|
|
164
|
+
* profile), falling back to the official registry. Mirrors npm's own
|
|
165
|
+
* resolution well enough for update checks; a mismatch only means the check
|
|
166
|
+
* queries a different mirror, which is acceptable.
|
|
167
|
+
* @param profileDir - profile directory (checked for a local .npmrc).
|
|
168
|
+
* @returns the registry base URL (no trailing slash).
|
|
169
|
+
*/
|
|
170
|
+
function resolveNpmRegistry(profileDir) {
|
|
171
|
+
if (typeof process.env.npm_config_registry === 'string' && process.env.npm_config_registry.trim() !== '') {
|
|
172
|
+
return process.env.npm_config_registry.trim().replace(/\/+$/, '')
|
|
173
|
+
}
|
|
174
|
+
const candidates = [
|
|
175
|
+
join(profileDir, '.npmrc'),
|
|
176
|
+
join(homedir(), '.npmrc'),
|
|
177
|
+
]
|
|
178
|
+
for (const file of candidates) {
|
|
179
|
+
try {
|
|
180
|
+
const text = readFileSync(file, 'utf8')
|
|
181
|
+
const match = /^\s*registry\s*=\s*(\S+)\s*$/m.exec(text)
|
|
182
|
+
if (match) return match[1].replace(/\/+$/, '')
|
|
183
|
+
} catch {
|
|
184
|
+
// file absent — try the next candidate
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return NPM_REGISTRY_DEFAULT
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Query the npm registry for the `latest` dist-tag version of one package.
|
|
192
|
+
* Strict timeout; returns null on any failure so a dead registry never
|
|
193
|
+
* breaks the panel.
|
|
194
|
+
* @param registry - registry base URL.
|
|
195
|
+
* @param name - package name (scoped names are URL-encoded).
|
|
196
|
+
* @returns the latest version string, or null when unknown/unreachable.
|
|
197
|
+
*/
|
|
198
|
+
async function fetchLatestVersion(registry, name) {
|
|
199
|
+
try {
|
|
200
|
+
const url = `${registry}/${name.split('/').map(encodeURIComponent).join('/')}/latest`
|
|
201
|
+
const controller = new AbortController()
|
|
202
|
+
const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS)
|
|
203
|
+
let response
|
|
204
|
+
try {
|
|
205
|
+
response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } })
|
|
206
|
+
} finally {
|
|
207
|
+
clearTimeout(timer)
|
|
208
|
+
}
|
|
209
|
+
if (!response.ok) return null
|
|
210
|
+
const data = await response.json()
|
|
211
|
+
if (data && typeof data.version === 'string') return data.version
|
|
212
|
+
return null
|
|
213
|
+
} catch {
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Remote update check for one plugin entry: only registry-installed bundles
|
|
220
|
+
* (dependency-managed and not a local path) are queried. The result never
|
|
221
|
+
* throws — network failures surface as `error` on the entry.
|
|
222
|
+
* @param registry - registry base URL.
|
|
223
|
+
* @param plugin - plugin list entry ({ name, version, dependency, localPath }).
|
|
224
|
+
* @returns { name, version, latest, updateAvailable, error? }.
|
|
225
|
+
*/
|
|
226
|
+
async function checkPluginUpdate(registry, plugin) {
|
|
227
|
+
if (!plugin.dependency || plugin.localPath !== null) {
|
|
228
|
+
return { name: plugin.name, version: plugin.version, latest: null, updateAvailable: false }
|
|
229
|
+
}
|
|
230
|
+
const latest = await fetchLatestVersion(registry, plugin.name)
|
|
231
|
+
if (latest === null) {
|
|
232
|
+
return { name: plugin.name, version: plugin.version, latest: null, updateAvailable: false, error: '无法查询远程版本(网络或 registry 不可达)' }
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
name: plugin.name,
|
|
236
|
+
version: plugin.version,
|
|
237
|
+
latest,
|
|
238
|
+
updateAvailable: plugin.version !== latest,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Run bounded-concurrency async work over a list, preserving order. */
|
|
243
|
+
async function mapConcurrent(items, limit, worker) {
|
|
244
|
+
const results = new Array(items.length)
|
|
245
|
+
let cursor = 0
|
|
246
|
+
const runners = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
|
|
247
|
+
while (true) {
|
|
248
|
+
const index = cursor++
|
|
249
|
+
if (index >= items.length) return
|
|
250
|
+
results[index] = await worker(items[index], index)
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
await Promise.all(runners)
|
|
254
|
+
return results
|
|
255
|
+
}
|
|
256
|
+
|
|
150
257
|
/**
|
|
151
258
|
* Write the bundle layer list back into the profile manifest.
|
|
152
259
|
* @param profileDir - the profile directory.
|
|
@@ -308,8 +415,10 @@ function assertPnpmOperand(field, value) {
|
|
|
308
415
|
|
|
309
416
|
/* Exported for host-check.mjs: the shell-allowlist validator and the local
|
|
310
417
|
* source-path classifier are pure functions, so the self-check drives them
|
|
311
|
-
* directly without touching pnpm or the real profile manifest.
|
|
312
|
-
|
|
418
|
+
* directly without touching pnpm or the real profile manifest. The update
|
|
419
|
+
* checker is exported too — its registry fetch is injected, so the host-check
|
|
420
|
+
* can drive it against a local HTTP stub without touching the real npm. */
|
|
421
|
+
export { localSpecPath, assertPnpmOperand, resolveNpmRegistry, fetchLatestVersion, checkPluginUpdate }
|
|
313
422
|
|
|
314
423
|
/* ========================================================================== */
|
|
315
424
|
/* MCP Connectivity Probe */
|
|
@@ -1002,11 +1111,48 @@ export function apply(ctx) {
|
|
|
1002
1111
|
}
|
|
1003
1112
|
|
|
1004
1113
|
/* ---------------------- Plugin Admin Remote Service ---------------------- */
|
|
1114
|
+
// Remote-update cache: name -> { at, latest } so repeated panel refreshes
|
|
1115
|
+
// within the TTL do not re-hit the registry.
|
|
1116
|
+
const updateCache = new Map()
|
|
1117
|
+
|
|
1005
1118
|
const pluginService = {
|
|
1006
1119
|
async list() {
|
|
1007
1120
|
return listLayers()
|
|
1008
1121
|
},
|
|
1009
1122
|
|
|
1123
|
+
/**
|
|
1124
|
+
* Check every registry-installed bundle for a newer version on the npm
|
|
1125
|
+
* registry (same registry npm/pnpm use). In-box bundles and local-path
|
|
1126
|
+
* installs are skipped. Never throws: each entry reports its own
|
|
1127
|
+
* `updateAvailable`/`latest`/`error`. Results are cached briefly.
|
|
1128
|
+
* @returns { updates, checkedAt } where updates is per-plugin status.
|
|
1129
|
+
*/
|
|
1130
|
+
async checkUpdates() {
|
|
1131
|
+
const now = Date.now()
|
|
1132
|
+
const { plugins } = listLayers()
|
|
1133
|
+
const registry = resolveNpmRegistry(profileDir)
|
|
1134
|
+
const cached = []
|
|
1135
|
+
const todo = []
|
|
1136
|
+
for (const plugin of plugins) {
|
|
1137
|
+
if (!plugin.dependency || plugin.localPath !== null) continue
|
|
1138
|
+
const hit = updateCache.get(plugin.name)
|
|
1139
|
+
if (hit !== undefined && now - hit.at < UPDATE_CHECK_CACHE_TTL_MS) {
|
|
1140
|
+
cached.push({ ...hit.result })
|
|
1141
|
+
} else {
|
|
1142
|
+
todo.push(plugin)
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
const fresh = await mapConcurrent(todo, UPDATE_CHECK_CONCURRENCY, async (plugin) => {
|
|
1146
|
+
const result = await checkPluginUpdate(registry, plugin)
|
|
1147
|
+
if (result.latest !== null) {
|
|
1148
|
+
updateCache.set(plugin.name, { at: now, result: { name: result.name, latest: result.latest } })
|
|
1149
|
+
}
|
|
1150
|
+
return result
|
|
1151
|
+
})
|
|
1152
|
+
const updates = [...cached, ...fresh]
|
|
1153
|
+
return { updates, checkedAt: now }
|
|
1154
|
+
},
|
|
1155
|
+
|
|
1010
1156
|
async install(spec) {
|
|
1011
1157
|
if (typeof spec !== 'string' || spec.trim() === '') {
|
|
1012
1158
|
throw new Error('plugin-admin: install requires a spec string')
|
|
@@ -1875,6 +2021,15 @@ export function apply(ctx) {
|
|
|
1875
2021
|
parameters: nameParam,
|
|
1876
2022
|
result: { mode: 'src-json' },
|
|
1877
2023
|
},
|
|
2024
|
+
{
|
|
2025
|
+
id: `${PACKAGE}/checkUpdates`,
|
|
2026
|
+
service: PLUGIN_SERVICE_KEY,
|
|
2027
|
+
namespace: PLUGIN_NAMESPACE,
|
|
2028
|
+
method: 'checkUpdates',
|
|
2029
|
+
invocation: { kind: 'direct' },
|
|
2030
|
+
parameters: [],
|
|
2031
|
+
result: { mode: 'src-json' },
|
|
2032
|
+
},
|
|
1878
2033
|
// sessionAdmin
|
|
1879
2034
|
{
|
|
1880
2035
|
id: `${PACKAGE}/session/list`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-admin",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "dsh web UI plugin: unified administration panel — plugin and session management with modern tabbed Web UI (host remote + browser panel)",
|
|
6
6
|
"main": "lib/index.js",
|