dsh-plugin-admin 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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; }',
@@ -35,6 +35,9 @@ var CSS_TEXT = [
35
35
  '[data-dsh-admin-section] .input:hover { border-color: var(--dsw-alias-label-tertiary, rgba(0,0,0,0.28)); }',
36
36
  '[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); }',
37
37
  '[data-dsh-admin-section] .filter-bar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }',
38
+ '[data-dsh-admin-section] .search-bar { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.08)); border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.02)); flex: none; }',
39
+ '[data-dsh-admin-section] .search-icon { font-size: 12px; opacity: 0.7; flex: none; }',
40
+ '[data-dsh-admin-section] .search-bar .input { padding: 6px 10px; }',
38
41
  '[data-dsh-admin-section] .pill { font-size: 11px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.1)); background: transparent; cursor: pointer; color: var(--dsw-alias-label-secondary, #666); transition: all 0.12s; font-family: inherit; }',
39
42
  '[data-dsh-admin-section] .pill:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.04)); }',
40
43
  '[data-dsh-admin-section] .pill.active { background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06)); color: var(--dsw-alias-button-primary-fill, #1677ff); font-weight: 600; border-color: currentColor; }',
@@ -51,7 +54,7 @@ var CSS_TEXT = [
51
54
  '[data-dsh-admin-section] .btn.danger-solid:hover:not(:disabled) { opacity: 0.9; }',
52
55
  '[data-dsh-admin-section] .btn.sm { padding: 3px 8px; font-size: 11px; border-radius: 6px; }',
53
56
  '[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; }',
57
+ '[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
58
  '[data-dsh-admin-section] .list::-webkit-scrollbar { width: 6px; }',
56
59
  '[data-dsh-admin-section] .list::-webkit-scrollbar-thumb { border-radius: 99px; background: var(--dsw-alias-border-subtle, rgba(0,0,0,0.2)); }',
57
60
  '[data-dsh-admin-section] .session-group { display: flex; flex-direction: column; gap: 6px; }',
@@ -74,6 +77,8 @@ var CSS_TEXT = [
74
77
  '[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
78
  '[data-dsh-admin-section] .tag.plugin { background: rgba(16, 185, 129, 0.12); color: var(--dsw-alias-state-success-primary, #10b981); }',
76
79
  '[data-dsh-admin-section] .tag.local { background: rgba(59, 130, 246, 0.1); color: var(--dsw-alias-button-primary-fill, #1677ff); }',
80
+ '[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); }',
81
+ '[data-dsh-admin-section] .tag.update-error { background: rgba(239, 68, 68, 0.1); color: var(--dsw-alias-state-error-primary, #ef4444); }',
77
82
  '[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
83
  '[data-dsh-admin-section] .tag.version { font-family: monospace; }',
79
84
  '[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 +93,7 @@ var CSS_TEXT = [
88
93
  '[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
94
  '[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
95
  '[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)); }',
96
+ '[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
97
  '[data-dsh-admin-section] .footer .path { max-width: 60%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
93
98
  '[data-dsh-admin-section] .footer .hint { font-style: normal; }',
94
99
  '[data-dsh-admin-section] .session-id-badge { font-family: monospace; font-size: 10px; opacity: 0.75; }',
@@ -485,6 +490,9 @@ function PluginsSection(props) {
485
490
  note: '',
486
491
  output: '',
487
492
  filter: 'all',
493
+ needle: '',
494
+ checkingUpdates: false,
495
+ updates: {}, // name -> { latest, updateAvailable, error? }
488
496
  })
489
497
  var pView = pairPlugins[0]
490
498
  var setPView = pairPlugins[1]
@@ -523,6 +531,69 @@ function PluginsSection(props) {
523
531
  })
524
532
  }
525
533
 
