ygc-dsh-plugin-autoupdate 0.6.0 → 0.9.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
@@ -32,20 +32,96 @@
32
32
 
33
33
  **插件不自己改 `cordis.patch.yml`,不卸载任何插件,不碰 `~/.dsh/workbuddy-vault`。**
34
34
 
35
+ > 括号里的「不卸载插件」是 0.7.x 及更早的边界。0.8.0 起**新增了受控卸载**,
36
+ > 见下面「卸载插件」一节:只卸 profile 自装且宿主判定 removable 的包,走宿主官方接口。
37
+
35
38
  ## 自动重启怎么做的
36
39
 
37
- 不自杀。插件调用时只是把重启助手以 detached 方式拉起来(`ELECTRON_RUN_AS_NODE=1`),
38
- 由助手负责:
40
+ **两条通道,官方优先。**
41
+
42
+ ### 通道一:宿主优雅重启(首选)
43
+
44
+ 宿主若暴露 `desktopRuntime.requestRestart()`(`ctx.get('desktopRuntime')` 或
45
+ `ctx.get('desktopActions')`),插件直接调它。桌面 shell 自己 `app.relaunch()` 并无确认退出,
46
+ 把这次退出当作**预期内**的退出,因此**不会弹「启动失败」恢复框、不写 crash report**。
39
47
 
40
- 1. 等 `restartDelaySeconds`(默认 10 秒)
41
- 2. 等旧宿主进程退出(最多 15 秒)
42
- 3. 清掉同名残留进程(排除自己)
48
+ ### 通道二:进程树之外的助手(兜底)
49
+
50
+ 拿不到官方通道时才走这条。插件不自杀——它把重启助手**送到调用树之外**,由助手负责:
51
+
52
+ 1. 等 `restartDelaySeconds`(默认 0 秒)
53
+ 2. **等旧 Electron main 自己退出**(默认最多 15 秒,`exitWaitMs` 可调)——它自己退了就一步强制动作都不做
54
+ 3. 仍没退出,先 `taskkill /PID <main>`(**不带 `/F`**,等于发 WM_CLOSE,走 `app.quit()` 正常收尾);
55
+ 仍不领情才 `/T /F`
43
56
  4. 用 `process.execPath` 重新拉起,Windows 上带 `--updated`(窗口会自己置前台)
57
+ 5. 删掉跨进程锁,下一次重启才能正常排期
44
58
 
45
59
  全程写 `~/.dsh/storages/plugin-autoupdate/restart.log`。
46
60
 
