@noob-stupid/dsh-plugin-console 0.3.25 → 0.3.27

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
@@ -20,20 +20,25 @@
20
20
 
21
21
 
22
22
 
23
- > ## 🎉 v0.3.24 — AI Empower released (2026-08-31)
23
+ > ## 🎉 v0.3.27 released — family-bundle cards, never-crash safety & adapt-gate hardening (2026-09-04)
24
24
  >
25
- > Give the Plugin Console a package name or GitHub repo: the local AI reads its docs,
26
- > drafts a **deployment plan** (install / write config / start service / health check),
27
- > and — after your confirmation — executes it safely. Server-type components get an
28
- > automatic control card (start/stop/status/open + dropdown). An OpenViking template
29
- > deploys a local memory server in one flow.
25
+ > **Family-bundle cards**: same-root subpath exports (e.g. `@linxin666/dsh-web-all/settings`) auto-group
26
+ > into one family card at the bottom — collapse/expand, batch check-updates, **one-click unlock-adapted**,
27
+ > known-check preview, `web-all/`-prefix-free sub-cards.
28
+ > **Never-crash safety**: pre-enable import probe (child-process) + patch auto-heal (core-row restore &
29
+ > missing-module auto-disable) + exports-fallback `package.json` resolution — the memory-plugin
30
+ > false-disable incident is fixed.
31
+ > **Adapt-gate hardening**: source-scan hard criterion (post-0.3.6 false-pass), migrate detection
32
+ > (`dsh.migrate.to`), softened dependency-strict judgment, pending hints moved into details.
33
+ > **Safe sub-package delete**: deleting a bundle sub-row disables only that row and keeps the bundle —
34
+ > fixing the incident where deleting one sub-package removed the whole family bundle.
30
35
  >
31
36
  > **Install:**
32
37
  > ```bash
33
38
  > npm i -g @noob-stupid/dsh-plugin-console
34
39
  > # or in DSH: dsh plugin --profile web add @noob-stupid/dsh-plugin-console
35
40
  > ```
36
- > **Try it:** Plugin Console → **AI 赋能** button → enter e.g. `OpenViking` → confirm the plan.
41
+ > **Try it:** Plugin Console → family card at the bottom → 「批量检测更新 / 一键启用已适配」.
37
42
  >
38
43
  > Feedback: [GitHub Issues](https://github.com/Noob-stupid/dsh-plugin-hub/issues).
39
44
  A **plugin management panel** for the DeepSeek Harness (DSH) Web GUI: one-click
@@ -82,6 +87,8 @@ auto-rollback).
82
87
  | | Benefit | Detail |
83
88
  |---|---|---|
84
89
  | 🤖 | **AI Empower (v0.3.24)** | Give the console a package name or GitHub repo — the local AI reads docs, drafts a **deployment plan** (install / write config / start service / health check) and executes it safely after your confirmation; server-type components get an automatic control card |
90
+ | 🛡️ | **Framework upgrade safety & adapt gate (v0.3.25)** | Force-disables plugins incompatible with the new framework (enable locked; 「Check update → Update & adapt」auto-verifies and unlocks); full-tree checkpoint before upgrade, automatic rollback on relaunch failure, one-click rollback to the previous version |
91
+ | 🏠 | **Family-bundle cards & safety (v0.3.27)** | Same-root subpath exports group into one family card (collapse/expand, batch check-update, one-click unlock-adapted, known-check preview); never-crash safety (pre-enable import probe + patch auto-heal + exports fallback); adapt-gate source-scan hard criterion & migrate detection; deleting a bundle sub-row disables only that row |
85
92
  | 🚀 | **Server component cards** | Left-side floating card auto-aligned to the main panel: start / stop / status / **open Web UI** buttons, multi-server dropdown, collapsible |
86
93
  | 🧩 | **Plugin & skill hub** | Auto-collected index of `dsh-plugin` topic repos (**500+** by stars) plus a **Skills tab** (`agent-skills` ∪ `claude-skills` ∪ `dsh-skill`, up to 300) — browse, search, one-click install, no GitHub API calls |
87
94
  | 🤖 | **Auto-collection CI** | GitHub Actions reruns `build-index` every 6 hours (manual trigger available); authors just add the `dsh-plugin` / `agent-skills` / `claude-skills` / `dsh-skill` topic — no application needed |
