pi-web-ui 0.86.2 → 0.87.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +59 -21
  2. package/README.md +1 -1
  3. package/bin/pi-web-ui.mjs +312 -40
  4. package/dist/server/agent-service.js +297 -6
  5. package/dist/server/client-state.js +8 -0
  6. package/dist/server/composer-drafts.js +138 -0
  7. package/dist/server/dsh/dsh-agent-service.js +531 -54
  8. package/dist/server/dsh/dsh-client.js +28 -0
  9. package/dist/server/dsh/dsh-sessions.js +28 -0
  10. package/dist/server/dsh/dsh-usage.js +82 -0
  11. package/dist/server/dsh/preset-clones.js +260 -0
  12. package/dist/server/dsh/runtime/custom-prompt.mjs +33 -0
  13. package/dist/server/dsh/runtime/goal-rpc.mjs +406 -22
  14. package/dist/server/dsh/runtime/launcher.mjs +17 -0
  15. package/dist/server/dsh/runtime/override.patch.yml +19 -1
  16. package/dist/server/files-service.js +259 -1
  17. package/dist/server/index.js +219 -3
  18. package/dist/server/mcp-bridge.js +126 -22
  19. package/dist/server/mcp-hot-reload.js +117 -0
  20. package/dist/server/model-admin.js +93 -3
  21. package/dist/server/plugin-dom.js +83 -0
  22. package/dist/server/plugin-facilities.js +15 -1
  23. package/dist/server/plugin-installer.js +6 -0
  24. package/dist/server/plugins.js +781 -74
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/provider-oauth-flow.js +157 -0
  27. package/package.json +1 -1
  28. package/plugins/catalog.json +9 -0
  29. package/themes/dark-teal.css +63 -22
  30. package/web/dist/assets/index-DePnXpq-.js +374 -0
  31. package/web/dist/assets/index-F86qWlJy.css +41 -0
  32. package/web/dist/index.html +3 -2
  33. package/web/dist/assets/TerminalPanel-BytY8dx7.js +0 -6
  34. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
  35. package/web/dist/assets/index-CKoVyDkP.css +0 -10
  36. package/web/dist/assets/index-Dmwji4Cr.js +0 -361