61
+ ### 0.9.0 修的两条「重启之后 DSH 就没了」(实测复现 + 回归测试)
62
+
63
+ **故障一:助手是宿主的子进程,被自己的 `/T` 一起杀掉。**
64
+
65
+ 0.8.0 用 `spawn(detached: true)` 拉助手,助手仍然是宿主的**子进程**。随后的
66
+ `taskkill /PID <宿主> /T /F` 会顺着 `ParentProcessId` 把助手一起结束 ——
67
+ 日志停在「兜底结束旧宿主 pid=…」,`relaunched` 永远不出现。
68
+ 实测确认:`taskkill /T` 连 detached 子进程一起杀。
69
+
70
+ 现在两条路把助手送走:
71
+
72
+ | 通道 | 父进程 | 说明 |
73
+ |---|---|---|
74
+ | **WMI** `Win32_Process.Create`(首选) | `WmiPrvSE.exe` | 压根不在调用树里,`/T` 走不到 |
75
+ | **两跳孤儿**(WMI 不可用时) | 已退出的第一跳 | 第一跳拉起第二跳后立刻 `exit(0)`,`/T` 顺树找不到它 |
76
+
77
+ **故障二:结束的是宿主(Host),不是 Electron main。**
78
+
79
+ 插件跑在 Host 里,`process.pid` 是 Host 的 pid:
80
+
81
+ ```
82
+ DeepSeek Harness.exe (main, 13872)
83
+ ├─ gpu / utility / renderer
84
+ └─ DeepSeek Harness.exe (Host, 8036) ← 插件在这里
85
+ ```
86
+
87
+ 只杀 Host,main 与 renderer 都还活着;新实例一启动就 `requestSingleInstanceLock()`
88
+ 失败并**立刻退出** —— 用户看到「重启之后 DSH 不见了」。
89
+
90
+ 现在 `lib/pidtree.js` 按「同一可执行文件往上爬到根」解析出 main,把它登记成目标 pid。
91
+ 仅在 `process.versions.electron` 有值时启用(普通 node 环境里爬 `node.exe` 会爬到无关父进程);
92
+ 解析不出就**只等不杀**,绝不回落到 `process.pid` / `process.ppid`。
93
+
94
+ **另外两个坑(都有回归测试):**
95
+
96
+ - **`ELECTRON_RUN_AS_NODE` 必须显式设。** 助手是用 `DeepSeek Harness.exe <helper.js>`
97
+ 起的,不带这个变量 Electron 会把它当成一个完整 App 启动 —— 结果是又开一个 DSH 窗口、
98
+ 助手永远不执行。`test/restart-electron-env.e2e.mjs` 用真 Electron 验证,
99
+ `…negative.mjs` 证明该断言不是空的。
100
+ - **WMI 不继承调用方环境。** 实测:父进程设的变量在 WMI 子进程里是空的。
101
+ 所以 launcher 用 `.cmd` 逐行 `set` 白名单(`PATH` / `DSH_HOME` / `DSH_PROFILE` /
102
+ `DSH_DESKTOP_NODE_EXECUTABLE` / `TEMP` …),payload 也带一份;
103
+ 白名单之外的变量(可能含令牌)不落盘。
104
+
105
+ **并发**:面板连点或自动更新撞车时,0.8.0 会拉起多个助手互相打架(日志里 9 秒内 3 条
106
+ `scheduled restart`)。现在有进程内冷却(60 秒)+ 跨进程锁文件,第二次请求直接返回
107
+ 「已有一个重启在执行中,本次跳过」。
108
+
109
+ ### 为什么绝不按映像名杀进程(0.8.0 修复的实机故障)
110
+
111
+ Electron 的 main / gpu-process / renderer / utility,以及以 `ELECTRON_RUN_AS_NODE`
112
+ 跑起来的宿主,**映像名完全相同**(`DeepSeek Harness.exe`)。0.7.x 在「清掉同名残留进程」
113
+ 这一步用 `tasklist /FI "IMAGENAME eq ..."` 取全部同名 pid 再逐个强制结束,实际后果是:
114
+
115
+ - 把自己的 renderer 一起打死 → `Desktop renderer exited: launch-failed` 崩溃日志
116
+ - 用户看到的是 **DSH 无预警消失、随后自动重开**,完全不像「更新完成后的正常重启」
117
+
118
+ 0.8.0 起助手只认两个明确的 pid:**自己**(`process.pid`,永不动)与
119
+ **调用方登记的旧宿主**(`mainPid`)。这条边界由 `test/restart-safety.test.mjs`
120
+ 以源码契约钉死(`tasklist` 不得出现 `IMAGENAME`、`taskkill` 必须带 `/PID` 且不得带 `/IM`)。
121
+
47
122
  「只在没有会话正在跑的时候重启」默认开着:判定依据是最近 90 秒内有没有会话事件。
48
- 有会话时重启会被推迟,等空闲了再执行。
123
+ 有会话时重启会被推迟,等空闲了再执行。**手动卸载与手动重启不受这条约束。**
124
+
49
125
 
50
126
  ## 状态文件
51
127
 
@@ -96,8 +172,49 @@
96
172
  | POST | `/restart` | 立即重启 |
97
173
  | GET | `/backups` | 列出清单备份 |
98
174
  | POST | `/restore` | 用某份备份回滚清单 |
175
+ | GET | `/removable` | 哪些插件能卸(透传宿主 `removable` / `availability`) |
176
+ | POST | `/uninstall` | 卸载一个插件(body: `{ name, confirm: true }`) |
99
177
  | GET | `/diagnose` | 看插件识别结果(排查「为什么某个包没被检查」) |