534
+ /**
535
+ * Query the npm registry for newer versions of registry-installed bundles
536
+ * and stash per-plugin results. Runs concurrently with the list so the
537
+ * panel stays responsive; failures are per-plugin, never fatal.
538
+ */
539
+ function checkUpdates() {
540
+ if (pView.checkingUpdates) return
541
+ patchPlugin({ checkingUpdates: true, note: '' })
542
+ callRemote('pluginAdmin/checkUpdates', {}).then(function (result) {
543
+ if (!alive.current) return
544
+ patchPlugin({ checkingUpdates: false })
545
+ if (!result.ok) {
546
+ patchPlugin({ error: '检查更新失败:' + messageOf(result.error) })
547
+ return
548
+ }
549
+ var list = (result.value && result.value.updates) || []
550
+ var map = {}
551
+ var updateCount = 0
552
+ for (var i = 0; i < list.length; i++) {
553
+ var u = list[i]
554
+ map[u.name] = { latest: u.latest, updateAvailable: u.updateAvailable === true, error: u.error || '' }
555
+ if (u.updateAvailable === true) updateCount++
556
+ }
557
+ var updated = map
558
+ patchPlugin({
559
+ updates: updated,
560
+ note: updateCount > 0
561
+ ? '发现 ' + updateCount + ' 个插件有新版本'
562
+ : (list.length > 0 ? '已检查 ' + list.length + ' 个插件,均为最新版本' : ''),
563
+ })
564
+ }, function (failure) {
565
+ if (!alive.current) return
566
+ patchPlugin({ checkingUpdates: false, error: '检查更新调用失败:' + messageOf(failure) })
567
+ })
568
+ }
569
+
570
+ /** Upgrade one plugin to its latest version (registry install by name). */
571
+ function upgradePlugin(name) {
572
+ if (pView.busy) return
573
+ var spec = name + '@latest'
574
+ patchPlugin({ busy: true, error: '', confirming: null, note: '' })
575
+ callRemote('pluginAdmin/install', { spec: spec }).then(function (result) {
576
+ if (!alive.current) return
577
+ if (result.ok) {
578
+ patchPlugin({
579
+ busy: false,
580
+ note: '已更新 ' + name + '。更改在重启 dsh 后生效',
581
+ output: (result.value && result.value.output) || '',
582
+ profileDir: (result.value && result.value.profileDir) || '',
583
+ plugins: (result.value && result.value.plugins) || [],
584
+ })
585
+ // Refresh the update map so the upgraded entry stops flagging.
586
+ checkUpdates()
587
+ return
588
+ }
589
+ patchPlugin({ busy: false, error: '更新失败:' + messageOf(result.error) })
590
+ reloadPlugins()
591
+ }, function (failure) {
592
+ if (!alive.current) return
593
+ patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
594
+ })
595
+ }
596
+
526
597
  function installPlugin() {
527
598
  if (pView.busy || pView.spec.trim() === '') return
528
599
  var spec = pView.spec.trim()
@@ -573,11 +644,17 @@ function PluginsSection(props) {
573
644
  useEffect(function () {
574
645
  alive.current = true
575
646
  reloadPlugins()
647
+ // Auto-check for remote updates on mount (the panel loads lazily when
648
+ // the 插件管理 tab is opened, so this runs once per open). The host
649
+ // caches registry answers for 5 minutes, so re-opening the tab shortly
650
+ // after does not re-hit the registry. The manual ⬆ 检查更新 button stays
651
+ // for forcing a fresh check.
652
+ checkUpdates()
576
653
  return function () { alive.current = false }
577
654
  }, [])
578
655
 
579
656
  return createElement('div', { 'data-dsh-admin-section': '' },
580
- renderPluginsView(pView, patchPlugin, reloadPlugins, installPlugin, removePlugin))
657
+ renderPluginsView(pView, patchPlugin, reloadPlugins, installPlugin, removePlugin, checkUpdates, upgradePlugin))
581
658
  }
582
659
 
583
660
  /**
@@ -921,7 +998,7 @@ function AdminCenterSection(props) {
921
998
  /* Render Plugins View */
922
999
  /* ========================================================================== */
923
1000
 