package/CHANGELOG.md CHANGED
@@ -10,6 +10,42 @@
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.87.0] — 2026-09-16
14
+
15
+ ### Added
16
+
17
+ - **插件市场支持「从目录同步」**(issue #165)—— 设置面板插件市场头部新增同步入口:填一个目录文档 URL(http(s))或本地绝对路径,一键同步可安装列表(走服务端现成的 `plugin_catalog_sync` 通道:同校验、同原子写盘;可选同步后安装全部条目 / 整体替换,逐条安装结果就地回显)。成功同步过的 URL 记在浏览器 localStorage(最近 8 个),一点即重同步。第三方仓库从此不需要再为同步专门发一个占位插件。
18
+ - **`install --catalog <url>` 与 `PI_WEB_PLUGIN_CATALOG_URL`**(issue #165)—— headless/预置场景:CLI 从目录文档同步列表并逐条安装/更新(已安装默认跳过,`--force` 更新,`--replace` 整体替换,单条失败不中断整批;`--build` / `--no-build` 对逐条同样生效);服务端启动时若配了该环境变量则自动同步一次并安装,失败只告警不阻断启动。
19
+
20
+ - **DSH 引擎复刻 dsh-web 四模式 Agent 预设** —— standard(全功能)/ PTC(`run_code` 组合面)/ minimal(单持久 shell)/ cordis(组合创作),与官方同名录同语义:新对话下拉选择、空白会话可切换、首轮发言后锁定、默认预设在设置面板配置、自建预设(`$DSH_HOME/.agent-presets`)照常上架。自定义系统提示词改走独立 host section(standard/ptc/cordis 下发,minimal 按官方语义压住)。已知限制:自建组合里写裸包名的无法挂载(launcher 式 boot 的 baseUrl 所限);问卷/技能目录钩子改挂 agent scope(旧 host 写法在新版运行时已失效)。
21
+
22
+ - **未发送的输入框草稿跨刷新/切换自动恢复**(issue #166,单中心文件方案)—— 以前刷新页面、切会话再回来,输入框里没发出去的字全丢。现在打字时前端每 2s 防抖 + 失焦/切会话即时把草稿存到服务端 `<dataDir>/composer-drafts.json`(按 sessionId 键入,每会话只留最新一条;`localStorage` 同步镜像一层,兜住崩溃和关闭页面的最后一击),切会话/`newChat`/刷新重连时的全量快照把草稿带回来(本地没动过才恢复,绝不覆盖正在打的字)。发送成功、会话删除即清,30 天未更新兜底清扫。增量快照不带草稿(无 60ms 热帧开销),不进 LLM 上下文、不污染历史搜索,pi 引擎独有(DSH 暂无)。
23
+ - **语音输入插件支持一键安装本地 Whisper** —— 🎤 浮层里点一下,服务端自动装 transformers.js 运行时 + 下载模型(base 约 290MB / tiny 约 150MB,可在设置里切),以后录音不出本机、不要 key 也能转写;新 `engine` 设置(auto/local/remote,默认 auto 本地优先、挂了切远端)。服务端录音改走浏览器现场编码的 16k 单声道 WAV(AudioWorklet,ScriptProcessor 兜底),不再拼 MediaRecorder mime,服务端也无需装 ffmpeg。
24
+
25
+ ### Fixed
26
+
27
+ - **插件 `ensureDeps` 遇到带版本号的依赖永远判缺失** —— `isDepAvailable` 把 `foo@1.2.3` 原样丢给 `require.resolve`(它不认 `@后缀`,恒 `MODULE_NOT_FOUND`),于是 pin 了版本的插件每次都重跑 `npm install`,装完还报“仍缺”。现在先剥掉版本再探测(只判存在,不审计版本;安装时 pin 照走)。
28
+ - **语音输入在 Edge 上“完全不能识别”且报错看不懂** —— 现在浮层按错误码给中文解释:非安全上下文(`http://局域网IP` 打开被浏览器掐语音+麦克风,指路 localhost)、`network`(Edge 语音服务要联网,代理/VPN 可能拦)、`not-allowed`(麦克风权限,指路地址栏 🔒);听写中多了一个「改用服务端录音」按钮,一键绕过抽风的浏览器识别;浏览器静默断句续听加了 2 次上限,防无限空转。
29
+ - **DSH 引擎在新版 dsh 运行时下无法启动** —— wrapper 调的 `ctx.userQuestions.registerProvider` 已被上游删除,boot 直接 `TypeError` 崩溃。现在按官方 waterfall 语义把问卷 answerer 注册到每个 agent scope。
30
+ - **DSH 引擎底栏没有上下文占用 / 缓存命中 / 回复速率** —— 新版 dsh 运行时取消了持久的 `assistant/chunk`(逐 chunk 事件),
31
+ 改成 agent scope 的 `agent/assistant-stream` 直播帧,usage 只在结算时随 `assistant/message.usage` 落一次;jsonrpc 面两样都收不到,
32
+ 于是 streamingMessage / 速率 / 底栏统计全空。现在 wrapper 用 `{ global: true }` 订阅直播帧转成 `assistant.stream` 通知,服务端与老
33
+ `assistant/chunk` 共用一条 chunk 管线(`conv.liveChunks` 互斥),usage 走 `assistant/message.usage` 回填;没有 usage 时上下文显示 `—`
34
+ 而不是 `0 / 1.0M`。回归:`tests/unit/dsh-usage.test.ts` + `tests/dsh-stats-test.mjs`(已进冒烟清单)。
35
+ - **DSH 部署人设(`override.patch.yml`)键名写错** —— `persona:` 不是 schema 字段(应为 `personaPrefix:`),被 zod 静默丢弃,自定义系统提示词一直没进过运行时。
36
+ - **同 sessionId 重建抛 `already exists`** —— 运行时重启/优雅关闭后,磁盘已有的会话 id 走 `agents.create` 必撞;现在自动转 `agents.resume`(官方恢复路径,附带缝合被中断的 turn),预设按日志记录优先恢复。
37
+ - **只有源码的插件不再装出“沉默的死插件”**(issue #165)—— `install` 在“有构建声明但无产物”时默认直接构建(以前只打印一行极易错过的提示,装完是个什么都不加载的空目录),构建前先打印解析出的 install/command;产物已提交的仓库行为不变。`--no-build` 保留旧的装空目录行为(明确打印跳过原因),与 `--build` 互斥。设置面板的「源码构建」勾选框语义相应变为“强制重编”(不勾选时源码插件也会自动构建)。
38
+ - **顶栏「⋯」溢出菜单在 DOM 里但永远点不到**(issue #162)—— 菜单元件挂在 `.view-switch{overflow:hidden}`(桌面端圆角药丸容器的裁剪)/ `.topbar-actions` 横滑容器(窄屏 ≤768px)里面,往下展开的部分全被祖先裁掉,`z-index` 再高也出不来;藏进溢出菜单的条目实际不可达。现在菜单经 portal 到 `document.body` + `position: fixed`(与右键菜单同路),按触发按钮实测锚定、视口钳制(下方放不下翻到上方),并补上点外面 / Esc 关闭(滚动/缩放时重跟锚点,不关闭)。另修一个连带坑:关闭回调若是内联箭头,effect 每 render 解绑/重绑全套 document 监听,离散按键可能正好落在空窗里导致 Esc 丢键 —— 关闭走 ref,监听只装一次。回归:`tests/ui-layout-ui-test.mjs` 新增「真的可见可点」断言(`elementFromPoint` 落在菜单内)。
39
+
40
+ <!-- auto-i18n:start -->
41
+ ### i18n
42
+
43
+ - 前端新增 key(61):`atMentions`、`atMenuHint`、`fileOpenPreview`、`fileEnterDir`、`fileNewFile`、`fileNewDir`、`fileRename`、`fileNamePlaceholder`、`fileDuplicate`、`fileCut`、`fileCopyEntry`、`filePaste`、`fileDelete`、`fileDeleteConfirm`、`fileCopyRelPath`、`fileRefresh`、`providerAuthHint`、`oauthLogin`、`oauthLogout`、`oauthConnected`、`oauthDeviceCode`、`oauthOpenVerification`、`oauthContinue`、`pluginCatalogSync`、`pluginCatalogSyncHint`、`pluginCatalogSyncSource`、`pluginCatalogSyncSubmit`、`pluginCatalogSyncInstall`、`pluginCatalogSyncReplace`、`pluginCatalogSyncRecent`、`pluginCatalogSyncOk`、`pluginCatalogSyncInstalled`、`dshPreset`、`dshPresetNewChat`、`dshPresetLocked`、`dshPresetBlankOnly`、`dshPresetBroken`、`dshPresetUser`、`dshPresetDefaultTag`、`dshPresetCurrent`、`dshPresetMinimalNote`、`dshDefaultPreset`、`dshDefaultPresetDesc`、`dshPresetUserNote`、`dshPerm`、`dshPermReadOnly`、`dshPermReadOnlyDesc`、`dshPermWorkspaceWrite`、`dshPermWorkspaceWriteDesc`、`dshPermFullAccess`、`dshPermFullAccessDesc`、`dshPermFullAccessTag`、`dshPermCustom`、`dshPermConfirmFull`、`dshPermDefault`、`dshPermDefaultDesc`、`pluginDomNeed`、`pluginDomDesc`、`pluginDomGrant`、`pluginDomRevoke`、`pluginDomGranted`
44
+ - 前端中文变更(1):`pluginBuildHint`
45
+ - 前端英文变更(1):`pluginBuildHint`
46
+ - 服务端新增 key(2):`plugininstaller.build.conflict`、`plugins.host.engines.mismatch`
47
+ <!-- auto-i18n:end -->
48
+
13
49
  ## [0.86.2] — 2026-09-15
14
50
 
15
51
  ### Fixed
@@ -27,22 +63,22 @@
27
63
  ### Added
28
64
 
29
65
  - **插件安装 / 更新 / 卸载改为后台作业,不再抢走设置面板**(issue #152)—— 以前点安装会在可见终端里跑 CLI,同时把设置弹窗
30
- 关掉、主视图切到终端;连装几个插件就得「装一个、重开设置、再导航回市场」。现在作业跑在服务端(`server/plugin-installer.ts`
31
- ,执行的仍是同一个 CLI),输出按行回传,**设置面板原地显示进度**(进行中带最后一行输出,失败可就地展开输出尾部);同一时刻
32
- 只允许一个作业(两个 install 写同一目录必出半装状态),另有 15 分钟看门狗与「取消」。托管实例(`PI_WEB_MANAGED=1`)与
33
- CLI 缺失都会明确拒绝。
66
+ 关掉、主视图切到终端;连装几个插件就得「装一个、重开设置、再导航回市场」。现在作业跑在服务端(`server/plugin-installer.ts`
67
+ ,执行的仍是同一个 CLI),输出按行回传,**设置面板原地显示进度**(进行中带最后一行输出,失败可就地展开输出尾部);同一时刻
68
+ 只允许一个作业(两个 install 写同一目录必出半装状态),另有 15 分钟看门狗与「取消」。托管实例(`PI_WEB_MANAGED=1`)与
69
+ CLI 缺失都会明确拒绝。
34
70
  - **`host.reloadCatalog()`:受支持的插件市场目录同步**(issue #148)—— 第三方插件以前只能派发私有浏览器事件 + 开可见终
35
- 端来同步自己的插件清单(私有事件随时会变、宿主更新没有回执)。现在有一条一等公民路径:`reloadCatalog(url 或本地路径,
71
+ 端来同步自己的插件清单(私有事件随时会变、宿主更新没有回执)。现在有一条一等公民路径:`reloadCatalog(url 或本地路径,
36
72
  { install?, replace? })` —— 服务端拉取 → 用市场「添加到列表」同一套规则校验 → **原子写** `<dataDir>/plugin-catalog.json`
37
- (形状不对 / 解析失败 / 读不到时一个字节都不写,旧目录保持有效)→ 可选逐条安装(已装走更新,失败逐条记录不中断整批)→
38
- 重载插件并把新列表推给所有客户端 → 结构化回执 `{ok, error?, entries?, installed?}`。
73
+ (形状不对 / 解析失败 / 读不到时一个字节都不写,旧目录保持有效)→ 可选逐条安装(已装走更新,失败逐条记录不中断整批)→
74
+ 重载插件并把新列表推给所有客户端 → 结构化回执 `{ok, error?, entries?, installed?}`。
39
75
  - **插件 UI 扩展点框架:11 个挂载点,插件只声明、宿主负责渲染**(issue #146 完整版)—— v1 那条 `topbar` 专用声明长成
40
- 了一套通用 slot 框架。manifest 的 `ui` 字段(或运行时 `host.ui.*`)可以往 `topbar.primary` / `topbar.overflow` /
41
- `bottombar` / `composer.actions` / `message.actions` / `rightpanel.tabs` / `contextmenu.topbar|message|session|file` /
42
- `settings.pages` 这 11 个挂载点声明条目(`{id, label, labelEn?, icon?, kind?, order?, group?, hidden?, action?, view?,
76
+ 了一套通用 slot 框架。manifest 的 `ui` 字段(或运行时 `host.ui.*`)可以往 `topbar.primary` / `topbar.overflow` /
77
+ `bottombar` / `composer.actions` / `message.actions` / `rightpanel.tabs` / `contextmenu.topbar|message|session|file` /
78
+ `settings.pages` 这 11 个挂载点声明条目(`{id, label, labelEn?, icon?, kind?, order?, group?, hidden?, action?, view?,
43
79
  when?, children?}`,也收 `topbar` / `settings` 这类简写别名);宿主负责渲染、排序、**溢出菜单**、可访问性、用户偏好与
44
- 审计,插件不碰 DOM,动作由插件客户端经 `host.onUiAction(name, fn)` 接管(按需加载它的 bundle,最终没人接管就提示一句,
45
- 不让按钮看起来点了没用)。
80
+ 审计,插件不碰 DOM,动作由插件客户端经 `host.onUiAction(name, fn)` 接管(按需加载它的 bundle,最终没人接管就提示一句,
81
+ 不让按钮看起来点了没用)。
46
82
  - **插件能整理宿主内置条目**:`ui.arrange` 可以把内置入口(id 形如 `host:settings`)移到别的槽位、隐藏、改顺序/分组/
47
83
  文案。内置条目表是 `web/src/ui-slots.ts` 的 `BUILTIN_UI_ITEMS`(32 条,逐条对应代码里真实存在的入口,不臆造)。
48
84
  - **用户偏好永远最后说话**:设置面板「界面插件 → 界面布局」按槽位列出所有条目,可逐条隐藏 / ↑↓ 调序 / 「恢复」单条
@@ -89,10 +125,10 @@ when?, children?}`,也收 `topbar` / `settings` 这类简写别名);宿主
89
125
  (4 加了 `reloadCatalog` / `openSession` / `onTopbarAction`,5 把顶栏动作推广成通用 `onUiAction`,6 加了 `sessions`
90
126
  与多根 `openSession`;`onTopbarAction` 保留为别名)。插件可用 `version` 判断宿主能力,老宿主上不会拿到 undefined 接口。
91
127
  - **`pi-web-ui install --build`:源码安装时隔离构建**(issue #150)—— 插件仓库可以只提交 TypeScript 源码,不必再把
92
- `index.mjs` / `client/entry.mjs` 产物提交进仓库。构建在临时目录里完成:只装插件声明的构建依赖
93
- (`npm install --ignore-scripts`,不执行任意生命周期脚本)→ 跑 manifest.build.command(缺省回落 package.json 的
94
- `scripts.build`)→ 校验 `outputs` 产物齐全 → **成功后才替换目标目录**(失败时上一版插件原样可用、无半装状态)。设置面
95
- 板的插件市场有「源码构建」勾选项,等价 `--build`,网络安装入口全部纳入托管实例(`PI_WEB_MANAGED`)拒绝面。
128
+ `index.mjs` / `client/entry.mjs` 产物提交进仓库。构建在临时目录里完成:只装插件声明的构建依赖
129
+ (`npm install --ignore-scripts`,不执行任意生命周期脚本)→ 跑 manifest.build.command(缺省回落 package.json 的
130
+ `scripts.build`)→ 校验 `outputs` 产物齐全 → **成功后才替换目标目录**(失败时上一版插件原样可用、无半装状态)。设置面
131
+ 板的插件市场有「源码构建」勾选项,等价 `--build`,网络安装入口全部纳入托管实例(`PI_WEB_MANAGED`)拒绝面。
96
132
  - **vscode-editor 插件:SSH 主机支持私钥路径 / 口令 / agent,还能从 `~/.ssh/config` 批量导入**(issue #149)—— 以前主机编辑只有密码与内联 PEM 私钥两项:带口令的私钥没地方填口令(连上就挂),用 `~/.ssh/id_rsa` 这类文件路径的得把私钥全文粘贴进来,ssh-agent 更没入口。现在编辑框多了三项:私钥路径(支持 `~` 展开,填写则优先用文件、不必粘贴全文)、私钥口令 passphrase(留空=保持不变,连接时透给 ssh2)、agent socket(如 `$SSH_AUTH_SOCK`,与密码/私钥互斥);「从 ssh config 导入」按钮解析本机 `~/.ssh/config` 列出候选(已导入的标出跳过),勾选批量导入 —— 导入只存私钥路径引用,不读私钥内容。解析语义对齐 OpenSSH:同块先出现的值优先,`Host *` 块只充当默认值继承、不产出候选,含通配符的别名不产出。
97
133
  - **检查更新走你自己的 npm 源,不再卡在官方源上**(issue #151)—— 配了镜像/私有源的用户(`<agentDir>/npm/.npmrc`,`pi update` 经 npm 本来就认这一份),以前顶栏更新检查还直连 `registry.npmjs.org`:镜像用户查不到新版、私有源用户直接 401。现在检查更新读同一份 `.npmrc` 解析 registry + 认证头(`_authToken` 优先、`_auth` 其次,同源才带),无文件/无配置时回落官方源。pi 与 DSH 双引擎同修。
98
134
  - **桌面版里「浏览器操作」给明确结论,不再让人白装扩展**(issue #153)—— 桌面窗口(Electron)里没有 Chrome 扩展运行时,page-picker 扩展永远装不上;以前面板还是网页版那四步安装引导,用户跟着做完才发现此路不通,模型调 `browser_page` 还要干等 3 秒桥超时。现在桌面壳里面板直接给结论 + 「用默认浏览器打开当前地址」按钮(去网页版按四步装即可),`queryBrowserControl` 与服务端 `pageCall` 都短路返回「改用网页版」的错误,不碰扩展桥。
@@ -101,11 +137,11 @@ when?, children?}`,也收 `topbar` / `settings` 这类简写别名);宿主
101
137
  ### Changed
102
138
 
103
139
  - **插件 `apiVersion: 2` 起「不写 `permissions`」等于默认拒绝**(issue #146 完整版)—— 宿主设施版本升到
104
- `PLUGIN_API_VERSION = 2`。以前 `permissions` 缺省是「旧格式全权模式」:受控宿主 API 一律放行、只在日志里警告一次;
105
- 现在只要 manifest 声明了 `apiVersion: 2`,没写 `permissions` 就按**空能力集**处理 —— `fs` / `http` / `tools` / `ui` /
106
- `chat` 这些受控入口逐个拒绝(`manifest.ui` 整份忽略),日志里写明缺哪个能力族。**对插件作者的含义**:升到 2 就得同时
107
- 补上 `permissions`(哪怕只是加一个顶栏按钮,也要写 `"permissions": ["ui"]`)。不写 `apiVersion` 的老插件仍是 v1 +
108
- 旧全权模式(只警告、行为不变),所以升级可以按插件逐个进行;`apiVersion` 比宿主新才会被拒绝激活,并提示升级 pi-web-ui。
140
+ `PLUGIN_API_VERSION = 2`。以前 `permissions` 缺省是「旧格式全权模式」:受控宿主 API 一律放行、只在日志里警告一次;
141
+ 现在只要 manifest 声明了 `apiVersion: 2`,没写 `permissions` 就按**空能力集**处理 —— `fs` / `http` / `tools` / `ui` /
142
+ `chat` 这些受控入口逐个拒绝(`manifest.ui` 整份忽略),日志里写明缺哪个能力族。**对插件作者的含义**:升到 2 就得同时
143
+ 补上 `permissions`(哪怕只是加一个顶栏按钮,也要写 `"permissions": ["ui"]`)。不写 `apiVersion` 的老插件仍是 v1 +
144
+ 旧全权模式(只警告、行为不变),所以升级可以按插件逐个进行;`apiVersion` 比宿主新才会被拒绝激活,并提示升级 pi-web-ui。
109
145
 
110
146
  ### Fixed
111
147
 
@@ -114,12 +150,14 @@ when?, children?}`,也收 `topbar` / `settings` 这类简写别名);宿主
114
150
  - **输入框里的一行 JSX 注释不再渲染成可见文本** —— `ChatInput.tsx` 里 `/* … */` 写在了 JSX 子节点位置,会被当成文本渲染出来;已改为 `{/* … */}`。
115
151
 
116
152
  <!-- auto-i18n:start -->
153
+
117
154
  ### i18n
118
155
 
119
156
  - 前端新增 key(44):`elsewhereBadge`、`elsewhereTip`、`workspaceRoots`、`workspaceRootsHint`、`addWorkspaceRoot`、`addWorkspaceRootHint`、`removeWorkspaceRoot`、`browserControlDesktop`、`browserControlDesktopLead`、`browserControlOpenInBrowser`、`devNoCache`、`devNoCacheDesc`、`autoReload`、`autoReloadDesc`、`pluginJobRunning`、`pluginJobDone`、`pluginJobFailed`、`pluginBuildSource`、`pluginBuildHint`、`uiLayoutTitle`、`uiLayoutHint`、`pluginTopbarMore`、`uiLayoutTopbar`、`uiLayoutTopbarOverflow`、`uiLayoutBottombar`、`uiLayoutComposer`、`uiLayoutMessage`、`uiLayoutRightPanel`、`uiLayoutSettingsPages`、`uiLayoutRestore`、`uiLayoutRestoreAll`、`uiLayoutArranged`、`uiLayoutEmpty`、`pluginGrantsTitle`、`pluginGrantsHint`、`pluginGrantsEmpty`、`pluginGrantsRevoke`、`pluginGrantRequestTitle`、`pluginGrantRequestBody`、`pluginGrantAllow`、`pluginGrantDeny`、`pluginUiNoHandler`、`pluginSessionGrantTitle`、`pluginSessionGrantBody`
120
157
  - 前端中文变更(3):`pluginUpdateHint`、`pluginInstallHint`、`pluginUninstallHint`
121
158
  - 前端英文变更(3):`pluginUpdateHint`、`pluginInstallHint`、`pluginUninstallHint`
122
159
  - 服务端新增 key(15):`plugincatalog.sync.fetch.failed`、`plugincatalog.sync.http`、`plugincatalog.sync.too.large`、`plugincatalog.sync.source.invalid`、`plugincatalog.sync.read.failed`、`plugincatalog.sync.source.missing`、`plugincatalog.sync.parse.failed`、`plugincatalog.sync.shape`、`plugininstaller.id.invalid`、`plugininstaller.source.invalid`、`plugininstaller.managed`、`plugininstaller.busy`、`plugininstaller.cli.missing`、`plugininstaller.cancelled`、`plugininstaller.timeout`
160
+
123
161
  <!-- auto-i18n:end -->
124
162
 
125
163
  ## [0.85.0] — 2026-09-14
package/README.md CHANGED
@@ -153,7 +153,7 @@ theme switching, and a full settings panel — tuned for daily development.
153
153
  - **`edit_soft`** — a looser `edit` (default off) for when indentation or whitespace makes the built-in tool fail: exact substring first, then trimmed line-core matching, `newText` written verbatim with the file's line endings/BOM preserved, and a diff + unified patch in the result. It also tolerates sloppy input (a JSON string, a bare object, legacy top-level `oldText`/`newText`).
154
154
  - **`delegate_task`** — hands a specialist template a six-section brief (TASK / EXPECTED OUTCOME / REQUIRED TOOLS / MUST DO / MUST NOT DO / CONTEXT) validated on the server: a missing template, a task under 20 characters or any empty section is rejected, and the error tells the model which templates it may use. Cards render the brief as labelled sections, and a finished delegation gets a button that jumps to the subagent's conversation.
155
155
  - **`ask_user_question`** — the pi engine has no questionnaire tool, so pi-web-ui adds one: the model asks structured questions (single/multi-select with rich option previews, plus free text) which open as a dialog; answers go back as the tool result, cancelling returns a tool error, waiting for you is exempt from the tool watchdog, and an unanswered questionnaire survives a page refresh or reconnect.
156
- - **MCP servers** — drop a `<dataDir>/mcp.json` (`{"servers":{"github":{"command":"node","args":["mcp.js"],"cwd":"/x"}}}`) and every tool a stdio MCP server advertises becomes an ordinary agent tool, executed server-side; one server failing to start only logs a line and leaves the others working. The file is read at startup, so restart pi-web-ui after editing it.
156
+ - **MCP servers** — drop a `<dataDir>/mcp.json` (`{"servers":{"github":{"command":"node","args":["mcp.js"],"cwd":"/x"}}}`) and every tool a stdio MCP server advertises becomes an ordinary agent tool, executed server-side; one server failing to start only logs a line and leaves the others working. The file is **hot-reloaded**: saving it applies within a second or two, no restart needed and only the servers whose spec actually changed are restarted (a malformed file is reported and the running servers are kept).
157
157
  - **Extension UI bridge** — pi extensions can drive the browser: `setWidget` renders live panels under the file tree (click a title to open it centred), `setStatus` puts text in the status bar, `notify` raises a toast, and `select` / `confirm` / `input` open an inline request panel above the composer with Markdown-rendered options (Esc cancels). ANSI colour codes in widget text are stripped, so extension footers don't arrive as escape-sequence noise.
158
158
  - **Plugin extras** — plugins can register `/commands` (badge “plugin”, executed server-side without spending tokens), register background tasks with their own stop button, declare a settings form, subscribe to run/tool/conversation events, and reach the host from their client bundle via `window.__piWebUiHost`. See [Plugins](#plugins).
159
159
 
package/bin/pi-web-ui.mjs CHANGED
@@ -43,11 +43,12 @@ import {
43
43
  realpathSync,
44
44
  readFileSync,
45
45
  readdirSync,
46
+ renameSync,
46
47
  rmSync,
47
48
  writeFileSync,
48
49
  } from "node:fs";
49
50
  import { homedir, tmpdir, userInfo } from "node:os";
50
- import { dirname, join, relative, resolve } from "node:path";
51
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
51
52
  import { pathToFileURL } from "node:url";
52
53
  import { fileURLToPath } from "node:url";
53
54
 
@@ -116,6 +117,10 @@ server 选项:
116
117
  install 选项: --name <id> 自定义插件目录名(默认取仓库名)
117
118
  --data-dir <dir> 数据目录(默认 ~/.pi-web)
118
119
  --force 目标已存在时覆盖
120
+ --build 强制源码构建(隔离目录编译,见下)
121
+ --no-build 即使只有源码也不构建(产物缺失的插件装上后不加载)
122
+ --catalog <url> 目录同步模式:读目录文档 → 写可安装列表 → 逐条安装
123
+ --replace 配合 --catalog:整体替换列表(默认按 id 合并)
119
124
 
120
125
  环境变量(flag 优先,环境变量后备):
121
126
  PI_WEB_PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_WEB_ENGINE / PI_WEB_HOST /
@@ -163,6 +168,11 @@ UI plugins (installed into <data-dir>/plugins/; refresh browser to activate whil
163
168
  install options: --name <id> Custom plugin directory name (default: repo name)
164
169
  --data-dir <dir> Data directory (default: ~/.pi-web)
165
170
  --force Overwrite if target already exists
171
+ --build Force a source build (isolated build, see below)
172
+ --no-build Never build, even for source-only plugins
173
+ --catalog <url> Catalog mode: read a catalog document, write the
174
+ installable list, then install every entry
175
+ --replace With --catalog: replace the whole list (default merges by id)
166
176
 
167
177
  Environment variables (flag takes precedence, env var as fallback):
168
178
  PI_WEB_PORT / PI_WEB_CWD / PI_WEB_DATA_DIR / PI_WEB_ENGINE / PI_WEB_HOST /
@@ -273,6 +283,15 @@ function parseFlags(argv) {
273
283
  case "--build":
274
284
  opts.build = true;
275
285
  break;
286
+ case "--no-build":
287
+ opts.noBuild = true;
288
+ break;
289
+ case "--catalog":
290
+ opts.catalog = take("--catalog");
291
+ break;
292
+ case "--replace":
293
+ opts.replace = true;
294
+ break;
276
295
  case "--check-updates":
277
296
  opts.checkUpdates = true;
278
297
  break;
@@ -1441,6 +1460,7 @@ const PLUGIN_ID_RE = /^[A-Za-z0-9_-]+$/;
1441
1460
 
1442
1461
  const PLUGIN_HELP = `用法:
1443
1462
  pi-web-ui install <源> [选项] 安装 GitHub 上的界面插件
1463
+ pi-web-ui install --catalog <目录> [选项] 同步插件市场目录并逐条安装
1444
1464
  pi-web-ui uninstall <id> [选项] 卸载已安装的界面插件
1445
1465
  pi-web-ui plugins [选项] 列出已安装的界面插件
1446
1466
 
@@ -1449,13 +1469,21 @@ const PLUGIN_HELP = `用法:
1449
1469
  https://github.com/owner/repo 完整 URL(.git 可省)
1450
1470
  https://github.com/o/r/tree/dev/sub/dir 指定分支 + 仓库内子目录
1451
1471
  以上任意写法末尾加 #分支或tag 指定分支/tag(如 owner/repo#v1.2)
1452
- /path/to/plugin-dir install 选项:
1472
+ /path/to/plugin-dir 本地目录(离线开发调试)
1473
+ 目录写法(--catalog 用):
1474
+ https://example.com/catalog.json 远端目录文档(数组或 {entries:[...]})
1475
+ /path/to/catalog.json 本地目录文档(绝对路径)
1476
+ install 选项:
1453
1477
  --name <id> 插件目录名/id(默认取仓库名或 manifest.id,仅限字母数字-_)
1454
1478
  --data-dir <dir> 数据目录(默认 ~/.pi-web 或 $PI_WEB_DATA_DIR)
1455
1479
  --force 目标目录已存在时覆盖(覆盖前自动备份旧版本)
1456
- --build 源码安装:在隔离临时目录里按插件声明安装构建依赖并编译
1480
+ --build 强制源码构建:在隔离临时目录里按插件声明安装构建依赖并编译
1457
1481
  (manifest.build 或 package.json 的 scripts.build),
1458
- 成功且产物齐全后才替换目标目录,失败不留半装状态 目标目录已存在时覆盖(覆盖前自动备份旧版本)
1482
+ 成功且产物齐全后才替换目标目录,失败不留半装状态
1483
+ --no-build 即使插件只有源码也不构建(与 --build 互斥;装出来的插件因缺产物不会被加载)
1484
+ --catalog <目录> 目录同步模式:读目录文档 → 原子写入可安装列表 → 逐条安装/更新
1485
+ (已安装的条目默认跳过,加 --force 则更新;单条失败不中断整批)
1486
+ --replace 配合 --catalog:整体替换可安装列表(默认按 id 合并,保留旧条目)
1459
1487
 
1460
1488
  plugins 选项:
1461
1489
  --check-updates 逐个对比最近安装版本与远端 HEAD,列出可更新插件
@@ -1656,30 +1684,50 @@ function buildPluginSource(pluginRoot, tmpDir, manifest) {
1656
1684
  return buildDir;
1657
1685
  }
1658
1686
 
1659
- async function pluginInstallCmd(argv) {
1660
- const { opts, positionals } = parseFlags(argv);
1661
- if (opts.help) {
1662
- console.log(PLUGIN_HELP);
1663
- return;
1687
+ /**
1688
+ * 构建决策(issue #165:--build 自动推断)。
1689
+ *
1690
+ * 只有源码、没有产物(index.mjs 与 client/entry.mjs 都缺)且存在可解析的构建声明 =
1691
+ * “不构建这次安装必死”,此时默认直接构建( previously 只打印一行提示,等用户滚回去重加
1692
+ * --build)。产物已提交的仓库不受影响(mode=none,什么都不跑)。
1693
+ * --no-build 保留旧的“装个空目录”行为(脚本化镜像/检查用),并明确打印跳过原因。
1694
+ */
1695
+ function decideBuildAction({ pluginRoot, manifest, build, noBuild }) {
1696
+ if (build && noBuild) throw new Error("--build 与 --no-build 不能同时用(二选一)");
1697
+ const plan = resolveBuildPlan(pluginRoot, manifest);
1698
+ const artifactsMissing =
1699
+ !existsSync(join(pluginRoot, "index.mjs")) && !existsSync(join(pluginRoot, "client", "entry.mjs"));
1700
+ if (build) {
1701
+ if (!plan)
1702
+ throw new Error(
1703
+ "插件没有声明构建方式:请在 manifest.json 里加 build.command(或 package.json 的 scripts.build),或去掉 --build",
1704
+ );
1705
+ return { mode: "explicit", plan };
1664
1706
  }
1665
- if (positionals.length !== 1)
1666
- fail(`用法: pi-web-ui install <源> [--name <id>] [--data-dir <dir>] [--force] [--build]\n${PLUGIN_HELP}`);
1667
- const rawSpec = positionals[0];
1668
- const pluginsDir = join(pluginDataDir(opts), "plugins");
1669
- // 本地目录直接装(离线开发调试),否则从 GitHub 拉取
1707
+ if (plan && artifactsMissing) {
1708
+ if (noBuild) return { mode: "skipped", plan };
1709
+ return { mode: "auto", plan };
1710
+ }
1711
+ return { mode: "none", plan };
1712
+ }
1713
+
1714
+ /**
1715
+ * 装一个插件(单源模式与目录模式共用):拉取/定位 → 读 manifest → 构建决策 →
1716
+ * 覆盖(备份+保留 config.json)→ 落盘 → 记录来源/sha。
1717
+ * 失败抛 Error(目录模式逐条 try/catch 继续下一条,单源模式由调用方转 fail)。
1718
+ */
1719
+ async function installOnePlugin({ rawSpec, name, force, build, noBuild, dataDir }) {
1720
+ const pluginsDir = join(dataDir, "plugins");
1670
1721
  const localCandidate = resolve(rawSpec.replace(/^file:\/\//, ""));
1671
1722
  const isLocal = existsSync(localCandidate);
1672
1723
  const src = isLocal ? null : parsePluginSource(rawSpec);
1673
1724
  const tmp = mkdtempSync(join(tmpdir(), "pi-web-ui-plugin-"));
1674
- let backupTs = null;
1675
1725
  try {
1676
1726
  let checkout;
1677
1727
  try {
1678
1728
  checkout = isLocal ? localCandidate : await acquireRepo(src, tmp);
1679
1729
  } catch (err) {
1680
- console.error(`✖ ${err?.message ?? err}`);
1681
- process.exitCode = 1;
1682
- return;
1730
+ throw new Error(`${err?.message ?? err}`);
1683
1731
  }
1684
1732
  const repoLabel = isLocal ? localCandidate : `${src.owner}/${src.repo}`;
1685
1733
  const pluginRoot = locatePluginRoot(checkout, src?.subpath, repoLabel);
@@ -1687,25 +1735,23 @@ async function pluginInstallCmd(argv) {
1687
1735
  try {
1688
1736
  manifest = JSON.parse(readFileSync(join(pluginRoot, "manifest.json"), "utf8"));
1689
1737
  } catch (err) {
1690
- fail(`manifest.json 不是合法 JSON:${err?.message ?? err}`);
1738
+ throw new Error(`manifest.json 不是合法 JSON:${err?.message ?? err}`);
1691
1739
  }
1692
- // 源码构建(--build,issue #150):在临时目录里装依赖 + 编译,成功后才进入
1693
- // 覆盖流程——构建失败 = 目标目录完全没被动过(上一版插件照常可用)。
1740
+ // 构建决策(issue #150 的 --build + issue #165 的自动推断):构建在临时目录里完成,
1741
+ // 成功后才进入覆盖流程——构建失败 = 目标目录完全没被动过(上一版插件照常可用)。
1742
+ const decision = decideBuildAction({ pluginRoot, manifest, build: build === true, noBuild: noBuild === true });
1694
1743
  let installRoot = pluginRoot;
1695
- if (opts.build) {
1744
+ if (decision.mode === "explicit" || decision.mode === "auto") {
1745
+ console.log(
1746
+ `· 源码构建(${decision.mode === "auto" ? "自动推断:有构建声明但无产物" : "--build"}):先 ${decision.plan.install},再 ${decision.plan.command}`,
1747
+ );
1696
1748
  try {
1697
1749
  installRoot = buildPluginSource(pluginRoot, tmp, manifest);
1698
1750
  } catch (err) {
1699
- process.exitCode = 1;
1700
- console.error(`✖ ${err?.message ?? err}`);
1701
- return;
1751
+ throw new Error(`${err?.message ?? err}`);
1702
1752
  }
1703
- } else if (
1704
- resolveBuildPlan(pluginRoot, manifest) &&
1705
- !existsSync(join(pluginRoot, "index.mjs")) &&
1706
- !existsSync(join(pluginRoot, "client", "entry.mjs"))
1707
- ) {
1708
- console.log("· 这个插件声明了构建、但目录里还没有产物:加 --build 可在安装时构建");
1753
+ } else if (decision.mode === "skipped") {
1754
+ console.log( 跳过构建(--no-build):目录里没有产物,装上后该插件不会被加载");
1709
1755
  }
1710
1756
  // 默认 id:子目录名 > 仓库名 > 本地目录名
1711
1757
  const sourceName = src?.subpath ? src.subpath.split("/").pop() : (src?.repo ?? localCandidate.split(/[\\/]/).pop());
@@ -1713,16 +1759,17 @@ async function pluginInstallCmd(argv) {
1713
1759
  String(manifest.id ?? sourceName)
1714
1760
  .replace(/[^A-Za-z0-9_-]/g, "-")
1715
1761
  .replace(/^-+|-+$/g, "") || "plugin";
1716
- const id = opts.name ?? fallbackId;
1717
- if (!PLUGIN_ID_RE.test(id)) fail(`非法插件 id "${id}"(仅限字母数字-_,可用 --name <id> 自定义)`);
1762
+ const id = name ?? fallbackId;
1763
+ if (!PLUGIN_ID_RE.test(id)) throw new Error(`非法插件 id "${id}"(仅限字母数字-_,可用 --name <id> 自定义)`);
1718
1764
  const target = join(pluginsDir, id);
1765
+ let backupTs = null;
1719
1766
  let prevConfig = null;
1720
1767
  const CONFIG_NAME = "config.json";
1721
1768
  if (existsSync(target)) {
1722
- if (!opts.force) fail(`插件目录已存在:${target}\n 加 --force 覆盖,或用 --name <id> 换个名字。`);
1769
+ if (!force) throw new Error(`插件目录已存在:${target}\n 加 --force 覆盖,或用 --name <id> 换个名字。`);
1723
1770
  // 更新前备份旧版本(<dataDir>/plugin-backups/<id>-<ts>/,保留最近 3 份),
1724
1771
  // 失败时自动回滚。备份与安装同 filter:不带 .git/node_modules。
1725
- backupTs = ensurePluginBackup(pluginDataDir(opts), id, { source: rawSpec });
1772
+ backupTs = ensurePluginBackup(dataDir, id, { source: rawSpec });
1726
1773
  // 插件凭据/配置不因升级丢失:先取出旧 config.json,拷完新文件后原样放回
1727
1774
  try {
1728
1775
  prevConfig = readFileSync(join(target, CONFIG_NAME), "utf8");
@@ -1736,10 +1783,10 @@ async function pluginInstallCmd(argv) {
1736
1783
  cpSync(installRoot, target, { recursive: true, filter: PLUGIN_COPY_FILTER });
1737
1784
  } catch (err) {
1738
1785
  // 拷贝失败 → 有备份则自动回滚,保持旧版本可用
1739
- if (backupTs && restorePluginBackup(pluginDataDir(opts), id)) {
1740
- fail(`插件更新失败:${err?.message ?? err}\n 已自动回滚到更新前版本。`);
1786
+ if (backupTs && restorePluginBackup(dataDir, id)) {
1787
+ throw new Error(`插件更新失败:${err?.message ?? err}\n 已自动回滚到更新前版本。`);
1741
1788
  }
1742
- fail(`插件更新失败:${err?.message ?? err}\n (无可用备份,请重新 install --force)`);
1789
+ throw new Error(`插件更新失败:${err?.message ?? err}\n (无可用备份,请重新 install --force)`);
1743
1790
  }
1744
1791
  if (prevConfig !== null && !existsSync(join(target, CONFIG_NAME))) {
1745
1792
  writeFileSync(join(target, CONFIG_NAME), prevConfig);
@@ -1758,14 +1805,239 @@ async function pluginInstallCmd(argv) {
1758
1805
  } catch {
1759
1806
  /* 尽力而为 */
1760
1807
  }
1808
+ return { id, target, manifest, buildMode: decision.mode };
1809
+ } finally {
1810
+ rmSync(tmp, { recursive: true, force: true });
1811
+ }
1812
+ }
1813
+
1814
+ /**
1815
+ * 目录文档校验(issue #165 的 CLI --catalog)。
1816
+ *
1817
+ * 规则与 server/plugin-catalog.ts 的 toEntry 对齐(id 字符集、source 形状、字段裁剪),
1818
+ * 仅放宽一条:CLI 跑在使用者的本机信任上下文里,允许已存在的本地目录源(离线开发、
1819
+ * 本地目录同步);服务端 toEntry(网络/插件触发)仍只收远端源。两边规则若漂移,
1820
+ * tests/plugin-catalog-cli-test.mjs 的行为断言会先响(离线全链路)。
1821
+ */
1822
+ function isCatalogEntrySource(s) {
1823
+ if (!s || s.length > 300) return false;
1824
+ // 本地目录:CLI 才放行(存在性检查,file:// 前缀兼容 install 单源写法)
1825
+ try {
1826
+ if (existsSync(resolve(s.replace(/^file:\/\//, "")))) return true;
1827
+ } catch {
1828
+ /* 非法路径字符:走下面的远端规则 */
1829
+ }
1830
+ if (/^https?:\/\//.test(s)) return true;
1831
+ const spec = s.split("#")[0].replace(/\/+$/, "");
1832
+ const segs = spec.split("/").filter(Boolean);
1833
+ if (segs.length < 2) return false;
1834
+ for (const seg of segs) if (seg === "." || seg === "..") return false;
1835
+ return true;
1836
+ }
1837
+
1838
+ function deriveCatalogEntryId(rawId, source) {
1839
+ if (rawId && PLUGIN_ID_RE.test(rawId)) return rawId;
1840
+ try {
1841
+ const local = resolve(source.replace(/^file:\/\//, ""));
1842
+ if (existsSync(local)) {
1843
+ const base = local.split(/[\\/]/).pop() || "plugin";
1844
+ const cleaned = base.replace(/[^A-Za-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
1845
+ return cleaned || "plugin";
1846
+ }
1847
+ } catch {
1848
+ /* 走远端规则 */
1849
+ }
1850
+ const spec = source.split("#")[0].replace(/\/+$/, "");
1851
+ const segs = spec.split("/").filter(Boolean);
1852
+ const last = segs.length >= 2 ? segs[segs.length - 1] : (segs[0] ?? "plugin");
1853
+ const cleaned = last.replace(/[^A-Za-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
1854
+ return cleaned || "plugin";
1855
+ }
1856
+
1857
+ function catalogEntryFromRaw(raw) {
1858
+ if (!raw || typeof raw !== "object") return null;
1859
+ const source = typeof raw.source === "string" ? raw.source.trim() : "";
1860
+ if (!isCatalogEntrySource(source)) return null;
1861
+ const id = deriveCatalogEntryId(typeof raw.id === "string" ? raw.id.trim() : undefined, source);
1862
+ if (!PLUGIN_ID_RE.test(id)) return null;
1863
+ const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
1864
+ const name = str(raw.name) ?? id;
1865
+ const description = str(raw.description);
1866
+ const descriptionEn = str(raw.descriptionEn);
1867
+ const icon = str(raw.icon);
1868
+ const homepage = str(raw.homepage);
1869
+ return {
1870
+ id,
1871
+ name,
1872
+ source,
1873
+ ...(description ? { description } : {}),
1874
+ ...(descriptionEn ? { descriptionEn } : {}),
1875
+ ...(icon ? { icon } : {}),
1876
+ ...(homepage ? { homepage } : {}),
1877
+ };
1878
+ }
1879
+
1880
+ /** 目录文档形状:JSON 数组或 {entries:[...]};非法条目丢弃并计数(与服务端同语义)。 */
1881
+ function normalizeCatalogEntries(raw) {
1882
+ const list = Array.isArray(raw)
1883
+ ? raw
1884
+ : raw && typeof raw === "object" && Array.isArray(raw.entries)
1885
+ ? raw.entries
1886
+ : null;
1887
+ if (!list) throw new Error('目录 JSON 需为数组,或 {"entries": [...]} 形状');
1888
+ const entries = [];
1889
+ let skipped = 0;
1890
+ for (const it of list) {
1891
+ const e = catalogEntryFromRaw(it);
1892
+ if (e) entries.push(e);
1893
+ else skipped++;
1894
+ }
1895
+ return { entries, skipped };
1896
+ }
1897
+
1898
+ /** 读目录文档:http(s) 拉取(30s 超时),其余当本地绝对路径(与服务端同口径)。 */
1899
+ async function readCatalogDocumentText(source) {
1900
+ const src = String(source ?? "").trim();
1901
+ if (!src) throw new Error("缺少目录来源(--catalog <url 或本地绝对路径>)");
1902
+ if (/^https?:\/\//i.test(src)) {
1903
+ let res;
1904
+ try {
1905
+ res = await fetch(src, { redirect: "follow", signal: AbortSignal.timeout(30_000) });
1906
+ } catch (err) {
1907
+ throw new Error(`拉取目录失败:${err?.message ?? err}`);
1908
+ }
1909
+ if (!res.ok) throw new Error(`拉取目录失败:HTTP ${res.status}`);
1910
+ const text = await res.text();
1911
+ if (text.length > 1024 * 1024) throw new Error("目录文档过大(> 1024 KB)");
1912
+ return text;
1913
+ }
1914
+ if (!isAbsolute(src)) throw new Error("本地目录文档需为绝对路径(远端用 http(s) URL)");
1915
+ try {
1916
+ return readFileSync(src, "utf8");
1917
+ } catch (err) {
1918
+ throw new Error(`读取目录文件失败:${err?.message ?? err}`);
1919
+ }
1920
+ }
1921
+
1922
+ function readCatalogFileEntries(customPath) {
1923
+ try {
1924
+ const raw = JSON.parse(readFileSync(customPath, "utf8"));
1925
+ if (raw && typeof raw === "object" && Array.isArray(raw.entries)) return raw.entries;
1926
+ } catch {
1927
+ /* 无文件/坏文件 = 空列表 */
1928
+ }
1929
+ return [];
1930
+ }
1931
+
1932
+ function writeCatalogFileEntries(customPath, entries) {
1933
+ mkdirSync(dirname(customPath), { recursive: true });
1934
+ const tmp = `${customPath}.tmp-${process.pid}`;
1935
+ writeFileSync(tmp, JSON.stringify({ entries }, null, 2) + "\n");
1936
+ renameSync(tmp, customPath);
1937
+ }
1938
+
1939
+ /** install --catalog <url>:同步可安装列表 + 逐条安装/更新(单条失败不中断整批)。 */
1940
+ async function installCatalogCmd(opts) {
1941
+ const dataDir = pluginDataDir(opts);
1942
+ const customPath = join(dataDir, "plugin-catalog.json");
1943
+ const pluginsDir = join(dataDir, "plugins");
1944
+ const text = await readCatalogDocumentText(opts.catalog).catch((err) => fail(`${err?.message ?? err}`));
1945
+ let raw;
1946
+ try {
1947
+ raw = JSON.parse(text);
1948
+ } catch (err) {
1949
+ fail(`目录 JSON 解析失败:${err?.message ?? err}`);
1950
+ }
1951
+ let entries;
1952
+ let skipped = 0;
1953
+ try {
1954
+ ({ entries, skipped } = normalizeCatalogEntries(raw));
1955
+ } catch (err) {
1956
+ fail(`${err?.message ?? err}`);
1957
+ }
1958
+ // 到这里才动磁盘:形状不对的文档绝不覆盖有效列表(与服务端同纪律)。
1959
+ if (opts.replace) {
1960
+ writeCatalogFileEntries(customPath, entries);
1961
+ } else {
1962
+ const prev = readCatalogFileEntries(customPath).filter((x) => x && typeof x === "object");
1963
+ const next = [...prev];
1964
+ for (const e of entries) {
1965
+ const idx = next.findIndex((x) => x.id === e.id);
1966
+ if (idx >= 0) next[idx] = e;
1967
+ else next.push(e);
1968
+ }
1969
+ writeCatalogFileEntries(customPath, next);
1970
+ }
1971
+ console.log(
1972
+ `· 目录同步:${entries.length} 条合法${skipped ? `(丢弃 ${skipped} 条非法)` : ""}${opts.replace ? "(整体替换)" : "(按 id 合并)"} → ${customPath}`,
1973
+ );
1974
+ let okCount = 0;
1975
+ let failCount = 0;
1976
+ let skipCount = 0;
1977
+ for (const e of entries) {
1978
+ if (existsSync(join(pluginsDir, e.id)) && !opts.force) {
1979
+ console.log(`· 跳过 ${e.id}(已安装,加 --force 更新)`);
1980
+ skipCount++;
1981
+ continue;
1982
+ }
1983
+ try {
1984
+ await installOnePlugin({
1985
+ rawSpec: e.source,
1986
+ name: e.id,
1987
+ force: true,
1988
+ build: opts.build === true,
1989
+ noBuild: opts.noBuild === true,
1990
+ dataDir,
1991
+ });
1992
+ console.log(`✔ ${e.id} 安装成功`);
1993
+ okCount++;
1994
+ } catch (err) {
1995
+ console.error(`✖ ${e.id} 安装失败:${err?.message ?? err}`);
1996
+ failCount++;
1997
+ }
1998
+ }
1999
+ console.log(`✔ 目录安装完成:${okCount} 成功 / ${failCount} 失败 / ${skipCount} 跳过(已安装)`);
2000
+ if (failCount) process.exitCode = 1;
2001
+ }
2002
+
2003
+ async function pluginInstallCmd(argv) {
2004
+ const { opts, positionals } = parseFlags(argv);
2005
+ if (opts.help) {
2006
+ console.log(PLUGIN_HELP);
2007
+ return;
2008
+ }
2009
+ // 目录同步模式(issue #165):install --catalog <url> —— 读目录文档 → 校验 →
2010
+ // 原子写盘 → 逐条安装/更新(与服务端 plugin-catalog-sync 同语义)。
2011
+ if (opts.catalog !== undefined) {
2012
+ if (positionals.length !== 0)
2013
+ fail(
2014
+ `用法: pi-web-ui install --catalog <目录> [--data-dir <dir>] [--force] [--build|--no-build] [--replace]\n${PLUGIN_HELP}`,
2015
+ );
2016
+ if (opts.name) fail("--catalog 模式下 --name 无意义(目录名/id 来自目录条目)");
2017
+ await installCatalogCmd(opts);
2018
+ return;
2019
+ }
2020
+ if (positionals.length !== 1)
2021
+ fail(
2022
+ `用法: pi-web-ui install <源> [--name <id>] [--data-dir <dir>] [--force] [--build|--no-build]\n${PLUGIN_HELP}`,
2023
+ );
2024
+ try {
2025
+ const { id, target, manifest } = await installOnePlugin({
2026
+ rawSpec: positionals[0],
2027
+ name: opts.name,
2028
+ force: opts.force === true,
2029
+ build: opts.build === true,
2030
+ noBuild: opts.noBuild === true,
2031
+ dataDir: pluginDataDir(opts),
2032
+ });
1761
2033
  console.log(
1762
2034
  `✔ 已安装插件 ${id}${manifest.name && manifest.name !== id ? `(${manifest.name})` : ""}${manifest.version ? ` v${manifest.version}` : ""}`,
1763
2035
  );
1764
2036
  if (manifest.description) console.log(` ${manifest.description}`);
1765
2037
  console.log(` 位置: ${target}`);
1766
2038
  console.log(` 生效: 服务运行中刷新浏览器即可加载;未运行则下次启动生效。卸载: pi-web-ui uninstall ${id}`);
1767
- } finally {
1768
- rmSync(tmp, { recursive: true, force: true });
2039
+ } catch (err) {
2040
+ fail(`${err?.message ?? err}`);
1769
2041
  }
1770
2042
  }
1771
2043