100
178
 
179
+ ## 卸载插件(0.8.0 新增)
180
+
181
+ 桌面 profile 下一个插件同时躺在**三处状态**里,漏掉任何一处都会留下
182
+ 「卸了但行为还在」或「UI 里没了却仍在加载」的半成品:
183
+
184
+ 1. `package.json` 的 `dependencies`
185
+ 2. `dsh.profile.bundles`(不在这里就不会被加载)
186
+ 3. `cordis.patch.yml` 里针对它的补丁行
187
+
188
+ 所以卸载**只走宿主官方 `pluginManager.removeBundle(name)`**:它一次做完
189
+ 「摘选 bundle → `pnpm remove` → 重新发布并 reload 加载树」。本插件绝不自己去改
190
+ 这三个文件(自己改最容易漏,而且和宿主内存状态不同步)。
191
+
192
+ ### 什么能卸、什么不能
193
+
194
+ | 情形 | 结果 |
195
+ |---|---|
196
+ | `availability === 'profile'` 且 `removable === true` | 可卸 |
197
+ | `availability === 'installation'`(随 DSH 安装提供) | 拒绝,说明原因 |
198
+ | `removable !== true`(正在运行 / 宿主只读) | 拒绝,回显宿主的 `readOnlyReason` |
199
+ | 本插件自身 `ygc-dsh-plugin-autoupdate` | 拒绝,避免把自动更新器自己拆了 |
200
+ | `@deepseek-ai/dsh-plugin-manager` | 拒绝,卸掉后整个插件体系都加载不了 |
201
+ | 宿主没提供 `pluginManager` | 拒绝,明确告知改用 DSH 自带插件管理 |
202
+
203
+ ### 三条硬约束
204
+
205
+ 1. **必须显式确认**:`POST /uninstall` 没有 `confirm: true` 一律不执行;
206
+ 面板上的按钮会先弹二次确认,取消就什么都不做。
207
+ 2. **不可卸载时按钮置灰并说明原因**:信息未知(拿不到宿主清单)时也置灰——
208
+ 宁可点不了,也不让用户点了才发现不行。
209
+ 3. **卸载后自动重启生效**:模块已经加载进宿主内存,不重启不会真的消失。
210
+ 走的是与自动更新**同一条**重启通道(优雅优先),不另写一套杀进程逻辑。
211
+
212
+ ### 卸载 → 重启用哪条路
213
+
214
+ 与「自动重启怎么做的」完全一致:优先 `desktopRuntime.requestRestart()`,
215
+ 拿不到才用分离助手。卸载时**不受「只在空闲时重启」约束**——
216
+ 这是用户当前主动发起的操作,不会打断别人的长任务还装作没发生。
217
+
101
218
  ## 插件共享(特征文件夹)
102
219
 
103
220
  把「我这台机器装了什么插件」沉淀成一份可以给别人看的东西,互相补全。
@@ -228,26 +345,53 @@ $node = "C:\DeepSeekHarness\resources\runtime\primary-runtime\dependencies\node\
228
345
  & $node "C:\DSH-Work\plugin-autoupdate\pkg\test\all.mjs"