924
- function renderPluginsView(view, patch, reload, install, remove) {
1001
+ function renderPluginsView(view, patch, reload, install, remove, checkUpdates, upgrade) {
925
1002
  var elements = []
926
1003
 
927
1004
  // Toolbar
@@ -946,6 +1023,16 @@ function renderPluginsView(view, patch, reload, install, remove) {
946
1023
  view.busy ? createElement('span', { className: 'spinner', key: 'spin' }) : null,
947
1024
  '安装'
948
1025
  ),
1026
+ createElement('button', {
1027
+ type: 'button',
1028
+ key: 'btn-check-updates',
1029
+ className: 'btn',
1030
+ disabled: view.busy || view.checkingUpdates,
1031
+ onClick: checkUpdates,
1032
+ },
1033
+ view.checkingUpdates ? createElement('span', { className: 'spinner', key: 'spin' }) : null,
1034
+ view.checkingUpdates ? '检查中...' : '⬆ 检查更新'
1035
+ ),
949
1036
  createElement('button', {
950
1037
  type: 'button',
951
1038
  key: 'btn-refresh',
@@ -955,6 +1042,25 @@ function renderPluginsView(view, patch, reload, install, remove) {
955
1042
  }, '刷新')
956
1043
  ))
957
1044
 
1045
+ // Search bar: fuzzy filter by plugin name / version / local path.
1046
+ elements.push(createElement('div', { className: 'search-bar', key: 'search' },
1047
+ createElement('span', { className: 'search-icon', key: 'icon' }, '🔍'),
1048
+ createElement('input', {
1049
+ className: 'input',
1050
+ key: 'search-input',
1051
+ placeholder: '按名称/版本/路径模糊搜索插件...',
1052
+ value: view.needle,
1053
+ onChange: function (e) { patch({ needle: e.target.value }) },
1054
+ }),
1055
+ view.needle !== '' ? createElement('button', {
1056
+ type: 'button',
1057
+ key: 'btn-clear-search',
1058
+ className: 'btn sm',
1059
+ title: '清空搜索',
1060
+ onClick: function () { patch({ needle: '' }) },
1061
+ }, '✕') : null
1062
+ ))
1063
+
958
1064
  // Filter Pills
959
1065
  var totalPlugins = view.plugins.length
960
1066
  var thirdPartyCount = 0
@@ -997,17 +1103,17 @@ function renderPluginsView(view, patch, reload, install, remove) {
997
1103
  }
998
1104
 
999
1105
  // Filter and render rows
1106
+ var needle = view.needle.trim().toLowerCase()
1107
+ var filtered = filterPlugins(view.plugins, view.filter, needle)
1000
1108
  var rows = []
1001
- for (var j = 0; j < view.plugins.length; j++) {
1002
- var p = view.plugins[j]
1003
- if (view.filter === 'plugin' && !p.removable) continue
1004
- if (view.filter === 'builtin' && p.removable) continue
1005
- rows.push(renderPluginCard(p, view, remove, patch))
1109
+ for (var j = 0; j < filtered.length; j++) {
1110
+ var p = filtered[j]
1111
+ rows.push(renderPluginCard(p, view, remove, patch, upgrade))
1006
1112
  }
1007
1113
 
1008
1114
  if (rows.length === 0) {
1009
1115
  rows.push(createElement('div', { className: 'empty', key: 'empty' },
1010
- createElement('div', null, '📦 暂无匹配的插件层')
1116
+ createElement('div', null, needle !== '' ? '🔍 无匹配的插件(试试其他关键词)' : '📦 暂无匹配的插件层')
1011
1117
  ))
1012
1118
  }
1013
1119
 
@@ -1327,7 +1433,7 @@ function mergeTestState(map, id, partial) {
1327
1433
  for (var pk in partial) next[id][pk] = partial[pk]
1328
1434
  return next
1329
1435
  }
1330
- function renderPluginCard(plugin, view, remove, patch) {
1436
+ function renderPluginCard(plugin, view, remove, patch, upgrade) {
1331
1437
  var isConfirming = view.confirming === plugin.name
1332
1438
 
1333
1439
  var titleChildren = [
@@ -1344,19 +1450,49 @@ function renderPluginCard(plugin, view, remove, patch) {
1344
1450
  plugin.removable ? (isLocal ? '本地安装' : '包安装') : '内置'
1345
1451
  ))
1346
1452
 
1453
+ // Remote update badge: only for registry-installed bundles (removable and
1454
+ // not a local path) after a check ran. `updates` is keyed by plugin name.
1455
+ var updateInfo = view.updates && view.updates[plugin.name]
1456
+ if (updateInfo && updateInfo.updateAvailable && updateInfo.latest) {
1457
+ titleChildren.push(createElement('span', {
1458
+ className: 'tag update',
1459
+ key: 'update-badge',
1460
+ title: '远程 registry 有新版本:v' + updateInfo.latest + '(当前 v' + plugin.version + ')',
1461
+ }, '⬆ 有新版本 v' + updateInfo.latest))
1462
+ } else if (updateInfo && updateInfo.error) {
1463
+ titleChildren.push(createElement('span', {
1464
+ className: 'tag update-error',
1465
+ key: 'update-error',
1466
+ title: updateInfo.error,
1467
+ }, '⚠ 更新检查失败'))
1468
+ }
1469
+
1347
1470
  var headerChildren = [
1348
1471
  createElement('div', { className: 'card-title', key: 'title' }, titleChildren),
1349
1472
  ]
1350
1473
 
1351
1474
  if (plugin.removable && !isConfirming) {
1352
- headerChildren.push(createElement('div', { className: 'card-actions', key: 'actions' },
1353
- createElement('button', {
1475
+ var actionChildren = []
1476
+ // 更新 button — only when an update is available and the plugin is a
1477
+ // registry install (not local path).
1478
+ if (updateInfo && updateInfo.updateAvailable && updateInfo.latest && !isLocal) {
1479
+ actionChildren.push(createElement('button', {
1354
1480
  type: 'button',
1355
- className: 'btn danger sm',
1481
+ key: 'btn-upgrade',
1482
+ className: 'btn sm',
1356
1483
  disabled: view.busy,
1357
- onClick: function () { patch({ confirming: plugin.name }) },
1358
- }, '卸载')
1359
- ))
1484
+ title: '升级到 v' + updateInfo.latest + '(npm install ' + plugin.name + '@latest)',
1485
+ onClick: function () { upgrade(plugin.name) },
1486
+ }, '⬆ 更新'))
1487
+ }
1488
+ actionChildren.push(createElement('button', {
1489
+ type: 'button',
1490
+ key: 'btn-remove',
1491
+ className: 'btn danger sm',
1492
+ disabled: view.busy,
1493
+ onClick: function () { patch({ confirming: plugin.name }) },
1494
+ }, '卸载'))
1495
+ headerChildren.push(createElement('div', { className: 'card-actions', key: 'actions' }, actionChildren))
1360
1496
  }
1361
1497
 
1362
1498
  var header = createElement('div', { className: 'card-header', key: 'header' }, headerChildren)
@@ -1435,6 +1571,25 @@ function filterSessions(sessions, filter, needle) {
1435
1571
  }
1436
1572
 
1437
1573
  /**
1574
+ * Filter plugin layers by the type pill and a fuzzy name/version/path query.
1575
+ * The needle matches case-insensitively against the package name, the
1576
+ * installed version, and the local source path, so "tool" finds
1577
+ * dsh-custom-tool and "0.2" finds version rows.
1578
+ */
1579
+ function filterPlugins(plugins, filter, needle) {
1580
+ var result = []
1581
+ for (var i = 0; i < plugins.length; i++) {
1582
+ var p = plugins[i]
1583
+ if (filter === 'plugin' && !p.removable) continue
1584
+ if (filter === 'builtin' && p.removable) continue
1585
+ if (needle !== '') {
1586
+ var hay = ((p.name || '') + ' ' + (p.version || '') + ' ' + (p.localPath || '')).toLowerCase()
1587
+ if (hay.indexOf(needle) === -1) continue
1588
+ }
1589
+ result.push(p)
1590
+ }
1591
+ return result
1592
+ }/**
1438
1593
  * Group sessions by their accounting workspace in registry order; sessions
1439
1594
  * without a workspace trail under an "未分组" bucket. Empty workspaces are
1440
1595
  * 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
- export { localSpecPath, assertPnpmOperand }
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.0",
3
+ "version": "0.2.0",
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",