@@ -246,6 +253,47 @@ Requires: DSH ≥ 0.1.0-rc.6 (web profile, with `dsh-client-modules` / `dsh-host
246
253
  - **Card dismiss semantics**: terminal states (done/failed) are permanently dismissed on ✕
247
254
  (persisted); in-progress dismissal is session-only and the card returns after a refresh.
248
255
 
256
+ ### Framework upgrade safety & plugin adapt gate (v0.3.25)
257
+
258
+ > Origin: the 2026-09-04 DSH `0.1.1-rc.2 → 0.1.2-rc.1` incident — the upgrade script ran in
259
+ > the wrong working directory, overwrote the new CLI into the old `.pnpm` entry, left a
260
+ > mixed-version tree, and the service could not start. This version makes upgrade safety
261
+ > and plugin compatibility detection first-class console mechanisms.
262
+
263
+ **Upgrade safety trio**
264
+
265
+ 1. **Framework root detection**: locates the real install root (the top-level `node_modules`
266
+ containing `.pnpm`), fixing the `require.resolve`-returns-`.pnpm`-realpath root cause
267
+ (relink no-op / new package overwriting the old tree); refuses to upgrade when the root
268
+ cannot be identified (service stays running);
269
+ 2. **Full-tree checkpoint before upgrade**: mirrors every `@deepseek-ai` version package in
270
+ `.pnpm` + the top-level scope + `lock.yaml`
271
+ (under `~/.dsh/plugin-console/framework-backups/<version>/fw-tree/`);
272
+ 3. **Automatic full-tree rollback + retry**: install failure or relaunch failure restores
273
+ the pre-upgrade tree and relaunches automatically — no more "please run it manually"
274
+ dead-ends; after rollback, relink/repair steps are skipped to avoid damaging the
275
+ restored tree a second time.
276
+
277
+ **One-click rollback**: after an upgrade the framework card shows a **「回滚到上一版」** button
278
+ (`/plugin-console/framework-rollback`): stop service → restore full tree → relaunch →
279
+ health check, state visible throughout.
280
+
281
+ **Plugin adapt gate (compatibility detection)**
282
+
283
+ - After an upgrade, plugin entries recorded in `~/.dsh/plugin-console/compat-pending.json`
284
+ are **force-disabled**; the enable button is locked and the server rejects `/toggle` with
285
+ 409 (unbypassable). The enabled-button label itself shows「待适配」as the state hint;
286
+ - The **details** panel explains the flow: **Check update → Update & adapt**;
287
+ - **Post-update auto-verification**: scans the latest `package.json` for an explicit
288
+ `dsh.engines.framework` / `engines.dsh` declaration plus `@deepseek-ai/*` dependency
289
+ ranges (built-in zero-dependency semver engine — strict npm prerelease rules for
290
+ dependencies, loose for explicit declarations); if the version changed and no
291
+ incompatibility was found, the `disabled` block is **removed automatically** and the row
292
+ unlocks; aggregator updates validate all synced subpackages too;
293
+ - **AI Empower pre-check**: during planning the server pre-checks the registry's latest
294
+ declaration and adapt-gate hits, injects them into the subagent prompt (the plan must
295
+ explain the conclusion to the user) and marks it on the plan panel (red for incompatible).
296
+
249
297
  ### Marketplace (multi-source)
250
298
 
251
299
  - **Source switcher**: click the login pill to switch between **GitHub / Gitee / custom
@@ -349,6 +397,10 @@ GitHub Actions (every 6h, repo token)
349
397
  - Supports the **DSH 0.1.0 series** (`0.1.0-rc.6` and siblings).
350
398
  - The panel reads the running `@deepseek-ai/dsh-web-app` version: after a breaking upgrade
351
399
  (0.2 / 1.0) a compatibility warning appears instead of silent failure.
400
+ - **Plugin adapt gate (v0.3.25)**: third-party plugins incompatible with the current
401
+ framework are force-disabled after an upgrade (enable locked); run 「Check update」to
402
+ upgrade the plugin first — it unlocks automatically after the auto-verification passes.
403
+ See 「Framework upgrade safety & plugin adapt gate」.
352
404
  - Likely breaking seams: patch semantics, `webServer.register`, loader entry shape,
353
405
  `dsh.client` bundle format, `settings.plugins.tab` slot.
354
406
  - Deploy scripts do not check versions; the in-panel warning is authoritative.
@@ -390,6 +442,11 @@ test-harness.mjs Logic self-test (state/toggle/validation/loopback; search SKI
390
442
  | `/plugin-console/gitee-oauth-url` / `gitee-oauth-callback` | GET | Gitee OAuth flow |
391
443
  | `/plugin-console/ai-consent` | POST | Approve/cancel the AI-fallback step |
392
444
  | `/plugin-console/restart` | POST | Watchdog-safe self-restart (panel button equivalent) |
445
+ | `/plugin-console/framework-upgrade` | POST | One-click framework upgrade (full-tree checkpoint → online install → rollback protection) |
446
+ | `/plugin-console/framework-rollback` | POST | One-click rollback to the previous version (stop → full-tree restore → auto relaunch → health check) |
447
+ | `/plugin-console/self-update` | POST | Update the console itself (latest npm tarball into the profile) |
448
+ | `/plugin-console/ai-empower/plan` / `status` / `run` / `cancel` | POST | AI Empower: plan (with framework compat pre-check) → status polling → confirmed run → cancel |
449
+ | `/plugin-console/components` / `component/start` / `stop` / `status` | GET/POST | Server component registry and start/stop/status |
393
450
 
394
451
  ---
395
452
 
package/README.zh.md CHANGED
@@ -6,18 +6,23 @@
6
6
 
7
7
  # DSH 插件中心(dsh-plugin-hub)
8
8
 
9
- > ## 🎉 v0.3.24 正式发布 — AI 赋能一键部署(2026-08-31)
9
+ > ## 🎉 v0.3.27 正式发布 — 全家桶分组卡片、永不崩安全与适配门强化(2026-09-04)
10
10
  >
11
- > 在插件控制台输入 npm 包名或 GitHub 仓库,本地 AI 读取文档自动生成**部署计划**,
12
- > 你确认后安全执行;服务器类组件自动生成控制卡片(启动/停止/状态/打开 + 下拉)。
13
- > 内置 OpenViking 模板,一条指令部署本地记忆服务器。
11
+ > **全家桶分组卡片**:同根包子路径导出(如 `@linxin666/dsh-web-all/settings`)自动聚合为一张
12
+ > 全家桶卡片(列表底部)——收起/展开、批量检测更新、**一键启用已适配**、已知校验预览,子卡剥离
13
+ > `web-all/` 前缀。
14
+ > **永不崩安全**:启用前子进程导入探针 + 补丁自动自愈(核心行恢复/缺失模块自动禁用)+
15
+ > exports 回退 `package.json` 解析——记忆插件被误禁用的事故已修复。
16
+ > **适配门强化**:源码扫描硬判据(0.3.6 假通过教训)、迁移检测(`dsh.migrate.to`)、
17
+ > 依赖严格判定软化、待适配提示移入详情。
18
+ > **子包删除安全**:删除聚合包子行仅停用该行、不动 bundle——修复"删一个子包导致整个全家桶消失"的事故。
14
19
  >
15
20
  > **安装:**
16
21
  > ```bash
17
22
  > npm i -g @noob-stupid/dsh-plugin-console
18
- > # 或在 DSH 中更新:dsh plugin --profile web add @noob-stupid/dsh-plugin-console
23
+ > # 或在 DSH 中:dsh plugin --profile web add @noob-stupid/dsh-plugin-console
19
24
  > ```
20
- > **体验入口:** 插件控制台 → 右上角 **AI 赋能** 按钮 → 输入如 `OpenViking` → 确认计划 → 自动部署。
25
+ > **体验入口:** 插件控制台 → 列表底部全家桶卡片 → 批量检测更新 / 一键启用已适配。
21
26
  >
22
27
  > 反馈:[GitHub Issues](https://github.com/Noob-stupid/dsh-plugin-hub/issues)。
23
28
 
@@ -75,6 +80,8 @@
75
80
  | | 优势 | 说明 |
76
81
  |---|---|---|
77
82
  | 🤖 | **AI 赋能(v0.3.24)** | 输入 npm 包名 / GitHub 仓库,本地 AI 读文档生成**部署计划**(安装/写配置/启动服务/健康检查),你确认后安全执行;服务器类组件自动生成控制卡片 |
83
+ | 🛡️ | **框架升级适配门与安全(v0.3.25)** | 升级后不兼容插件强制禁用(适配门锁定,「检测更新 → 更新并适配 → 自动校验解锁」);升级前全树 checkpoint、拉起失败自动全树回滚、框架卡片一键回滚到上一版 |
84
+ | 🏠 | **全家桶分组卡片与安全(v0.3.27)** | 同根包子路径导出自动聚合为一张全家桶卡片(收起/展开、批量检测更新、一键启用已适配、已知校验预览);永不崩安全(启用前导入探针 + 补丁自愈 + exports 回退);适配门源码扫描硬判据与迁移检测;删除聚合包子行仅停用该行 |
78
85
  | 🚀 | **服务器组件卡片** | 左侧浮卡与主面板顶边对齐:启动 / 停止 / 状态 / 【打开】直达 Web UI,多服务器下拉、可折叠(状态记忆) |
79
86
  | 🧩 | **插件 + 技能双市场** | 自动收录 `dsh-plugin` topic 仓库(按 star **500+**),另有**技能 tab**(`agent-skills` ∪ `claude-skills` ∪ `dsh-skill`,最多 300)——浏览、搜索、一键安装,零 GitHub API 调用;插件自带技能(如 openviking-memory)只读展示 |
80
87
  | 🤖 | **自动收录 CI** | GitHub Actions 每 6 小时自动重跑 `build-index`(也支持手动触发);作者只需给自己的仓库打 `dsh-plugin` / `agent-skills` / `claude-skills` / `dsh-skill` 标签,**无需申请、无需审核** |
@@ -227,6 +234,37 @@ git clone https://github.com/Noob-stupid/dsh-plugin-hub /tmp/dsh-plugin-console
227
234
  - **卡片关闭语义**:终态(成功/失败)点叉号永久关闭(持久化);进行中点叉号仅本次会话
228
235
  隐藏,刷新后恢复显示。
229
236
 
237
+ ### 框架升级适配门与升级安全(v0.3.25)
238
+
239
+ > 缘起:2026-09-04 DSH `0.1.1-rc.2 → 0.1.2-rc.1` 升级事故——升级脚本在错误的工作目录执行,
240
+ > 把新 CLI 原位覆盖进旧 `.pnpm` 目录、依赖树混版本,服务无法拉起。本版本把「升级安全」与
241
+ > 「插件兼容检测」固化为控制台机制。
242
+
243
+ **升级安全三件套**
244
+
245
+ 1. **框架安装根识别**:定位真正的框架安装根(含 `.pnpm` 的顶层 `node_modules`),修复
246
+ `require.resolve` 返回 `.pnpm` 内部 realpath 导致的「重链空转 / 新包覆盖旧目录」根因;
247
+ 定位不到时**拒绝升级**(不 kill 服务、服务保持运行);
248
+ 2. **升级前全树 checkpoint**:镜像 `.pnpm` 全部 `@deepseek-ai` 版本包 + 顶层 scope +
249
+ `lock.yaml`(写入 `~/.dsh/plugin-console/framework-backups/<版本>/fw-tree/`);
250
+ 3. **拉起失败自动全树回滚并重试**:安装失败或服务拉起失败都会自动恢复升级前全树并再次拉起,
251
+ 不再只留「请手动运行」提示;回滚后跳过重链/修复(避免对已恢复旧树二次破坏)。
252
+
253
+ **一键回滚**:升级后框架卡片出现「回滚到上一版」按钮(`/plugin-console/framework-rollback`):
254
+ 停服 → 全树恢复 → 自动拉起 → 健康检测,全程状态可见。
255
+
256
+ **插件适配门(插件检测)**
257
+
258
+ - 升级后写入 `~/.dsh/plugin-console/compat-pending.json` 的插件行被**强制禁用**,启用按钮
259
+ 锁定(服务端 `/toggle` 直接 409,无法绕过);行状态以按钮文案「待适配」提示;
260
+ - 「详情」面板给出说明与入口:**检测更新 → 更新并适配**;
261
+ - **更新后自动校验**:扫描最新版 `package.json` 的 `dsh.engines.framework` / `engines.dsh`
262
+ 显式声明 + `@deepseek-ai/*` 依赖范围(内置零依赖 semver 判定器:依赖判定遵循 npm
263
+ prerelease 严格规则、声明判定宽松);版本已变化且未判定不兼容 → **自动移除禁用块解锁**;
264
+ 聚合包更新连带校验同步的子包;
265
+ - **AI 赋能适配预检**:规划时服务端预检 registry 最新版声明与适配门命中情况,注入子代理
266
+ 提示词(要求计划向用户解释适配结论),并在计划面板标注(不兼容标红)。
267
+
230
268
  ### 插件市场(多搜索源)
231
269
 
232
270
  - **搜索源切换**:点击顶部登录态标识弹出菜单,在 **GitHub / Gitee / 自定义源** 间切换(选择持久化);
@@ -323,6 +361,9 @@ GitHub Actions(每 6 小时,仓库自带 token)
323
361
  - 当前支持 **DSH 0.1.0 系列**(`0.1.0-rc.6` 及同系列版本)。
324
362
  - 面板会读取运行中的 `@deepseek-ai/dsh-web-app` 版本:官方发布破坏性升级
325
363
  (0.2 / 1.0 等)后,面板顶部会显示兼容性警告并给出本仓库地址,而不是默默失效。
364
+ - **插件适配门(v0.3.25)**:框架升级后不兼容的第三方插件会被**强制禁用**(启用按钮锁定);
365
+ 请先「检测更新」升级该插件,更新并通过自动校验后才会解锁——详见
366
+ 「框架升级适配门与升级安全」。
326
367
  - 官方破坏性更新可能改动的接口:补丁层语义、`webServer.register`、
327
368
  加载器条目结构、`dsh.client` bundle 格式、`settings.plugins.tab` 插槽。
328
369
  届时随官方版本更新本仓库即可(依赖面已收窄到上述几个点)。
@@ -341,6 +382,7 @@ scripts/apply-framework-patch.cjs 框架层补丁(issue #5,幂等,可重
341
382
  marketplace/index.json 生成的静态索引(jsDelivr CDN 分发)
342
383
  deploy.ps1 / deploy.sh 一键部署脚本(Windows / Linux·macOS)
343
384
  test-harness.mjs 逻辑自检(state/toggle/校验/环回保护;搜索视网络环境 SKIP)
385
+ test-compat-gate.mjs 适配门单元测试(内置 semver 判定器:声明/依赖兼容规则)
344
386
  ```
345
387
  <img width="1878" height="945" alt="image" src="https://github.com/user-attachments/assets/b26f2f19-0ba4-4be7-9ca1-b3fd4c51a7a8" />
346
388
 
@@ -365,6 +407,11 @@ test-harness.mjs 逻辑自检(state/toggle/校验/环回保护;搜索视
365
407
  | `/plugin-console/gitee-oauth-url` / `gitee-oauth-callback` | GET | Gitee OAuth 流程 |
366
408
  | `/plugin-console/ai-consent` | POST | 同意/取消 AI 兜底步骤 |
367
409
  | `/plugin-console/restart` | POST | 自带守护的安全自重启(等同面板按钮) |
410
+ | `/plugin-console/framework-upgrade` | POST | 框架一键升级(全树 checkpoint → 在线安装 → 全树回滚保护) |
411
+ | `/plugin-console/framework-rollback` | POST | 一键回滚到升级前版本(停服 → 全树恢复 → 自动拉起 → 健康检测) |
412
+ | `/plugin-console/self-update` | POST | 控制台自身一键更新(npm 最新 tarball 到当前 profile) |
413
+ | `/plugin-console/ai-empower/plan` / `status` / `run` / `cancel` | POST | AI 赋能:规划(含框架适配预检)→ 状态轮询 → 确认执行 → 取消 |
414
+ | `/plugin-console/components` / `component/start` / `stop` / `status` | GET/POST | 服务器组件清单与启动/停止/状态 |
368
415
 
369
416
  ---
370
417
 
package/lib/client.js CHANGED
@@ -104,6 +104,21 @@ window.__ModuleLoader__.load({
104
104
  pendingCompatTag: "待适配",
105
105
  pendingCompatHint: "框架升级后被强制禁用:先点「检测更新」,如有新版按钮会变为「更新并适配」,点击后更新完成自动校验通过即解锁启用",
106
106
  updateAndAdapt: "更新并适配",
107
+ familyTag: "全家桶",
108
+ familySubs: "子包",
109
+ familyUnlockAll: "一键启用已适配",
110
+ familyUnlockAllTip: "对全家桶内所有源码扫描通过(已适配)的子包批量解锁启用;未通过的保持禁用",
111
+ familyUnlockAllConfirm: "将对全家桶内所有已适配(源码扫描通过)的子包一键解锁启用?未通过的将保持禁用。",
112
+ familyUnlockAllDone: "已批量适配解锁",
113
+ familyExpand: "展开",
114
+ familyCollapse: "收起",
115
+ familyUpdate: "全家桶版本检测",
116
+ familyCheckUpdate: "批量检测更新",
117
+ adaptUnlockBtn: "已适配,立即解锁",
118
+ adaptUnlockHint: "已安装版本经源码扫描确认适配当前框架:立即移除禁用块并解锁启用",
119
+ adaptUnlockDone: "已适配解锁:刷新后该行可正常启用",
120
+ migrateTip: "项目已迁移(适配当前框架)",
121
+ migrateBtn: "迁移并适配",
107
122
  aiEmpowerFrameworkCheck: "框架适配检测",
108
123
  userForcedTag: "补丁强制启用",
109
124
  protectedTag: "受保护",
@@ -404,6 +419,21 @@ window.__ModuleLoader__.load({
404
419
  pendingCompatTag: "Pending adapt",
405
420
  pendingCompatHint: "Force-disabled after framework upgrade: click Check update first; if a newer version exists the button becomes Update & adapt - click it and it unlocks automatically after the compat check",
406
421
  updateAndAdapt: "Update & adapt",
422
+ familyTag: "Family bundle",
423
+ familySubs: "sub packages",
424
+ familyUnlockAll: "Unlock all adapted",
425
+ familyUnlockAllTip: "Batch-unlock every sub package in this family whose source scan passed; failed ones stay disabled",
426
+ familyUnlockAllConfirm: "Unlock all adapted (source-scan passed) sub packages in this family? Failed ones stay disabled.",
427
+ familyUnlockAllDone: "Family batch unlock done",
428
+ familyExpand: "Expand",
429
+ familyCollapse: "Collapse",
430
+ familyUpdate: "Family version check",
431
+ familyCheckUpdate: "Check all",
432
+ adaptUnlockBtn: "Adapted - unlock now",
433
+ adaptUnlockHint: "Installed version passed the source scan for the current framework: unlock immediately",
434
+ adaptUnlockDone: "Unlocked: refresh and the row is enabled",
435
+ migrateTip: "Project migrated (adapted to the current framework)",
436
+ migrateBtn: "Migrate & adapt",
407
437
  aiEmpowerFrameworkCheck: "Framework compat check",
408
438
  userForcedTag: "Forced on by patch",
409
439
  protectedTag: "Protected",
@@ -971,10 +1001,12 @@ window.__ModuleLoader__.load({
971
1001
  function PluginConsoleTab({ t }) {
972
1002
  const [state, setState] = react.useState({ status: "loading" });
973
1003
  const [busy, setBusy] = react.useState(null);
1004
+ const [adaptUnlocking, setAdaptUnlocking] = react.useState(null);
974
1005
  const [message, setMessage] = react.useState(null);
975
1006
  const [reloadHint, setReloadHint] = react.useState(false);
976
1007
  const [restartHint, setRestartHint] = react.useState(false);
977
1008
  const [jobs, setJobs] = react.useState({});
1009
+ const [familyCollapsed, setFamilyCollapsed] = react.useState({});
978
1010
  const [query, setQuery] = react.useState(() => {
979
1011
  try { return localStorage.getItem("pc-market-query") ?? ""; } catch { return ""; }
980
1012
  });
@@ -1396,7 +1428,29 @@ window.__ModuleLoader__.load({
1396
1428
  // 挂载时加载静态插件索引(GitHub 源空查询时秒开浏览,零 API 调用)
1397
1429
  react.useEffect(() => {
1398
1430
  call("/plugin-console/market-index").then(
1399
- (data) => { if (data && Array.isArray(data.items)) setIndexData({ items: data.items, skills: Array.isArray(data.skills) ? data.skills : [], loaded: true }); },
1431
+ (data) => {
1432
+ if (data && Array.isArray(data.items)) {
1433
+ setIndexData({ items: data.items, skills: Array.isArray(data.skills) ? data.skills : [], loaded: true });
1434
+ // ★「只看官方」需要官方标记:索引条目补标(网络/服务端此前判定失败时不动已有标记,避免 null 覆盖)
1435
+ // 服务端 /enrich 已带 24h 结果缓存时,此处仅兜底浏览器直连失败部分。
1436
+ const base = data.items;
1437
+ enrichOfficialBundle(base).then((enriched) => {
1438
+ if (!Array.isArray(enriched)) return;
1439
+ const merged = enriched.map((e, i) => {
1440
+ const b = base[i] ?? {};
1441
+ return {
1442
+ ...e,
1443
+ official: e.official !== null ? e.official : (b.official ?? null),
1444
+ aggregate: e.aggregate === true || b.aggregate === true,
1445
+ aggregateInstallable: b.aggregateInstallable === true,
1446
+ hasSkill: e.hasSkill === true || b.hasSkill === true,
1447
+ hasSuite: e.hasSuite === true || b.hasSuite === true,
1448
+ };
1449
+ });
1450
+ setIndexData((prev) => (prev !== null && prev.loaded === true ? { ...prev, items: merged } : prev));
1451
+ });
1452
+ }
1453
+ },
1400
1454
  () => {},
1401
1455
  );
1402
1456
  // 市场状态记忆:关闭前搜索过 → 重开时自动恢复该搜索(query 已从 localStorage 初始化)
@@ -2217,13 +2271,31 @@ window.__ModuleLoader__.load({
2217
2271
  : ((searchSourcesList !== null ? searchSourcesList.find((s) => s.id === source)?.name : undefined) ?? source);
2218
2272
  const extraIds = new Set((state.status === "ready" ? state.data.patch?.inserts ?? [] : []));
2219
2273
  const extraCount = state.status === "ready" ? state.data.entries.filter((entry) => entry.extra === true || extraIds.has(entry.rowId)).length : 0;
2220
- const rows = state.status === "ready"
2274
+ const pkgRootOf = (m) => { const mm = String(m ?? ""); const x = mm.match(/^(@[^/]+\/[^/]+)(?:\/.*)?$/u); return x ? x[1] : mm; };
2275
+ const filteredEntries = state.status === "ready"
2221
2276
  ? state.data.entries.filter((entry) => {
2222
2277
  if (extraOnly && !(entry.extra === true || extraIds.has(entry.rowId))) return false;
2223
2278
  if (installedQueryNorm === "") return true;
2224
2279
  return [entry.moduleName, entry.entryId, entry.rowId]
2225
2280
  .some((value) => String(value ?? "").toLowerCase().includes(installedQueryNorm));
2226
- }).map((entry) => {
2281
+ })
2282
+ : [];
2283
+ // 聚合包分组:仅「同根包 + 子路径导出」(web-all 0.3.14 式,moduleName=pkg/sub)→ 全家桶组;
2284
+ // 经典多子包聚合(每子包独立名)不归组。组头与子包卡片一律置于列表【最底部】。
2285
+ const famRootOf = (entry) => {
2286
+ const m = String(entry.moduleName ?? "");
2287
+ if (m.startsWith("@deepseek-ai/")) return null;
2288
+ return pkgRootOf(m);
2289
+ };
2290
+ const familyCounts = new Map();
2291
+ for (const entry of (state.status === "ready" ? state.data.entries : [])) {
2292
+ const root = famRootOf(entry);
2293
+ if (root === null) continue;
2294
+ familyCounts.set(root, (familyCounts.get(root) ?? 0) + 1);
2295
+ }
2296
+ const familyCards = filteredEntries.map((entry) => {
2297
+ const familyRoot = String(entry.moduleName ?? "").startsWith("@deepseek-ai/") ? null : pkgRootOf(entry.moduleName);
2298
+ void familyRoot;
2227
2299
  const open = details !== null && details.entryId === entry.entryId;
2228
2300
  let detailPanel = null;
2229
2301
  if (open) {
@@ -2236,10 +2308,13 @@ window.__ModuleLoader__.load({
2236
2308
  } else {
2237
2309
  const meta = details.data.meta;
2238
2310
  const readme = details.data.readme;
2311
+ const compatInfo = state.status === "ready" && state.data.compatPending
2312
+ ? (state.data.compatPending.pending ?? []).find((p) => p.rowId === entry.rowId) ?? null
2313
+ : null;
2239
2314
  detailPanel = el("div", { className: styles.detail },
2240
2315
  meta !== null ? el("p", { className: styles.desc }, meta.description ?? t("noDetail")) : null,
2241
2316
  entry.pendingCompat === true
2242
- ? el("p", { className: styles.status, "data-pending": "true" }, t("pendingCompatHint"))
2317
+ ? el("p", { className: styles.status, "data-pending": "true" }, t("pendingCompatHint") + (compatInfo && compatInfo.checkNote ? ";已知校验:" + compatInfo.checkNote : ""))
2243
2318
  : null,
2244
2319
  meta !== null && meta.version ? el("div", { className: styles.rowTop },
2245
2320
  el("p", { className: styles.status }, t("versionLabel") + ":" + meta.version),
@@ -2254,7 +2329,38 @@ window.__ModuleLoader__.load({
2254
2329
  className: styles.toggle,
2255
2330
  title: t("aiEmpowerTitle"),
2256
2331
  onClick: () => openAiFor(String(entry.moduleName ?? entry.repo ?? "")),
2257
- }, t("aiEmpowerLabel"))) : null,
2332
+ }, t("aiEmpowerLabel")),
2333
+ entry.pendingCompat === true
2334
+ ? el("button", {
2335
+ type: "button",
2336
+ className: styles.toggle,
2337
+ "data-pending": "true",
2338
+ title: t("adaptUnlockHint"),
2339
+ disabled: installing === entry.moduleName || adaptUnlocking === entry.entryId,
2340
+ onClick: () => {
2341
+ setAdaptUnlocking(entry.entryId);
2342
+ call("/plugin-console/adapt-unlock", { rowId: entry.rowId }).then(
2343
+ () => {
2344
+ setAdaptUnlocking(null);
2345
+ setMessage(t("adaptUnlockDone"));
2346
+ window.setTimeout(() => window.location.reload(), 1200);
2347
+ },
2348
+ (error) => { setAdaptUnlocking(null); setMessage(t("failed") + ":" + friendlyGithubError(error).message); },
2349
+ );
2350
+ },
2351
+ }, adaptUnlocking === entry.entryId ? t("loadingPhase") : t("adaptUnlockBtn"))
2352
+ : null) : null,
2353
+ updateCheck !== null && updateCheck.status === "ready" && updateCheck.migrate && updateCheck.migrate.compatible
2354
+ ? el("div", null,
2355
+ el("div", { className: styles.rowTop },
2356
+ el("p", { className: styles.status }, t("migrateTip") + ":" + String(updateCheck.migrate.to ?? "") + (updateCheck.migrate.latest ? " v" + updateCheck.migrate.latest : "")),
2357
+ el("button", {
2358
+ type: "button",
2359
+ className: styles.toggle,
2360
+ disabled: installing === entry.moduleName,
2361
+ onClick: () => startJob("", updateCheck.migrate.to, "github", "plugin", false),
2362
+ }, installing === entry.moduleName ? t("installing") : t("migrateBtn"))))
2363
+ : null,
2258
2364
  updateCheck !== null && updateCheck.status === "ready"
2259
2365
  ? (updateCheck.error
2260
2366
  ? el("p", { className: styles.status, "data-error": "true" }, t("updateCheckFailed") + ":" + updateCheck.error)
@@ -2292,7 +2398,13 @@ window.__ModuleLoader__.load({
2292
2398
  }
2293
2399
  return el("li", { key: entry.entryId, className: styles.row },
2294
2400
  el("div", { className: styles.rowTop },
2295
- el("strong", { className: styles.name, title: entry.moduleName }, moduleShortName(entry.moduleName)),
2401
+ el("strong", { className: styles.name, title: entry.moduleName }, (() => {
2402
+ const famRoot = famRootOf(entry);
2403
+ const mn = String(entry.moduleName ?? "");
2404
+ return famRoot !== null && (familyCounts.get(famRoot) ?? 0) > 1 && mn.startsWith(famRoot + "/")
2405
+ ? mn.slice(famRoot.length + 1)
2406
+ : moduleShortName(entry.moduleName);
2407
+ })()),
2296
2408
  el("span", { className: styles.tag, "data-enabled": entry.enabled ? "true" : "false" },
2297
2409
  t(entry.enabled ? "enabledTag" : "disabledTag")),
2298
2410
  entry.extra === true ? el("span", { className: styles.tag, "data-user": "true" }, t("extraTag")) : null,
@@ -2338,7 +2450,7 @@ window.__ModuleLoader__.load({
2338
2450
  setMessage(t("deleteBundleNote") + ":" + (data.packageName ?? "") + (data.uninstallError ? "(包卸载警告:" + data.uninstallError + ")" : ""));
2339
2451
  setRestartHint(true);
2340
2452
  } else {
2341
- setMessage(t("deleteNote") + ":" + (data.packageName ?? "") + (data.uninstallError ? "(包卸载警告:" + data.uninstallError + ")" : ""));
2453
+ setMessage(t("deleteNote") + ":" + (data.packageName ?? "") + (data.note ? ";" + data.note : "") + (data.uninstallError ? "(包卸载警告:" + data.uninstallError + ")" : ""));
2342
2454
  setTimeout(refresh, 1500);
2343
2455
  }
2344
2456
  },
@@ -2366,8 +2478,72 @@ window.__ModuleLoader__.load({
2366
2478
  ),
2367
2479
  detailPanel,
2368
2480
  );
2369
- })
2370
- : [];
2481
+ });
2482
+ // 全家桶组头:聚合包(同根包 ≥2 行)合并展示为一个「web 包」卡片
2483
+ const familyHeaders = [];
2484
+ const familySeen = new Set();
2485
+ for (const entry of filteredEntries) {
2486
+ const root = famRootOf(entry);
2487
+ if (root === null || (familyCounts.get(root) ?? 0) <= 1 || familySeen.has(root)) continue;
2488
+ familySeen.add(root);
2489
+ familyHeaders.push(el("div", { key: "family-" + root, className: styles.detail, style: { border: "1px solid var(--dsw-alias-state-business-primary)", borderRadius: 10, padding: "8px 12px", gridColumn: "1 / -1" } },
2490
+ el("div", { className: styles.rowTop, style: { alignItems: "center" } },
2491
+ el("strong", { className: styles.name, style: { flex: "0 1 auto", minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, title: root }, root.replace(/^@[^/]+\//u, "").replace(/^dsh-/u, "")),
2492
+ el("span", { className: styles.tag, "data-user": "true" }, t("familyTag") + " · " + (familyCounts.get(root) ?? 0) + " " + t("familySubs")),
2493
+ el("button", {
2494
+ type: "button",
2495
+ className: styles.toggle,
2496
+ style: { whiteSpace: "nowrap", flexShrink: 0 },
2497
+ onClick: () => setFamilyCollapsed((prev) => ({ ...prev, [root]: prev[root] !== true })),
2498
+ }, familyCollapsed[root] === true ? "▸ " + t("familyExpand") : "▾ " + t("familyCollapse")),
2499
+ el("button", {
2500
+ type: "button",
2501
+ className: styles.toggle,
2502
+ style: { whiteSpace: "nowrap", flexShrink: 0 },
2503
+ onClick: () => {
2504
+ setMessage(t("checkingUpdate"));
2505
+ call("/plugin-console/check-update", { packageName: root }).then(
2506
+ (d) => setMessage(t("familyUpdate") + ":" + root + " " + (d?.latest ?? "?") + (d?.migrate && d.migrate.compatible ? " → " + d.migrate.to + " v" + d.migrate.latest : "") + (d?.error ? "(" + d.error + ")" : "")),
2507
+ (e) => setMessage(t("failed") + ":" + friendlyGithubError(e).message),
2508
+ );
2509
+ },
2510
+ }, t("familyCheckUpdate")),
2511
+ el("button", {
2512
+ type: "button",
2513
+ className: styles.toggle,
2514
+ style: { whiteSpace: "nowrap", flexShrink: 0 },
2515
+ title: t("familyUnlockAllTip"),
2516
+ onClick: () => {
2517
+ if (!window.confirm(t("familyUnlockAllConfirm"))) return;
2518
+ call("/plugin-console/adapt-unlock-all", { root }).then(
2519
+ (d) => {
2520
+ setMessage(t("familyUnlockAllDone") + ":" + (d.unlocked ?? []).length + " 行" + (Array.isArray(d.kept) && d.kept.length > 0 ? ";保留 " + d.kept.length + " 行(未通过校验)" : ""));
2521
+ window.setTimeout(() => window.location.reload(), 1500);
2522
+ },
2523
+ (e) => setMessage(t("failed") + ":" + friendlyGithubError(e).message),
2524
+ );
2525
+ },
2526
+ }, t("familyUnlockAll")))
2527
+ ));
2528
+ }
2529
+ // 组头放到最底部:普通条目在前,全家桶(组头+子包卡片)整体在后
2530
+ const nonFamilyCards = [];
2531
+ const familySections = new Map();
2532
+ familyCards.forEach((card, i) => {
2533
+ const e = filteredEntries[i];
2534
+ if (e === undefined) { nonFamilyCards.push(card); return; }
2535
+ const root = famRootOf(e);
2536
+ if (root === null || (familyCounts.get(root) ?? 0) <= 1) { nonFamilyCards.push(card); return; }
2537
+ if (!familySections.has(root)) familySections.set(root, { head: familyHeaders.find((h) => h && h.key === "family-" + root) ?? null, cards: [] });
2538
+ familySections.get(root).cards.push(card);
2539
+ });
2540
+ const familySectionEls = [];
2541
+ for (const [, section] of familySections) {
2542
+ if (section.head !== null) familySectionEls.push(section.head);
2543
+ const root = section.head !== null ? section.head.key.slice("family-".length) : null;
2544
+ if (root === null || familyCollapsed[root] !== true) familySectionEls.push(...section.cards);
2545
+ }
2546
+ const rows = state.status === "ready" ? [...nonFamilyCards, ...familySectionEls] : [];
2371
2547
  const marketShown = market === null || market.status !== "ready"
2372
2548
  ? []
2373
2549
  : officialOnly
package/lib/index.js CHANGED
@@ -132,8 +132,8 @@ const FIBER_PHASE = {
132
132
  * (有入口)都是这样安装的,当作插件条目 insert 会漏掉它们的 bundle 补丁。 */
133
133
  async function detectBundleOnly(profileDir, packageName) {
134
134
  try {
135
- const require = createRequire(join(profileDir, 'package.json'))
136
- const pkgPath = require.resolve(`${packageName}/package.json`)
135
+ const pkgPath = resolvePackageJson(packageName, profileDir)
136
+ if (pkgPath === null) throw new Error('not found')
137
137
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
138
138
  return typeof pkg.dsh?.bundle?.patch === 'string'
139
139
  } catch {
@@ -158,6 +158,37 @@ async function addBundleToManifest(profileDir, packageName) {
158
158
  /** 官方 profile 模板自带的 bundle(其余 bundle 视为用户额外添加)。 */
159
159
  const DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
160
160
 
161
+ /** 包名归一:支持子路径导出形式(@linxin666/dsh-web-all/settings → @linxin666/dsh-web-all)。 */
162
+ function packageNameOf(moduleName) {
163
+ if (typeof moduleName !== 'string') return moduleName
164
+ const m = moduleName.match(/^(@[^/]+\/[^/]+)(?:\/.*)?$/u)
165
+ return m ? m[1] : moduleName
166
+ }
167
+
168
+ /** 把 file:// URL(如 ctx.baseUrl)转成目录路径(resolve 用)。 */
169
+ function baseDirOf(baseUrl) {
170
+ try {
171
+ if (typeof baseUrl === 'string' && baseUrl.startsWith('file:')) return dirname(fileURLToPath(baseUrl))
172
+ } catch {}
173
+ return typeof baseUrl === 'string' && baseUrl !== '' ? baseUrl : '.'
174
+ }
175
+
176
+ /**
177
+ * 解析包 package.json 的绝对路径(exports 限制包 fallback):
178
+ * require.resolve('pkg/package.json') 对声明了 exports 且不含 './package.json' 的包会抛错
179
+ * (2026-09-04 教训:@openviking/dsh-memory-plugin 因此被 heal 误判模块缺失并自动禁用)——
180
+ * fallback 直接查 node_modules 物理路径。
181
+ */
182
+ function resolvePackageJson(pkgName, baseDir) {
183
+ const name = packageNameOf(pkgName)
184
+ try {
185
+ return createRequire(join(baseDir, 'package.json')).resolve(`${name}/package.json`)
186
+ } catch {}
187
+ const parts = String(name).split('/')
188
+ const candidate = join(baseDir, 'node_modules', ...parts, 'package.json')
189
+ return existsSync(candidate) ? candidate : null
190
+ }
191
+
161
192
  /** 已加载插件的包元信息缓存(安装日期/版本/仓库),60 秒 TTL。 */
162
193
  const pkgMetaCache = new Map()
163
194
  const PKG_META_TTL = 60000
@@ -167,8 +198,8 @@ function entryPkgMeta(moduleName, baseUrl) {
167
198
  if (hit !== undefined && Date.now() - hit.at < PKG_META_TTL) return hit
168
199
  const meta = { at: Date.now(), installDate: null, version: null, repository: null }
169
200
  try {
170
- const require = createRequire(baseUrl)
171
- const pkgPath = require.resolve(`${moduleName}/package.json`)
201
+ const pkgPath = resolvePackageJson(moduleName, baseDirOf(baseUrl))
202
+ if (pkgPath === null) throw new Error('not found')
172
203
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
173
204
  // 安装日期:优先 curl 通道写入的 .dsh-installed-at 标记(真实安装时刻);
174
205
  // 否则用 package.json 的 mtime,但 npm tarball 会把文件时间固定为 1985-10-26
@@ -293,19 +324,69 @@ function semverRangeMatchLoose(versionText, rangeText) {
293
324
  return semverRangeMatch(`${v.major}.${v.minor}.${v.patch}`, rangeText)
294
325
  }
295
326
 
327
+ /**
328
+ * 扫描已安装包源码,检测对已删除的 dsh-settings API 的引用
329
+ * (2026-09-04 教训:0.1.2-rc.1 起 settingsNamespace / installSettingsSection 已删除,
330
+ * 静态声明检查判 pass 是假通过;真实兼容性只有模块 import 时见分晓,而 loader 单行失败
331
+ * = 整个服务启动崩溃)。返回命中的符号列表,空数组 = 干净。
332
+ */
333
+ function scanSettingsApiUsage(pkgDir) {
334
+ const found = []
335
+ const seen = new Set()
336
+ const walk = (dir, depth) => {
337
+ if (depth > 3) return
338
+ let entries = []
339
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
340
+ for (const entry of entries) {
341
+ const full = join(dir, entry.name)
342
+ if (entry.isDirectory()) {
343
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
344
+ walk(full, depth + 1)
345
+ continue
346
+ }
347
+ if (!entry.isFile() || !/\.(?:js|mjs|cjs)$/u.test(entry.name)) continue
348
+ if (seen.has(full)) continue
349
+ seen.add(full)
350
+ if (seen.size > 120) return
351
+ try {
352
+ const text = readFileSync(full, 'utf8').slice(0, 400000)
353
+ for (const sym of ['settingsNamespace', 'installSettingsSection']) {
354
+ if (text.includes(sym) && !found.includes(sym)) found.push(sym)
355
+ }
356
+ } catch {}
357
+ }
358
+ }
359
+ walk(pkgDir, 0)
360
+ return found
361
+ }
362
+
296
363
  /**
297
364
  * 插件-框架兼容校验(框架升级适配门):
298
365
  * 1) 显式兼容声明(dsh.engines.framework / dsh.compat.framework / engines.dsh):以声明为准;
299
- * 2) 否则扫描依赖/peerDeps 的 @deepseek-ai/*:任一范围明确不含框架版本 → fail;
300
- * 3) 无声明且依赖全部满足 → unknown(新版变动不大;由调用方在「版本已变化」前提下降级为放行)。
366
+ * 2) 扫描依赖/peerDeps 的 @deepseek-ai/*:任一范围明确不含框架版本 → fail;
367
+ * 3) 【硬判据】提供 pkgDir 时扫描包源码:命中已删除的 dsh-settings API
368
+ * (settingsNamespace / installSettingsSection)→ 直接 fail(2026-09-04 教训:
369
+ * 0.3.6 全家仍引用旧 API,静态声明/依赖检查判 pass 是假通过,loader 单行失败 = 服务崩溃);
370
+ * 4) 无声明且依赖全部满足 → unknown(新版变动不大;由调用方在「版本已变化」前提下放行)。
301
371
  * 返回 { decision: 'pass'|'fail'|'unknown', reason }。
302
372
  */
303
- function checkPluginFrameworkCompat(pkg, frameworkVersion) {
373
+ function checkPluginFrameworkCompat(pkg, frameworkVersion, pkgDir = null) {
304
374
  const declared = pkg?.dsh?.engines?.framework ?? pkg?.dsh?.compat?.framework ?? pkg?.engines?.dsh ?? null
305
375
  if (typeof declared === 'string' && declared !== '') {
306
- return semverRangeMatchLoose(frameworkVersion, declared)
307
- ? { decision: 'pass', reason: `声明兼容范围 ${declared} 满足框架 ${frameworkVersion}` }
308
- : { decision: 'fail', reason: `声明兼容范围 ${declared} 不满足当前框架 ${frameworkVersion}` }
376
+ const ok = semverRangeMatchLoose(frameworkVersion, declared)
377
+ if (!ok) return { decision: 'fail', reason: `声明兼容范围 ${declared} 不满足当前框架 ${frameworkVersion}` }
378
+ }
379
+ // 硬判据:实际源码扫描(比声明/依赖更接近真实兼容性)
380
+ if (pkgDir !== null && typeof pkgDir === 'string' && semverRangeMatchLoose(frameworkVersion, '>=0.1.2')) {
381
+ const broken = scanSettingsApiUsage(pkgDir)
382
+ if (broken.length > 0) {
383
+ return { decision: 'fail', reason: `源码仍引用 0.1.2 起已删除的 dsh-settings API(${broken.join('、')})——实际不兼容,启用会让整个服务启动崩溃` }
384
+ }
385
+ if (typeof declared === 'string' && declared !== '') return { decision: 'pass', reason: `声明兼容范围 ${declared} 满足框架 ${frameworkVersion},且源码无已删除 API 引用` }
386
+ // 源码扫描干净 = 权威判据:依赖范围可能滞后旧版("假拒绝"),不作为 fail。
387
+ return { decision: 'unknown', reason: '源码扫描无已删除 API 引用(声明/依赖范围为参考,不作为不兼容判据)' }
388
+ } else if (typeof declared === 'string' && declared !== '') {
389
+ return { decision: 'pass', reason: `声明兼容范围 ${declared} 满足框架 ${frameworkVersion}` }
309
390
  }
310
391
  const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.peerDependencies ?? {}), ...(pkg?.optionalDependencies ?? {}) }
311
392
  const hits = Object.entries(deps).filter(([name]) => /^@deepseek-ai\//u.test(name))
@@ -326,8 +407,8 @@ async function readExtraBundleRows(profileDir) {
326
407
  for (const pkg of bundles) {
327
408
  if (DEFAULT_BUNDLES.includes(pkg)) continue
328
409
  try {
329
- const require = createRequire(join(profileDir, 'package.json'))
330
- const dir = dirname(require.resolve(`${pkg}/package.json`))
410
+ const pk = resolvePackageJson(pkg, profileDir)
411
+ const dir = dirname(pk ?? join(profileDir, 'node_modules', ...String(pkg).split('/'), 'package.json'))
331
412
  const text = await readFile(join(dir, 'cordis.patch.yml'), 'utf8')
332
413
  const lines = text.split(/\r?\n/u)
333
414
  let inInsert = false
@@ -415,8 +496,8 @@ async function readExtraBundleOwners(profileDir) {
415
496
  for (const pkg of bundles) {
416
497
  if (DEFAULT_BUNDLES.includes(pkg)) continue
417
498
  try {
418
- const require = createRequire(join(profileDir, 'package.json'))
419
- const dir = dirname(require.resolve(`${pkg}/package.json`))
499
+ const pk = resolvePackageJson(pkg, profileDir)
500
+ const dir = dirname(pk ?? join(profileDir, 'node_modules', ...String(pkg).split('/'), 'package.json'))
420
501
  const text = await readFile(join(dir, 'cordis.patch.yml'), 'utf8')
421
502
  const lines = text.split(/\r?\n/u)
422
503
  let inInsert = false
@@ -802,6 +883,119 @@ const FRAMEWORK_STATE_FILE = () => join(dshHome(), 'plugin-console', 'framework-
802
883
  const FRAMEWORK_BACKUP_ROOT = () => join(dshHome(), 'plugin-console', 'framework-backups')
803
884
  const COMPAT_PENDING_FILE = () => join(dshHome(), 'plugin-console', 'compat-pending.json')
804
885
 
886
+ /** 框架核心行:误禁用会导致启动失败(2026-09-04 session-persistence-jsonl 事故:6 行 pending、
887
+ * 启动断言失败)。任何补丁写入(适配门/脚本/工具)都禁止禁用这些行;自愈机制自动恢复误禁。 */
888
+ const CORE_PATCH_ROW_IDS = new Set([
889
+ 'session-persistence-jsonl', 'webserver', 'timer', 'hmr', 'session', 'session-checkpoint-policy',
890
+ 'message-feedback', 'workspace', 'storage', 'storage-json', 'storage-domain', 'api-gateway',
891
+ 'api-session-controller', 'api-workspace-controller', 'credentials', 'settings', 'attachment-local',
892
+ 'subprocess', 'sandbox', 'sandbox-policy', 'shell-env', 'agent', 'agent-loop', 'llm', 'web-runtime', 'web-startup',
893
+ ])
894
+
895
+ let patchHealAt = null
896
+ let patchHealReport = null
897
+
898
+ /** 解析用户补丁中 insert 块的 id → moduleName(name 字段)。 */
899
+ function parseInsertNames(text) {
900
+ const map = new Map()
901
+ const lines = text.split(/\r?\n/u)
902
+ let inInsert = false
903
+ let curId = null
904
+ for (const line of lines) {
905
+ if (/^- insert:\s*$/u.test(line)) { inInsert = true; curId = null; continue }
906
+ if (inInsert && /^- /u.test(line)) inInsert = false
907
+ if (inInsert) {
908
+ const idMatch = line.match(/^\s+- id: ([A-Za-z0-9_.-]+)/u)
909
+ if (idMatch) { curId = idMatch[1]; continue }
910
+ if (curId !== null) {
911
+ const nameMatch = line.match(/^\s+name: ['"]([^'"]+)['"]/u)
912
+ if (nameMatch) { map.set(curId, nameMatch[1]); curId = null }
913
+ }
914
+ }
915
+ }
916
+ return map
917
+ }
918
+
919
+ /**
920
+ * 补丁安全自愈(服务永不崩机制):
921
+ * ① 核心行被误禁用 → 自动移除禁用块恢复;
922
+ * ② 启用态用户 insert 行的模块缺失(如引用未安装包的行)→ 自动禁用(loader 对缺失模块会致命崩溃)。
923
+ * return { healed:[], autoDisabled:[], healedAt } —— healedAt=0 表示本次无修改。
924
+ */
925
+ async function healPatchSafety(patchPath) {
926
+ const profileDir = dirname(patchPath)
927
+ const { text } = await readPatchState(patchPath)
928
+ let next = text
929
+ const healed = []
930
+ const autoDisabled = []
931
+ for (const id of CORE_PATCH_ROW_IDS) {
932
+ const re = new RegExp(`^- id: ${escapeRegExp(id)}\\s*\\r?\\n {2}disabled: true\\s*\\r?\\n`, 'mu')
933
+ if (re.test(next)) {
934
+ next = next.replace(re, '')
935
+ healed.push(id)
936
+ }
937
+ }
938
+ if (healed.length === 0) {
939
+ const insertNames = parseInsertNames(next)
940
+ let require = null
941
+ for (const [id, moduleName] of insertNames) {
942
+ if (!moduleName || moduleName.startsWith('cordis:')) continue
943
+ let ok = true
944
+ try { ok = resolvePackageJson(moduleName, profileDir) !== null } catch { ok = false }
945
+ if (!ok && !new RegExp(`^- id: ${escapeRegExp(id)}\\s*\\r?\\n {2}disabled: true\\s*\\r?\\n`, 'mu').test(next)) {
946
+ next = `${next.trimEnd()}\n- id: ${id}\n disabled: true\n`
947
+ autoDisabled.push(id)
948
+ }
949
+ }
950
+ }
951
+ if (next !== text) {
952
+ await writeFile(patchPath, sanitizePatchText(next), 'utf8')
953
+ }
954
+ return { healed, autoDisabled, healedAt: next !== text ? Date.now() : 0 }
955
+ }
956
+
957
+ /**
958
+ * 启用前冒烟检查(服务永不崩机制):在独立子进程中动态 import 插件主模块,
959
+ * 捕获 loader 会遇到的 import/resolution 错误(SyntaxError、缺失导出、模块缺失)。
960
+ * 子进程失败不影响控制台;探测失败/超时按"不通过"处理(宁可拒绝,不冒险拖崩服务)。
961
+ */
962
+ function probePluginImport(moduleName, profileDir) {
963
+ return new Promise((resolve) => {
964
+ const script = [
965
+ 'import { createRequire } from "node:module";',
966
+ 'import path from "node:path";',
967
+ 'try {',
968
+ ' const req = createRequire(path.join(process.argv[1], "package.json"));',
969
+ ' const mainPath = req.resolve(process.argv[2]);',
970
+ ' const { pathToFileURL } = await import("node:url");',
971
+ ' await import(pathToFileURL(mainPath).href);',
972
+ ' console.log("PROBE_OK");',
973
+ '} catch (e) { console.log("PROBE_ERR:" + String(e && e.message ? e.message : e).slice(0, 400)); process.exitCode = 1; }',
974
+ ].join('\n')
975
+ let child = null
976
+ try {
977
+ child = spawn(process.execPath, ['--input-type=module', '-e', script, profileDir, moduleName], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
978
+ } catch {
979
+ resolve({ ok: true, detail: 'probe 无法启动(放行)' })
980
+ return
981
+ }
982
+ let out = ''
983
+ let timer = null
984
+ const finish = (ok, detail) => {
985
+ if (timer !== null) clearTimeout(timer)
986
+ resolve({ ok, detail })
987
+ }
988
+ timer = setTimeout(() => { try { child.kill() } catch {}; finish(false, '探测超时') }, 8000)
989
+ child.stdout.on('data', (c) => { out += c })
990
+ child.stderr.on('data', (c) => { out += c })
991
+ child.on('error', () => finish(true, 'probe 进程错误(放行)'))
992
+ child.on('close', (code) => {
993
+ if (out.includes('PROBE_OK')) finish(true, 'import OK')
994
+ else finish(false, (out.match(/PROBE_ERR:([^\n]*)/u)?.[1] ?? out.slice(0, 300)).trim())
995
+ })
996
+ })
997
+ }
998
+
805
999
  /** 读取框架升级适配门清单(compat-pending.json);损坏/缺失返回 null。 */
806
1000
  function readCompatPending() {
807
1001
  try { return JSON.parse(readFileSync(COMPAT_PENDING_FILE(), 'utf8')) } catch { return null }
@@ -996,11 +1190,11 @@ function summarizeReadme(text) {
996
1190
  async function readPluginDetails(moduleName, baseUrl) {
997
1191
  if (typeof moduleName !== 'string' || moduleName.startsWith('cordis:')) return { meta: null, readme: null }
998
1192
  try {
999
- const require = createRequire(baseUrl)
1000
- const pkgPath = require.resolve(`${moduleName}/package.json`)
1193
+ const pkgPath = resolvePackageJson(moduleName, baseDirOf(baseUrl))
1194
+ if (pkgPath === null) throw new Error('not found')
1001
1195
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
1002
1196
  const meta = {
1003
- name: pkg.name ?? moduleName,
1197
+ name: moduleName,
1004
1198
  version: pkg.version ?? null,
1005
1199
  description: pkg.description ?? null,
1006
1200
  homepage: pkg.homepage ?? null,
@@ -1485,56 +1679,114 @@ async function fetchSkillMeta(repo, branch, skillDir) {
1485
1679
  * 全部条目并发 + raw 主站与镜像双通道竞速,单条 4 秒封顶。
1486
1680
  * 聚合仓库(根包 private+workspaces)额外检查子包是否有 dsh.bundle 清单:
1487
1681
  * 任一子包可 `dsh plugin add` 直装 → aggregateInstallable = true(★ 筛选会包含它)。 */
1488
- async function enrichItems(items) {
1489
- return Promise.all(items.map(async (item) => {
1490
- let official = null
1491
- let aggregate = false
1492
- let aggregateInstallable = false
1493
- let hasSkill = false
1494
- let hasSuite = false
1495
- try {
1496
- const branch = item.defaultBranch ?? 'main'
1497
- const base = `https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/package.json`
1498
- const [pkgResult, skillResult, suiteResult] = await Promise.allSettled([
1499
- Promise.any([
1500
- curlText(base, 4000),
1501
- curlText(`https://ghproxy.net/${base}`, 4000),
1502
- ]),
1503
- detectSkillRepo(item.fullName, branch),
1504
- rawTextWithFallback(item.fullName, branch, '.gitmodules'),
1505
- ])
1506
- if (suiteResult.status === 'fulfilled' && suiteResult.value !== null) {
1507
- hasSuite = true
1508
- }
1509
- if (pkgResult.status === 'fulfilled') {
1510
- const pkg = JSON.parse(pkgResult.value)
1511
- if (typeof pkg.dsh?.bundle?.patch === 'string') {
1512
- official = true
1513
- } else if (pkg.private === true && (Array.isArray(pkg.workspaces) || /(^|-)dsh[-/]/u.test(String(pkg.name ?? '')))) {
1514
- aggregate = true
1515
- try {
1516
- const tree = await curlJson(`https://api.github.com/repos/${item.fullName}/git/trees/${encodeURIComponent(branch)}?recursive=1`, 6000)
1517
- const pkgPaths = (tree.tree ?? [])
1518
- .filter((n) => n.type === 'blob' && /^packages\/[^/]+\/package\.json$/u.test(n.path))
1519
- .map((n) => n.path)
1520
- .slice(0, 12)
1521
- if (pkgPaths.length > 0) {
1522
- const subs = await Promise.all(pkgPaths.map((p) => curlText(`https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/${p}`, 4000)
1523
- .then((t) => { try { return JSON.parse(t) } catch { return null } })
1524
- .catch(() => null)))
1525
- if (subs.some((sp) => sp && typeof sp.dsh?.bundle?.patch === 'string')) {
1526
- aggregateInstallable = true
1527
- }
1682
+ const ENRICH_CACHE_FILE = () => join(dshHome(), 'plugin-console', 'enrich-cache.json')
1683
+ const ENRICH_CACHE_TTL = 24 * 60 * 60 * 1000
1684
+
1685
+ function readEnrichCache() {
1686
+ try {
1687
+ const j = JSON.parse(readFileSync(ENRICH_CACHE_FILE(), 'utf8'))
1688
+ return j !== null && typeof j === 'object' ? j : {}
1689
+ } catch { return {} }
1690
+ }
1691
+ function writeEnrichCache(cache) {
1692
+ try {
1693
+ mkdirSync(dirname(ENRICH_CACHE_FILE()), { recursive: true })
1694
+ const entries = Object.entries(cache).sort((a, b) => (b[1]?.at ?? 0) - (a[1]?.at ?? 0)).slice(0, 2000)
1695
+ writeFileSync(ENRICH_CACHE_FILE(), JSON.stringify(Object.fromEntries(entries), null, 2), 'utf8')
1696
+ } catch {}
1697
+ }
1698
+
1699
+ /** 单项识别:官方通道 / 聚合仓库 / 技能 / 套装(失败返回 official=null)。 */
1700
+ async function enrichItemOne(item) {
1701
+ let official = /^deepseek-ai\//u.test(item.fullName ?? '') ? true : null
1702
+ let aggregate = false
1703
+ let aggregateInstallable = false
1704
+ let hasSkill = false
1705
+ let hasSuite = false
1706
+ try {
1707
+ const branch = item.defaultBranch ?? 'main'
1708
+ const base = `https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/package.json`
1709
+ const [pkgResult, skillResult, suiteResult] = await Promise.allSettled([
1710
+ Promise.any([
1711
+ curlText(base, 4000),
1712
+ curlText(`https://ghproxy.net/${base}`, 4000),
1713
+ ]),
1714
+ detectSkillRepo(item.fullName, branch),
1715
+ rawTextWithFallback(item.fullName, branch, '.gitmodules'),
1716
+ ])
1717
+ if (suiteResult.status === 'fulfilled' && suiteResult.value !== null) {
1718
+ hasSuite = true
1719
+ }
1720
+ if (pkgResult.status === 'fulfilled') {
1721
+ const pkg = JSON.parse(pkgResult.value)
1722
+ if (typeof pkg.dsh?.bundle?.patch === 'string') {
1723
+ official = true
1724
+ } else if (pkg.private === true && (Array.isArray(pkg.workspaces) || /(^|-)dsh[-/]/u.test(String(pkg.name ?? '')))) {
1725
+ aggregate = true
1726
+ try {
1727
+ const tree = await curlJson(`https://api.github.com/repos/${item.fullName}/git/trees/${encodeURIComponent(branch)}?recursive=1`, 6000)
1728
+ const pkgPaths = (tree.tree ?? [])
1729
+ .filter((n) => n.type === 'blob' && /^packages\/[^/]+\/package\.json$/u.test(n.path))
1730
+ .map((n) => n.path)
1731
+ .slice(0, 12)
1732
+ if (pkgPaths.length > 0) {
1733
+ const subs = await Promise.all(pkgPaths.map((p) => curlText(`https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/${p}`, 4000)
1734
+ .then((t) => { try { return JSON.parse(t) } catch { return null } })
1735
+ .catch(() => null)))
1736
+ if (subs.some((sp) => sp && typeof sp.dsh?.bundle?.patch === 'string')) {
1737
+ aggregateInstallable = true
1528
1738
  }
1529
- } catch {}
1530
- }
1739
+ }
1740
+ } catch {}
1531
1741
  }
1532
- if (skillResult.status === 'fulfilled' && skillResult.value?.hasSkill === true) {
1533
- hasSkill = true
1742
+ }
1743
+ if (skillResult.status === 'fulfilled' && skillResult.value?.hasSkill === true) {
1744
+ hasSkill = true
1745
+ }
1746
+ } catch {}
1747
+ return { ...item, official, aggregate, aggregateInstallable, hasSkill, hasSuite }
1748
+ }
1749
+
1750
+ /**
1751
+ * 批量识别(★ 筛选数据源):并发限流 + 24h 结果缓存。
1752
+ * 网络黑洞期(raw.githubusercontent 大部分拉取失败)自动回退缓存中的上次判定,
1753
+ * 保证「只看官方」不因瞬时网络而坍缩成 0/1 条。
1754
+ */
1755
+ async function enrichItems(items) {
1756
+ const cache = readEnrichCache()
1757
+ const out = new Array(items.length)
1758
+ let next = 0
1759
+ let cacheDirty = false
1760
+ async function worker() {
1761
+ while (true) {
1762
+ const i = next
1763
+ next += 1
1764
+ if (i >= items.length) return
1765
+ const item = items[i]
1766
+ const key = `${item.fullName}@${item.defaultBranch ?? 'main'}`
1767
+ const cached = cache[key]
1768
+ try {
1769
+ if (cached !== undefined && typeof cached?.at === 'number' && Date.now() - cached.at < ENRICH_CACHE_TTL && cached.data && (cached.data.official !== null || item.fullName.startsWith('deepseek-ai/'))) {
1770
+ out[i] = { ...item, ...cached.data }
1771
+ continue
1772
+ }
1773
+ let result = await enrichItemOne(item)
1774
+ // 本次失败 → 回退缓存(哪怕已过期),避免「看天吃饭」
1775
+ if ((result.official === null && !/^deepseek-ai\//u.test(item.fullName ?? '')) && cached?.data) {
1776
+ result = { ...item, ...cached.data }
1777
+ } else {
1778
+ cache[key] = { at: Date.now(), data: { official: result.official, aggregate: result.aggregate, aggregateInstallable: result.aggregateInstallable, hasSkill: result.hasSkill, hasSuite: result.hasSuite } }
1779
+ cacheDirty = true
1780
+ }
1781
+ out[i] = result
1782
+ } catch {
1783
+ out[i] = cached?.data ? { ...item, ...cached.data } : { ...item, official: null, aggregate: false, aggregateInstallable: false, hasSkill: false, hasSuite: false }
1534
1784
  }
1535
- } catch {}
1536
- return { ...item, official, aggregate, aggregateInstallable, hasSkill, hasSuite }
1537
- }))
1785
+ }
1786
+ }
1787
+ await Promise.all(Array.from({ length: Math.min(12, items.length) }, worker))
1788
+ if (cacheDirty) writeEnrichCache(cache)
1789
+ return out
1538
1790
  }
1539
1791
 
1540
1792
  function githubRepoInfo(repo) {
@@ -3396,14 +3648,17 @@ async function maybeAutoAdaptCompat({ profileDir, packageName, syncedNames, patc
3396
3648
  const kept = []
3397
3649
  for (const p of targets) {
3398
3650
  let pkg = null
3651
+ let pkgDir = null
3399
3652
  try {
3400
- const require = createRequire(join(profileDir, 'package.json'))
3401
- const pkgPath = require.resolve(`${p.moduleName}/package.json`)
3402
- pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
3653
+ const pkgPath = resolvePackageJson(p.moduleName, profileDir)
3654
+ if (pkgPath !== null) {
3655
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
3656
+ pkgDir = dirname(pkgPath)
3657
+ }
3403
3658
  } catch {}
3404
3659
  const version = typeof pkg?.version === 'string' ? pkg.version : null
3405
3660
  const changed = version !== null && version !== p.version
3406
- const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer) : { decision: 'unknown', reason: '无法读取包信息' }
3661
+ const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer, pkgDir) : { decision: 'unknown', reason: '无法读取包信息' }
3407
3662
  if (changed && check.decision !== 'fail') {
3408
3663
  try { await removeDisableBlock(patchPath, p.rowId) } catch {}
3409
3664
  p.status = 'adopted'
@@ -3433,8 +3688,8 @@ async function frameworkCompatReportFor(source, ctx, profileDir) {
3433
3688
  const isNpm = norm.startsWith('@') || !norm.includes('/')
3434
3689
  const installed = (() => {
3435
3690
  try {
3436
- const require = createRequire(join(profileDir, 'package.json'))
3437
- const pkgPath = require.resolve(`${norm}/package.json`)
3691
+ const pkgPath = resolvePackageJson(norm, profileDir)
3692
+ if (pkgPath === null) return null
3438
3693
  return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? null
3439
3694
  } catch { return null }
3440
3695
  })()
@@ -3833,6 +4088,17 @@ async function handle(ctx, req, res) {
3833
4088
  if (method === 'GET' && pathname === `${ROUTE_PREFIX}/state`) {
3834
4089
  const patchPath = findPatchPath(ctx)
3835
4090
  const patch = await readPatchState(patchPath)
4091
+ // 补丁安全自愈(核心行误禁用恢复 / 缺失模块行自动禁用)——每 2 分钟最多跑一次
4092
+ let patchHeal = null
4093
+ try {
4094
+ if (patchHealAt === null || Date.now() - patchHealAt > 120000) {
4095
+ patchHealAt = Date.now()
4096
+ patchHeal = await healPatchSafety(patchPath)
4097
+ } else {
4098
+ patchHeal = patchHealReport
4099
+ }
4100
+ if (patchHeal !== null) patchHealReport = patchHeal
4101
+ } catch {}
3836
4102
  const extraRows = await readExtraBundleRows(dirname(patchPath))
3837
4103
  const compatPending = readCompatPending()
3838
4104
  const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
@@ -3868,7 +4134,7 @@ async function handle(ctx, req, res) {
3868
4134
  const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
3869
4135
  selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
3870
4136
  } catch {}
3871
- sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null })) }, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
4137
+ sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0 }, compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null, checkNote: p.checkNote ?? null, check: p.check ?? null })) }, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
3872
4138
  return
3873
4139
  }
3874
4140
 
@@ -4070,11 +4336,25 @@ async function handle(ctx, req, res) {
4070
4336
  const compatPending = readCompatPending()
4071
4337
  const pend = (compatPending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
4072
4338
  if (pend) {
4073
- sendError(res, 409, `「${rowId}」仍在框架升级适配门中(${compatPending.frameworkVersion ?? '?'})被强制禁用:请先点击「更新并适配」,更新完成并通过兼容校验后系统自动解锁`)
4339
+ sendError(res, 409, `「${rowId}」仍在框架升级适配门中(${compatPending.frameworkVersion ?? '?'})被强制禁用:请先点击「更新并适配 / 已适配,立即解锁」,通过兼容校验后系统自动解锁`)
4074
4340
  return
4075
4341
  }
4076
4342
  }
4077
4343
  const patchPath = findPatchPath(ctx)
4344
+ // 启用前冒烟检查(服务永不崩机制):第三方模块在独立子进程中试 import,
4345
+ // 失败(SyntaxError/缺失导出/模块缺失)即拒绝启用,杜绝「单行 import 失败→整个服务启动崩溃」
4346
+ if (enabled) {
4347
+ const moduleName = target?.options?.name
4348
+ if (typeof moduleName === 'string' && !moduleName.startsWith('cordis:') && !moduleName.startsWith('@deepseek-ai/')) {
4349
+ try {
4350
+ const probe = await probePluginImport(moduleName, dirname(patchPath))
4351
+ if (probe.ok !== true) {
4352
+ sendError(res, 409, `启用前冒烟检查未通过:模块加载失败(${probe.detail ?? '未知'})——该插件与当前框架不兼容或依赖缺失,已阻止启用(服务不会再被拖崩);请先「检测更新/更新并适配」其适配版`)
4353
+ return
4354
+ }
4355
+ } catch {}
4356
+ }
4357
+ }
4078
4358
  const result = enabled
4079
4359
  ? await enableEntry(patchPath, rowId)
4080
4360
  : await disableEntry(patchPath, rowId)
@@ -4110,14 +4390,18 @@ async function handle(ctx, req, res) {
4110
4390
  const owners = await readExtraBundleOwners(profileDir)
4111
4391
  const ownerBundle = owners.get(rowId) ?? owners.get(moduleName)
4112
4392
  if (ownerBundle !== undefined) {
4113
- await removeBundleFromManifest(profileDir, ownerBundle)
4114
- let uninstallError = null
4115
- try {
4116
- await pnpmRemove(profileDir, ownerBundle)
4117
- } catch (error) {
4118
- uninstallError = error instanceof Error ? error.message : String(error)
4119
- }
4120
- sendJson(res, 200, { ok: true, removed: 'bundle', packageName: ownerBundle, restart: true, uninstallError })
4393
+ // 2026-09-04 事故教训:删除聚合包子路径行(如 @linxin666/dsh-web-all/plugin-manager)时
4394
+ // 曾把整个 bundle 从清单移除,pnpm 卸载失败(corepack 报错)后重启,全家桶整体消失。
4395
+ // 现改为:任何 bundle 行的「删除」= 仅停用该行(patch disabled:true),bundle 清单不动,
4396
+ // 之后随时可「启用」恢复;整体卸载请走包管理器。
4397
+ await disableEntry(patchPath, rowId)
4398
+ sendJson(res, 200, {
4399
+ ok: true,
4400
+ removed: 'row',
4401
+ packageName: moduleName,
4402
+ restart: false,
4403
+ note: '该行来自聚合包 ' + ownerBundle + ',已仅停用本行(bundle 保留,可随时启用恢复);整体卸载请用包管理器执行 pnpm remove ' + ownerBundle,
4404
+ })
4121
4405
  return
4122
4406
  }
4123
4407
  if (!patch.inserts.includes(rowId)) {
@@ -4556,11 +4840,105 @@ async function handle(ctx, req, res) {
4556
4840
  return
4557
4841
  }
4558
4842
 
4843
+ if (pathname === `${ROUTE_PREFIX}/adapt-unlock`) {
4844
+ // 待适配行「已适配,立即解锁」:无需等新版本——对已装模块重跑源码扫描,通过即移除禁用块。
4845
+ const rowId = typeof body.rowId === 'string' ? body.rowId : ''
4846
+ if (!/^[A-Za-z0-9_:.-]{1,80}$/u.test(rowId)) {
4847
+ sendError(res, 400, 'rowId 无效')
4848
+ return
4849
+ }
4850
+ const pending = readCompatPending()
4851
+ const entry = (pending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
4852
+ if (entry === undefined) {
4853
+ sendError(res, 404, '该行不在适配门待适配清单中(或已解锁)')
4854
+ return
4855
+ }
4856
+ const patchPath = findPatchPath(ctx)
4857
+ const profileDir = dirname(patchPath)
4858
+ let pkg = null
4859
+ let pkgDir = null
4860
+ let version = null
4861
+ try {
4862
+ const require = createRequire(join(profileDir, 'package.json'))
4863
+ const pkgPath = resolvePackageJson(entry.moduleName, profileDir)
4864
+ if (pkgPath !== null) {
4865
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
4866
+ version = typeof pkg?.version === 'string' ? pkg.version : null
4867
+ pkgDir = dirname(pkgPath)
4868
+ }
4869
+ } catch {}
4870
+ const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
4871
+ const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer, pkgDir) : { decision: 'unknown', reason: '无法读取包信息' }
4872
+ if (check.decision === 'fail') {
4873
+ sendError(res, 409, `适配校验未通过:${check.reason}`)
4874
+ return
4875
+ }
4876
+ await removeDisableBlock(patchPath, rowId)
4877
+ entry.status = 'adopted'
4878
+ entry.adoptedAt = Date.now()
4879
+ entry.adoptedVersion = version
4880
+ entry.adoptedFramework = fwVer
4881
+ entry.check = check.decision
4882
+ entry.checkNote = check.reason ?? null
4883
+ writeCompatPending(pending)
4884
+ sendJson(res, 200, { ok: true, rowId, adopted: true, check: check.decision, note: check.reason ?? null })
4885
+ return
4886
+ }
4887
+
4888
+ if (pathname === `${ROUTE_PREFIX}/adapt-unlock-all`) {
4889
+ // 全家桶「一键启用已适配」:对家庭内所有待适配行批量重跑源码扫描,通过的全部解锁,未通过保留禁用。
4890
+ const root = typeof body.root === 'string' ? body.root : ''
4891
+ if (!/^(@[a-z0-9-][a-z0-9-._~]*\/)?[a-z0-9-][a-z0-9-._~]*$/u.test(root) || root.length > 214) {
4892
+ sendError(res, 400, 'root 无效')
4893
+ return
4894
+ }
4895
+ const pending = readCompatPending()
4896
+ const patchPath = findPatchPath(ctx)
4897
+ const profileDir = dirname(patchPath)
4898
+ const targets = (pending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending' && (p.moduleName === root || String(p.moduleName).startsWith(root + '/')))
4899
+ if (targets.length === 0) {
4900
+ sendError(res, 404, '该全家桶没有待适配行(或已全部解锁)')
4901
+ return
4902
+ }
4903
+ const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
4904
+ const unlocked = []
4905
+ const kept = []
4906
+ for (const p of targets) {
4907
+ let pkg = null
4908
+ let pkgDir = null
4909
+ let version = null
4910
+ try {
4911
+ const pkgPath = resolvePackageJson(p.moduleName, profileDir)
4912
+ if (pkgPath !== null) {
4913
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
4914
+ version = typeof pkg?.version === 'string' ? pkg.version : null
4915
+ pkgDir = dirname(pkgPath)
4916
+ }
4917
+ } catch {}
4918
+ const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer, pkgDir) : { decision: 'fail', reason: '无法读取包信息' }
4919
+ if (check.decision === 'fail') {
4920
+ kept.push({ rowId: p.rowId, reason: check.reason })
4921
+ continue
4922
+ }
4923
+ await removeDisableBlock(patchPath, p.rowId)
4924
+ p.status = 'adopted'
4925
+ p.adoptedAt = Date.now()
4926
+ p.adoptedVersion = version
4927
+ p.adoptedFramework = fwVer
4928
+ p.check = check.decision
4929
+ p.checkNote = check.reason ?? null
4930
+ unlocked.push(p.rowId)
4931
+ }
4932
+ writeCompatPending(pending)
4933
+ sendJson(res, 200, { ok: true, root, unlocked, kept })
4934
+ return
4935
+ }
4936
+
4559
4937
  if (pathname === `${ROUTE_PREFIX}/check-update`) {
4560
4938
  // 检测已安装插件是否有新版本:curl registry 元数据取 dist-tags.latest(node 网络黑洞时 curl 可用)。
4561
4939
  // 聚合包(有 dependencies)额外对比子包版本:声明版本 vs 本地 node_modules 实际版本,
4562
4940
  // 返回 depsOutdated 提示"更新本包需同步子包",避免半更新混搭导致启动冲突。
4563
- const packageName = typeof body.packageName === 'string' ? body.packageName.trim() : ''
4941
+ const packageName = packageNameOf(typeof body.packageName === 'string' ? body.packageName.trim() : '')
4564
4942
  if (!packageName) {
4565
4943
  sendError(res, 400, 'packageName 不能为空')
4566
4944
  return
@@ -4656,7 +5034,29 @@ async function handle(ctx, req, res) {
4656
5034
  error = `npm registry 查询失败且 GitHub 回退不可用(${fallbackError};${fbErr instanceof Error ? fbErr.message : String(fbErr)})`
4657
5035
  }
4658
5036
  }
4659
- sendJson(res, 200, { ok: true, packageName, latest, next, beta, depsOutdated, error, source })
5037
+ // migrate 换名检测(2026-09-04 缺陷修复):本地包声明 dsh.migrate.to 时查目标包最新版/引擎声明,
5038
+ // 识别「项目已改名/迁移发布」的更新(如 @linxin666/dsh-web-ui-all → @linxin666/dsh-web-all 0.3.14)
5039
+ let migrate = null
5040
+ try {
5041
+ const localPath = resolvePackageJson(packageName, profileDir)
5042
+ if (localPath !== null) {
5043
+ const localPkg = JSON.parse(readFileSync(localPath, 'utf8'))
5044
+ const to = typeof localPkg.dsh?.migrate?.to === 'string' ? localPkg.dsh.migrate.to : null
5045
+ if (to !== null && to !== '' && to !== packageName) {
5046
+ const encTo = to.startsWith('@')
5047
+ ? `@${encodeURIComponent(to.slice(1).split('/')[0])}%2f${encodeURIComponent(to.split('/').slice(1).join('/'))}`
5048
+ : encodeURIComponent(to)
5049
+ const meta = await fetchJsonUrl(`https://registry.npmmirror.com/${encTo}`)
5050
+ const toLatest = meta?.['dist-tags']?.latest ?? meta?.['dist-tags']?.next ?? null
5051
+ const toPkg = toLatest !== null ? (meta?.versions?.[toLatest] ?? null) : null
5052
+ const engine = toPkg?.dsh?.engines?.dsh ?? toPkg?.engines?.dsh ?? null
5053
+ const fwVer = currentFrameworkVersion(ctx)
5054
+ const compatible = fwVer !== null && (engine === null || semverRangeMatchLoose(fwVer, engine))
5055
+ migrate = { to, latest: toLatest, engine, compatible }
5056
+ }
5057
+ }
5058
+ } catch {}
5059
+ sendJson(res, 200, { ok: true, packageName, latest, next, beta, depsOutdated, error, source, migrate })
4660
5060
  return
4661
5061
  }
4662
5062
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
4
4
  "description": "DSH plugin management panel & marketplace: one-click enable/disable, multi-source market (GitHub/Gitee/custom), static-index market (500+ plugins / 300 skills), skills, suites, and one-click framework upgrade.",
5
5
  "repository": {
6
6
  "type": "git",