229
346
  ```
230
347
 
231
- 八个套件:
348
+ 十六个套件:
232
349
 
233
350
  | 套件 | 覆盖 |
234
351
  |---|---|
235
352
  | `selftest.mjs` | 版本比较(含 prerelease)、状态规范化夹紧、延迟判定、registry packument 解析、本机 profile 识别 |
236
353
  | `workspace-merge.test.mjs` | `pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude` 合并(同名并成一行、不重复、CRLF 保持、段落连续) |
237
354
  | `api-smoke.test.mjs` | 用假 ctx 真加载 Host 半,走完全部 API 路由;状态写在临时 HOME,不碰真实 profile |
355
+ | `snapshot-regression.test.mjs` | 面板快照结构回归 |
356
+ | `update-guard.test.mjs` | 只向上更新、来源判定、发布年龄门槛 |
357
+ | `git-source.test.mjs` | Git 来源的 commit 比较与安装 spec |
238
358
  | `machine.test.mjs` | 本机标识:继承会话同步的 machineId、固定后不漂移、改名不换特征值、卸载会话同步后旧值仍有效、写边界(别人的格 / `../` 逃逸 / 前缀相近) |
239
359
  | `share.test.mjs` | 特征文件夹:清单分类(registry 只写名 / 本地打副本 / 宿主自带不写)、tgz 结构、同步幂等与副本复用、他人清单合并去重、安装路径选择、特征名防上跳 |
360
+ | `schedule.test.mjs` | 定时排期:更新与共享同步各自独立开关 |
361
+ | `restart-safety.test.mjs` | **重启安全契约**(源码级):不得按映像名杀进程、必须排除自身、必须先等再兜底、助手须由 WMI/两跳送出进程树、须先礼后兵、须有冷却与锁、launcher 须设 `ELECTRON_RUN_AS_NODE` |
362
+ | `pidtree.test.mjs` | **进程树解析**(合成进程表):Host 爬到 main、renderer 爬到同一个 main、起点缺失/父进程缺失/链过深时的边界、只在 Electron 里才爬 |
363
+ | `restart-helper-behavior.test.mjs` | **助手行为**(真子进程):旧 main 自退时零强制动作、不退出时先请求正常退出再强杀、只针对登记的那个 pid、payload 读后即删 |
364
+ | `restart-relaunch.e2e.mjs` | **重启链路**(真进程树):助手父进程不是调用方、整树 `taskkill /T /F` 之后助手仍活着并完成 `relaunched` |
365
+ | `restart-env.e2e.mjs` | **环境传递**:`DSH_PROFILE` / `DSH_HOME` / `PATH` 传到新实例、白名单外的变量不落盘、新实例不带 `ELECTRON_RUN_AS_NODE` |
366
+ | `restart-electron-env.e2e.mjs` | **Electron 助手模式**(真 Electron exe):launcher 能真的让 Electron 以 Node 模式跑脚本 |
367
+ | `restart-electron-env.negative.mjs` | **负向对照**:故意不带 `ELECTRON_RUN_AS_NODE` 时脚本不执行,证明上一条断言不是空的 |
368
+ | `uninstall.test.mjs` | **卸载契约**:未确认不出手、`installation`/不可移除的拒绝、拒绝卸自己与管理器、走 `removeBundle`、失败写 `uninstall-failed`、成功后请求重启;外加前端按钮 / 二次确认 / `canUninstall` 契约 |
240
369
  | `client-smoke.test.mjs` | 在 Node 里用假 `document` / React 求值 `client.js`,验证插槽注册、locale 注册、面板首渲染 |
241
370
 
371
+ 真机取证脚本(不属于 `all.mjs`,需要手工跑):
372
+
373
+ ```powershell
374
+ # 在真宿主进程树里跑一次解析,确认能指出 Electron main
375
+ $env:ELECTRON_RUN_AS_NODE = '1'
376
+ & "C:\DeepSeekHarness\DeepSeek Harness.exe" "C:\DSH-Work\plugin-autoupdate\pkg\test\_live-pidtree.mjs"
377
+ ```
378
+
242
379
  ## 边界
243
380
 
244
- - 只**更新**已装插件,不安装新插件、不卸载插件
381
+ - 只**更新**已装插件,不安装新插件;**卸载仅限 profile 自装且宿主判定 removable 的包**(见「卸载插件」)
245
382
  - 只在 `registry` 来源上工作;`local` / `git` / `installation` 来源只显示、不处理
246
383
  - 更新失败只记历史,不自动回滚(避免把用户自己手改的清单吞掉);要回滚用 `/restore`
247
384
  - DSH 关闭期间不检查(进程内定时器的固有限制);重开后会立刻按上游发布时间重新判定
248
385
  - 共享只**同步清单与副本**,不自动安装别人的插件;安装必须人工点选
249
386
  - 只写 `插件库\<本机标识>\` 那一格,别人的格子只读(写入口有守卫)
250
387
  - 不在插件库根下留任何公共文件或公共目录
388
+ - **重启只认显式 pid**:任何「按映像名清场」的写法都是回归,`restart-safety.test.mjs` 会红
389
+ - **重启助手必须活在进程树之外**:`spawn(detached)` 逃不掉 `taskkill /T`(实测),
390
+ 任何退回「直接 spawn 助手」的改动都会让 0.9.0 的故障复活
391
+ - **要结束的是 Electron main**:插件里的 `process.pid` 是 Host,
392
+ 只杀 Host 等于没重启。目标 pid 只能来自 `lib/pidtree.js` 的解析结果或调用方显式登记
393
+ - **没有可信 pid 就只等不杀**:绝不回落到 `process.pid` / `process.ppid`
394
+ - **launcher 必须设 `ELECTRON_RUN_AS_NODE=1`**:否则 Electron 把助手脚本当 App 启动
251
395
 
252
396
  ## 踩过的坑
253
397
 
package/client.js CHANGED
@@ -26,6 +26,8 @@ const CSS = `
26
26
  .pau-btn:disabled{opacity:.45;cursor:not-allowed}
27
27
  .pau-btn-primary{background:var(--dsw-alias-brand-primary,#2563eb);border-color:transparent;color:#fff}
28
28
  .pau-btn-sm{padding:3px 10px;font-size:12px}
29
+ .pau-btn-danger{border-color:var(--dsw-alias-border-danger,#e5484d);color:var(--dsw-alias-label-danger,#c62a2f)}
30
+ .pau-btn-danger:hover:not(:disabled){background:var(--dsw-alias-bg-danger,#fef2f2)}
29
31
  .pau-input{font:inherit;font-size:13px;padding:6px 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111);min-width:0;box-sizing:border-box}
30
32
  .pau-input-num{width:76px}
31
33
  .pau-select{font:inherit;font-size:13px;padding:6px 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#111)}
@@ -179,7 +181,9 @@ function makePanel(React) {
179
181
 
180
182
  const plugins = Array.isArray(snap.plugins) ? snap.plugins : [];
181
183
  const registryPlugins = plugins.filter((p) => p.kind === 'registry');
182
- const otherPlugins = plugins.filter((p) => p.kind !== 'registry');
184
+ // git 与 registry 一样参与自动更新,只是比的是 commit 而不是版本号
185
+ const gitPlugins = plugins.filter((p) => p.kind === 'git');
186
+ const otherPlugins = plugins.filter((p) => p.kind !== 'registry' && p.kind !== 'git');
183
187
  const available = plugins.filter((p) => p.updateAvailable);
184
188
  const ready = available.filter((p) => !p.waitMs);
185
189
 
@@ -321,6 +325,7 @@ function makePanel(React) {
321
325
  );
322
326
 
323
327
  const pluginRow = (plugin) => {
328
+ const isGit = plugin.kind === 'git';
324
329
  const tone = plugin.updateAvailable
325
330
  ? plugin.waitMs
326
331
  ? 'pau-tag-warn'
@@ -333,7 +338,21 @@ function makePanel(React) {
333
338
  : plugin.latestError
334
339
  ? '查询失败'
335
340
  : '已是最新';
336
- const showLatest = plugin.latest && plugin.latest !== plugin.version;
341
+ // git 来源没有版本号可比:当前版本只认 commit,「最新」也只写短 sha
342
+ const currentCommit = plugin.git && plugin.git.commit ? String(plugin.git.commit).slice(0, 10) : '';
343
+ const showLatest = isGit
344
+ ? Boolean(plugin.latest && plugin.latest !== currentCommit)
345
+ : Boolean(plugin.latest && plugin.latest !== plugin.version);
346
+ const latestLabel = isGit ? `远端 ${plugin.latest}` : `最新 v${plugin.latest}`;
347
+ // npm 上有同名包才算「能切换」;查不到就明确说没有,免得用户反复猜
348
+ const migration = plugin.npmMigration;
349
+ const canMigrate = !isGit && plugin.kind === 'local' && migration && migration.available;
350
+ const migrationNote =
351
+ !isGit && plugin.kind === 'local' && migration
352
+ ? migration.available
353
+ ? `npm 上有同名包 ${migration.latest}${migration.upgradable ? '(更高)' : ''},可切换来源`
354
+ : 'npm 上没有同名包,保持原来源'
355
+ : '';
337
356
  return h(
338
357
  'div',
339
358
  { className: 'pau-row', key: plugin.name },
@@ -344,15 +363,21 @@ function makePanel(React) {
344
363
  'div',
345
364
  { className: 'pau-row-t' },
346
365
  h('span', { className: 'pau-mono' }, plugin.name),
366
+ isGit ? h('span', { className: 'pau-tag' }, 'Git') : null,
347
367
  h('span', { className: 'pau-tag' }, plugin.version ? `v${plugin.version}` : '未装'),
348
- showLatest ? h('span', { className: 'pau-tag' }, `最新 v${plugin.latest}`) : null,
368
+ isGit && currentCommit ? h('span', { className: 'pau-tag' }, currentCommit) : null,
369
+ showLatest ? h('span', { className: 'pau-tag' }, latestLabel) : null,
349
370
  h('span', { className: `pau-tag ${tone}` }, stateText),
350
371
  ),
351
372
  plugin.latestError
352
373
  ? h('div', { className: 'pau-row-d' }, `查询失败:${plugin.latestError}`)
353
- : plugin.description
354
- ? h('div', { className: 'pau-row-d' }, plugin.description.slice(0, 160))
355
- : null,
374
+ : isGit && plugin.latestMessage
375
+ ? h('div', { className: 'pau-row-d' }, `${plugin.latestTag ? `${plugin.latestTag} · ` : ''}${plugin.latestMessage}`)
376
+ : migrationNote
377
+ ? h('div', { className: 'pau-row-d' }, migrationNote)
378
+ : plugin.description
379
+ ? h('div', { className: 'pau-row-d' }, plugin.description.slice(0, 160))
380
+ : null,
356
381
  ),
357
382
  h(
358
383
  'select',
@@ -387,16 +412,42 @@ function makePanel(React) {
387
412
  {
388
413
  className: 'pau-btn pau-btn-sm',
389
414
  disabled: !!busy || !plugin.updateAvailable,
415
+ title: isGit ? '安装远端最新提交' : '安装最新版本',
390
416
  onClick: () =>
391
417
  run(`update:${plugin.name}`, async () => {
392
- const next = await call('/update', { name: plugin.name, version: plugin.latest });
418
+ const body = isGit
419
+ ? { name: plugin.name, commit: plugin.latestCommit || '' }
420
+ : { name: plugin.name, version: plugin.latest };
421
+ const next = await call('/update', body);
393
422
  if (next.restart) setNote(next.restart.message || '已安排重启');
423
+ else if (next.result && next.result.ok === false) setNote(next.result.message || '未执行');
394
424
  else setNote(`${plugin.name} 已更新`);
395
425
  return next;
396
426
  }),
397
427
  },
398
428
  busy === `update:${plugin.name}` ? '更新中…' : '立即更新',
399
429
  ),
430
+ canMigrate
431
+ ? h(
432
+ 'button',
433
+ {
434
+ className: 'pau-btn pau-btn-sm',
435
+ disabled: !!busy,
436
+ title: `改用 npm 来源(${migration.latest}),之后就能按版本号自动更新`,
437
+ onClick: () =>
438
+ run(`migrate:${plugin.name}`, async () => {
439
+ const next = await call('/migrate', { name: plugin.name });
440
+ setNote(
441
+ next.result && next.result.ok
442
+ ? `${plugin.name} 已切换到 npm ${next.result.latest}`
443
+ : (next.result && next.result.message) || '切换未完成',
444
+ );
445
+ return next;
446
+ }),
447
+ },
448
+ busy === `migrate:${plugin.name}` ? '切换中…' : '切换到 npm',
449
+ )
450
+ : null,
400
451
  h(
401
452
  'button',
402
453
  {
@@ -418,6 +469,45 @@ function makePanel(React) {
418
469
  },
419
470
  '刷新',
420
471
  ),
472
+ h(
473
+ 'button',
474
+ {
475
+ className: 'pau-btn pau-btn-sm pau-btn-danger',
476
+ // 只有宿主明确说 removable 才让点:信息未知时宁可置灰,也不让用户点了才发现不行
477
+ disabled: !!busy || !plugin.canUninstall,
478
+ title: plugin.canUninstall
479
+ ? `从 profile 卸载 ${plugin.name}(会先二次确认;卸载后自动重启生效)`
480
+ : plugin.uninstallBlockedReason || '不能卸载',
481
+ onClick: () => {
482
+ if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
483
+ const ok = window.confirm(
484
+ `确定要卸载 ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''} 吗?\n\n` +
485
+ '这会把它从 profile 的依赖与插件清单里移除,并自动重启 DSH 才生效。\n' +
486
+ '(卸载前会自动备份 profile 的 package.json / pnpm-lock.yaml / pnpm-workspace.yaml)',
487
+ );
488
+ if (!ok) {
489
+ setNote('已取消卸载');
490
+ return Promise.resolve(null);
491
+ }
492
+ }
493
+ return run(`uninstall:${plugin.name}`, async () => {
494
+ const next = await call('/uninstall', { name: plugin.name, confirm: true });
495
+ const result = next.result || {};
496
+ if (result.ok) {
497
+ setNote(
498
+ result.restart && result.restart.ok
499
+ ? `${plugin.name} 已卸载,正在重启生效`
500
+ : `${plugin.name} 已卸载;请手动完全退出 DSH 再重开`,
501
+ );
502
+ } else {
503
+ setNote(result.message || '卸载未完成');
504
+ }
505
+ return next;
506
+ });
507
+ },
508
+ },
509
+ busy === `uninstall:${plugin.name}` ? '卸载中…' : '卸载',
510
+ ),
421
511
  );
422
512
  };
423
513
 
@@ -427,42 +517,19 @@ function makePanel(React) {
427
517
  h(
428
518
  'div',
429
519
  { className: 'pau-card-h' },
430
- h('h3', { className: 'pau-card-t' }, `已装插件(${registryPlugins.length})`),
520
+ h('h3', { className: 'pau-card-t' }, `已装插件(${registryPlugins.length + gitPlugins.length})`),
431
521
  h('span', { className: 'pau-row-d' }, available.length ? `${available.length} 个可更新` : '全部最新'),
432
522
  ),
433
- registryPlugins.length
434
- ? registryPlugins.map(pluginRow)
523
+ registryPlugins.length + gitPlugins.length
524
+ ? registryPlugins.concat(gitPlugins).map(pluginRow)
435
525
  : h('div', { className: 'pau-empty' }, '没有可联网检查的插件'),
436
526
  otherPlugins.length
437
527
  ? h(
438
528
  'div',
439
529
  { style: { marginTop: 12 } },
440
- h('div', { className: 'pau-row-d' }, '本地路径 / Git 来源 / 随安装提供(不参与版本检查)'),
530
+ h('div', { className: 'pau-row-d' }, '本地路径 / 随安装提供(不参与版本检查)'),
441
531
  otherPlugins.map((plugin) =>
442
- h(
443
- 'div',
444
- { className: 'pau-row', key: plugin.name },
445
- h(
446
- 'div',
447
- { className: 'pau-row-main' },
448
- h(
449
- 'div',
450
- { className: 'pau-row-t' },
451
- h('span', { className: 'pau-mono' }, plugin.name),
452
- h(
453
- 'span',
454
- { className: 'pau-tag' },
455
- plugin.kind === 'git'
456
- ? 'Git'
457
- : plugin.kind === 'local'
458
- ? '本地'
459
- : '随安装提供',
460
- ),
461
- plugin.version ? h('span', { className: 'pau-tag' }, `v${plugin.version}`) : null,
462
- ),
463
- h('div', { className: 'pau-row-d' }, plugin.spec || '由 DSH 安装提供,版本跟随宿主'),
464
- ),
465
- ),
532
+ pluginRow(plugin),
466
533
  ),
467
534
  )
468
535
  : null,