dsh-m 0.8.5 → 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.en.md +24 -5
- package/README.md +24 -5
- package/docs/DESIGN.md +1 -1
- package/lib/cli.js +12 -0
- package/lib/client.js +18 -6
- package/lib/core/active-profile.js +73 -0
- package/lib/core/community.js +6 -5
- package/lib/core/env.js +14 -2
- package/lib/core/host-api.js +66 -10
- package/lib/core/market.js +21 -13
- package/lib/core/patch-yaml.js +1 -1
- package/lib/core/profile-ops.js +158 -0
- package/lib/core/registry-controller.js +15 -13
- package/lib/core/registry.js +34 -31
- package/lib/core/restart.js +3 -30
- package/lib/host.js +41 -3
- package/lib/tools.js +27 -3
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
English · [中文](./README.md)
|
|
10
10
|
|
|
11
|
-
A DeepSeek Harness (DSH) plugin marketplace with a customizable registry: **browse, install, uninstall, upgrade** — all local. Ships as a DSH web plugin: the "Plugin Marketplace" sidebar entry opens a three-view panel, backed by eight `dshm_*` agent tools and the `dshm` CLI.
|
|
11
|
+
A DeepSeek Harness (DSH) plugin marketplace with a customizable registry: **browse, install, uninstall, upgrade** — all local. Ships as a DSH web plugin: the "Plugin Marketplace" sidebar entry opens a three-view panel, backed by eight `dshm_*` agent tools and the `dshm` CLI. The same package installs into **both the Web (`web`) and official Desktop (`desktop`) profiles** (0.9.0+; capability table in the changelog).
|
|
12
12
|
|
|
13
13
|
<div align="center">
|
|
14
|
-
<img src="https://raw.githubusercontent.com/iasiv5/dsh-m/main/docs/images/marketplace.webp" alt="Plugin Marketplace panel — Market view:
|
|
15
|
-
<p><sub>The "Plugin Marketplace" panel · Market view
|
|
14
|
+
<img src="https://raw.githubusercontent.com/iasiv5/dsh-m/main/docs/images/marketplace.webp" alt="Plugin Marketplace panel — Market view: Community / Curated / Favorites zones, category chips, and plugin cards (verified / installed badges, npm & GitHub detail links)" width="100%">
|
|
15
|
+
<p><sub>The "Plugin Marketplace" panel · Market view (Community 4,200+ · Curated 23 · Favorites): click a card to install</sub></p>
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
18
|
## Quick start
|
|
@@ -38,10 +38,24 @@ The default registry includes DSH Skins, ModSearch, the Lark / QQ / Weixin / WeC
|
|
|
38
38
|
|---|---|
|
|
39
39
|
| **Market** | **Zoned** (0.7.0): **Community** (default landing, the 4,000+ full catalog) and **Curated** (hand-picked primary registry, single page) as two independent zones — each keeps its own category/search/sort/pagination state across tab switches; plus a **Favorites** zone (browser-local bookmarks with delisted-entry detection and one-click cleanup). Community zone: two-row collapsible category chips with sticky collapse, sort switch (downloads/stars/added × asc/desc), windowed pagination (24/48/96 per page), card byline (owner/downloads/stars); clicking a card opens a **detail modal** (superset of the card: download window, screenshot lightbox, capabilities collapsed by default, install command); npm sources pin the latest exact version, GitHub sources pin the release/tag commit |
|
|
40
40
|
| **Operations** | Install/upgrade/uninstall/toggle all flow through a global operation log (0.7.0): state lives off cards, surviving paging/searching/tab switches; persisted to localStorage and resumed after host reloads (each restored op re-validated "still applies" before executing); benignly-invalidated ops show as a neutral "skipped"; the Installed page gains "Update all (N)" batch queueing |
|
|
41
|
-
| **Installed** | What
|
|
41
|
+
| **Installed** | What the current profile (web / desktop, 0.9.0+) actually has, annotated "via market / non-market"; update badges, upgrade, two-step confirm uninstall; 📖 README preview (64KB cap); **live phase badge** (●active / ●failed / ○pending) and a **one-click toggle** (0.4.0: delegates to the official pluginManager service for live application; falls back to file-level edits + restart notice when absent; dsh-m itself and official host lifelines are locked) |
|
|
42
42
|
| **Settings** | Registry address draft with "Force refresh / Validate & apply / Restore default / Download default registry.json"; configured vs active address and status at a glance (shown only when abnormal); community catalog toggle; dsh-m self-update |
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
<div align="center">
|
|
45
|
+
<img src="https://raw.githubusercontent.com/iasiv5/dsh-m/main/docs/images/settings.webp" alt="Settings view — community catalog status card (catalog version / route / entries), curated registry configuration card (address draft with force-refresh / validate & apply / restore default / download), and the About card" width="86%">
|
|
46
|
+
<p><sub>Settings view · community catalog status, curated registry configuration, and dsh-m self-update</sub></p>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
The restart chain below applies to **Web only** (Desktop takes effect via the official app lifecycle: after installing, quit and reopen the Desktop app — see the 0.9.0 section). After any mutation, the already-open Market and Installed views refresh the profile state together, keeping badges and cards in sync without closing and reopening the marketplace; a "⚡ Restart" banner appears — under systemd, the DSH launcher's `appExit` hook hands the restart back to a unit configured with `Restart=on-failure` or `Restart=always`, avoiding a `systemctl` helper inside the unit cgroup that is about to stop; if `appExit` is unavailable, the fallback uses a manager-owned transient `systemd-run` service and only then a detached helper. The client confirms the replacement by boot id and dismisses the banner, leaving DSH Web's own background connection recovery in control; it does not force a full-page reload during the auth/route handoff. The restart chain has been live-verified on the current DSH Web `0.2.0-rc.2` (2026-10-01): dsh-m probes the unit's `Restart=` policy, hands the stop back via `appExit`, the `status=75/TEMPFAIL` exit is caught by the Restart policy and auto-started, and after recovery the page reconnects in the background with the panel fully functional. Historical scope: the `0.1.5-rc.1` era covered live `/dshm` ping and authenticated `303 → 200` checks; `0.1.2-rc.1` is covered by contract checks plus runtime shape-detection fallback (see the verified array) — with the deployment moved on, no live E2E is chased on retired generations; the transient `systemd-run` fallback only applies to hosts without `appExit` (all supported generations provide it) and stays a documented fallback rather than a release gate; repeated install/uninstall is covered by the profile-transaction test surface (compensation, post-install guard, 813 cases) plus the 0.4.0→0.4.2 consecutive-release evidence. Installs stream live pnpm progress (resolve → download → link → build).
|
|
50
|
+
|
|
51
|
+
### New in 0.9.0 — Official Desktop (dual profile) support
|
|
52
|
+
|
|
53
|
+
- **One package, two profiles**: dsh-m now installs into the official Desktop's `desktop` profile (`~/.dsh/profiles/desktop`) alongside the Web `web` profile; the catalog, panel, and agent tools are shared, and everything manages the host's current profile (official `profileContext` as the single source of truth).
|
|
54
|
+
- **Entrust admission to the host**: every `/dshm` method (including ping and unknown methods) delegates to the official `connection.requestRejection()` before any body is read (trustedHosts / loopback / cross-site / `Origin: null` semantics come from the host); rejected requests consume no body and call no business logic; hosts without the capability fail closed. **Behavior change**: the old home-grown "missing Origin → 403" guard is gone — Origin-less requests from the Desktop bridge are admitted per official semantics, and unauthenticated health probes now follow host trust instead of a blanket 403.
|
|
55
|
+
- **Desktop first-release capability table**: read-only market + **installing new packages** (delegated to the official `pluginManager.installBundle`; integrity, locking, and application phases stay with the official manager) + **toggles** (delegated; structured refusal when the service is absent, never a file-level fallback); **upgrade / uninstall / self-update / one-click restart** return structured 409 refusals with official-entry guidance (no official upgrade API exists; restarts belong to the Electron lifecycle); build scripts retry with the exact official `pendingBuilds` list — never an allow-all.
|
|
56
|
+
- **Per-profile read model and cache**: market installed-badges, the installed list, and README previews only read the current profile; registry / community / accepted-source caches are segmented per profile (web keeps its legacy paths — zero migration, zero clearing); favorites and operation logs stay per browser origin and do not sync between Web and Desktop.
|
|
57
|
+
- **CLI always targets the web profile**: `--profile web` is explicit; `--profile desktop` is rejected with a pointer to the official Desktop plugin management page.
|
|
58
|
+
- **Honest limitations**: Desktop on-device (Win/macOS) E2E has not been run; the `registry.json` verified arrays gain **no** Desktop generation (to be recorded after real testing); dsh-m's file-level post-install guard is skipped on Desktop (app.asar probing blind spot) and replaced by official result checks plus a `listBundles` re-read.
|
|
45
59
|
|
|
46
60
|
### Fixed in 0.8.5
|
|
47
61
|
|
|
@@ -156,6 +170,8 @@ dshm restart --yes
|
|
|
156
170
|
|
|
157
171
|
When the registry is unavailable, `registry` / `search` / `outdated` print the configured vs active address and exit 1; `list` still shows installed plugins. The CLI uses its own cache namespace and never touches the Web side's.
|
|
158
172
|
|
|
173
|
+
**Profile target (0.9.0)**: the CLI always acts on the web profile — `--profile web` makes it explicit; `--profile desktop` is rejected outright (Desktop plugin management belongs to the official Desktop plugin management page).
|
|
174
|
+
|
|
159
175
|
## Registry
|
|
160
176
|
|
|
161
177
|
`registry.json` is hand-curated and fetched at runtime in order: **GitHub raw (`@main`) → GitHub mirror (jsDelivr CDN, backup line) → local 60-min TTL cache → bundled snapshot** — listing updates are decoupled from plugin releases. To add or amend a listing, edit `registry.json` and open a PR; CI validates the strict schema, npm/GitHub existence, duplicate ids and URL reachability.
|
|
@@ -202,6 +218,9 @@ Release: `npm version patch|minor|major && git push --tags` → OIDC trusted pub
|
|
|
202
218
|
**1. Why don't GitHub-sourced update hints follow main?**
|
|
203
219
|
Intermediate commits on main can be unstable. dsh-m tracks **releases / tags** only (`releases/latest` first, tags list as fallback) and pins the commit SHA the tag points to.
|
|
204
220
|
|
|
221
|
+
**1.5 What works on the official Desktop?**
|
|
222
|
+
Desktop (the `desktop` profile) first release supports: browsing the market, **installing new packages**, and plugin toggles; upgrade/uninstall/self-update/one-click restart return structured refusals with official-entry guidance (official plugin management page / Desktop app restart). Installed badges and the installed list reflect only what Desktop itself has installed; favorites and operation logs do not sync with the Web side.
|
|
223
|
+
|
|
205
224
|
**2. Does uninstalling dsh-m delete my data?**
|
|
206
225
|
No. Only the package reference in the profile is removed (live UI disabled first), and suspected leftover paths are reported to you.
|
|
207
226
|
|
package/README.md
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
[English](./README.en.md) · 中文
|
|
10
10
|
|
|
11
|
-
可自定义收录清单(Registry)的 DeepSeek Harness (DSH) 插件市场:**收录 · 安装 · 卸载 · 升级**,全部本机完成。以 DSH web 插件形态运行——侧栏「插件市场」打开三视图面板,同时提供 `dshm_*` agent 工具与 `dshm` CLI
|
|
11
|
+
可自定义收录清单(Registry)的 DeepSeek Harness (DSH) 插件市场:**收录 · 安装 · 卸载 · 升级**,全部本机完成。以 DSH web 插件形态运行——侧栏「插件市场」打开三视图面板,同时提供 `dshm_*` agent 工具与 `dshm` CLI;同一包装入 **Web(`web`)与官方 Desktop(`desktop`)两个 profile**(0.9.0 起,能力表见变更节)。
|
|
12
12
|
|
|
13
13
|
<div align="center">
|
|
14
|
-
<img src="https://raw.githubusercontent.com/iasiv5/dsh-m/main/docs/images/marketplace.webp" alt="
|
|
15
|
-
<p><sub>侧栏「插件市场」·
|
|
14
|
+
<img src="https://raw.githubusercontent.com/iasiv5/dsh-m/main/docs/images/marketplace.webp" alt="侧栏「插件市场」面板——市场视图:社区 / 精选 / 收藏三分区、分类 chips 与插件卡片(已实测 / 已安装徽标、npm 与 GitHub 详情链接)" width="100%">
|
|
15
|
+
<p><sub>侧栏「插件市场」· 市场视图:社区 4,200+ · 精选 23 · 收藏,卡片即点即装</sub></p>
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
18
|
## 30 秒上手
|
|
@@ -38,10 +38,24 @@
|
|
|
38
38
|
|---|---|
|
|
39
39
|
| **市场** | **分区制**(0.7.0):**社区**(默认落地页,4,000+ 条全量目录)与**精选**(手工策展主清单,单页直出)两个独立分区,各有自己的分类/搜索/排序/分页状态(切 tab 互不重置);另有**收藏**分区(浏览器本地书签,自动检测下架条目并一键清理)。社区区:分类 chips 两行折叠 + 吸顶收缩、排序(下载量/Star/收录日期 × 升降)、页码窗口化分页(24/48/96 每页)、卡片 byline(作者/下载量/Star);卡片点开**详情 Modal**(卡片信息超集:下载量窗口三要素、截图灯箱、能力披露默认收起、安装命令);npm 源锁定最新精确版本,GitHub 源锁定 release/tag 指向的 commit |
|
|
40
40
|
| **操作记录** | 安装/升级/卸载/开关全部走全局操作记录(0.7.0):状态不挂卡片,翻页/搜索/切 tab 不丢;localStorage 持久化,宿主重载后自动恢复未完成操作(逐条校验「此刻仍成立才执行」);良性前提消失以中性「已跳过」呈现;已装页支持「全部更新 (N)」批量入队 |
|
|
41
|
-
| **已装** |
|
|
41
|
+
| **已装** | 当前 profile(web / desktop,0.9.0 起)实装列表,标注「市场安装 / 非市场安装」;可升级徽标、升级、两段式确认卸载;📖 README 预览(64KB 截断);**运行相位徽标**(●active / ●failed / ○pending)与**一键开关**(0.4.0:委派官方 pluginManager 服务活体生效,服务缺席时文件级编辑 + 重启提示;dsh-m 自身与官方宿主命脉锁定不可开关) |
|
|
42
42
|
| **设置** | registry 地址草稿 +「强制刷新 / 校验并应用 / 恢复默认 / 下载默认 registry.json」;配置地址、生效来源与状态一目了然(仅异常时提示);社区目录开关;dsh-m 自更新 |
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
<div align="center">
|
|
45
|
+
<img src="https://raw.githubusercontent.com/iasiv5/dsh-m/main/docs/images/settings.webp" alt="设置视图——社区清单状态卡(目录版本 / 获取线路 / 收录条目)、精选清单配置卡(地址草稿与强制刷新 / 校验并应用 / 恢复默认 / 下载默认清单)与关于卡" width="86%">
|
|
46
|
+
<p><sub>设置视图 · 社区清单状态、精选清单配置与 dsh-m 自更新</sub></p>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
以下重启链路仅适用于 **Web**(Desktop 的生效走官方应用生命周期:安装完成后退出并重新打开 Desktop 应用即可,见 0.9.0 节)。安装 / 卸载 / 升级完成后,当前已打开的市场页与已装页会一起重新读取 profile 状态并同步徽标/卡片,不需要关闭后重新打开插件市场;随后出现「⚡ 一键重启」横幅——受 systemd 管理时通过 DSH launcher 的 `appExit` 交给服务的 `Restart` 策略,避免在待停止 unit 的 cgroup 内启动 `systemctl` helper;无 `appExit` 的 systemd 兜底改用 manager-owned transient `systemd-run`,最后才退回 detached-helper。客户端按 boot id 确认新进程已恢复后关闭横幅,交由 DSH Web 自身的后台连接重试恢复页面,不强制整页刷新,避免认证/路由切换期间白屏。重启链路已在当前 DSH Web `0.2.0-rc.2` 实机核验(2026-10-01):dsh-m 探测 unit `Restart=` 策略后经 `appExit` 交还 systemd,`status=75/TEMPFAIL` 退出由 Restart 策略接住自动拉起,服务恢复后页面后台重连、面板全功能可用。历史口径:`0.1.5-rc.1` 时代已核验 `/dshm` ping 与带认证 `303 → 200`;`0.1.2-rc.1` 按契约核对 + 形态探测兜底收录(verified 数组),部署面演进后不再追旧代 live E2E;transient `systemd-run` 兜底仅适用于无 `appExit` 的宿主(受支持代际均提供 `appExit`),保持设计兜底而非发布门槛;连续安装/卸载由 profile 事务测试面(补偿事务、装后守卫等 813 用例)与 0.4.0→0.4.2 连续发布实证覆盖。安装过程实时显示 pnpm 进度(解析 → 下载 → 链接 → 构建)。
|
|
50
|
+
|
|
51
|
+
### 0.9.0 新增:官方 Desktop(双 profile)支持
|
|
52
|
+
|
|
53
|
+
- **同一包、两个 profile**:dsh-m 现在可装入官方 Desktop 的 `desktop` profile(`~/.dsh/profiles/desktop`),与 Web 的 `web` profile 并列;市场目录、面板与 agent 工具同一套,管理对象始终是宿主当前 profile(官方 `profileContext` 单一事实源)。
|
|
54
|
+
- **入口信任检查改委派官方**:`/dshm` 全部 method(含 ping 与未知 method)在读取请求体之前委派官方 `connection.requestRejection()` 判定(trustedHosts / loopback / 跨站 / `Origin: null` 语义随宿主),被拒请求零 body 消耗、零业务调用;宿主缺该能力时 fail-closed 全拒。**行为变化**:旧版自制守卫「缺 Origin 一律 403」不再存在——Desktop 桥合法剥除 Origin 的请求按官方语义放行,无凭据的健康检查探针从「一律 403」变为「按宿主信任判定」。
|
|
55
|
+
- **Desktop 首发能力表**:只读市场 + **安装新包**(委派官方 `pluginManager.installBundle`,完整性/锁/生效相位归官方)+ **开关**(委派官方管理器,服务缺席结构化拒绝、绝不文件级 fallback);**升级 / 卸载 / dsh-m 自更新 / 一键重启**在 Desktop 结构化拒绝(409,带官方入口指引——官方暂无 upgrade API,重启归 Electron 生命周期);构建脚本按官方 `pendingBuilds` 名单精确重试,绝不全量放行。
|
|
56
|
+
- **读模型与缓存按 profile 隔离**:市场安装标注、已装列表、README 预览只读当前 profile;registry / 社区清单 / accepted-source 缓存按 profile 分段(web 沿用旧路径,零迁移零清空);收藏与操作记录按浏览器 origin 各自独立,Web 与 Desktop 不自动同步。
|
|
57
|
+
- **CLI 恒作用于 web profile**:`--profile web` 显式声明;`--profile desktop` 明确拒绝并指引官方 Desktop 插件管理页。
|
|
58
|
+
- **如实声明**:Desktop 实机(Win/macOS)E2E 未跑,`registry.json` verified 数组**不新增** Desktop 代际(实测后按收录纪律补录);Desktop 下不做 dsh-m 文件级装后守卫(app.asar 打包布局探测盲区),以官方结果判定 + `listBundles` 复读替代。
|
|
45
59
|
|
|
46
60
|
### 0.8.5 修复
|
|
47
61
|
|
|
@@ -156,6 +170,8 @@ dshm restart --yes
|
|
|
156
170
|
|
|
157
171
|
清单不可用时 `registry` / `search` / `outdated` 打印配置与实际生效地址并退出码 1;`list` 仍列出已装插件。CLI 固定独立缓存命名空间,不影响 Web 端。
|
|
158
172
|
|
|
173
|
+
**profile 目标(0.9.0)**:CLI 恒作用于 web profile——`--profile web` 为显式声明;`--profile desktop` 直接拒绝(Desktop profile 的插件管理走官方 Desktop 插件管理页)。
|
|
174
|
+
|
|
159
175
|
## 收录清单(registry)
|
|
160
176
|
|
|
161
177
|
`registry.json` 手工 curated,运行时按 **GitHub 原始文件(raw @main)→ GitHub 镜像(jsDelivr CDN,备用线路)→ 本地 60 分钟 TTL 缓存 → 包内快照** 的顺序获取——收录更新与插件发版**解耦**,push 后最多等一个缓存周期(可在设置页强制刷新)。收录 / 修订直接改 `registry.json` 发 PR,CI 自动校验:严格 schema、npm 包与 GitHub 仓库存在性、重复 id、URL 可达性。
|
|
@@ -202,6 +218,9 @@ node scripts/validate-registry.mjs
|
|
|
202
218
|
**1. 为什么 GitHub 来源的更新提示不走 main HEAD?**
|
|
203
219
|
main 上的中间提交可能不稳定。dsh-m 只跟踪 **release / tag**(优先 `releases/latest`,无 release 回退 tags 列表),安装时锁定 tag 指向的 commit SHA。
|
|
204
220
|
|
|
221
|
+
**1.5 官方 Desktop 上能用哪些功能?**
|
|
222
|
+
Desktop(desktop profile)首发支持:浏览市场、**安装新包**、插件开关;升级/卸载/自更新/一键重启会返回结构化拒绝并指引官方入口(官方插件管理页 / Desktop 应用重启)。市场安装标注、已装列表只反映 Desktop 自己装了什么;与 Web 端的收藏、操作记录互不同步。
|
|
223
|
+
|
|
205
224
|
**2. 卸载 dsh-m 会删我的数据吗?**
|
|
206
225
|
不会。只移除 profile 中的包引用(卸载前先下线运行中的界面),并把疑似残留路径报告给你。
|
|
207
226
|
|
package/docs/DESIGN.md
CHANGED
|
@@ -119,7 +119,7 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
|
|
|
119
119
|
- **卸载**(事务定序写死):validate(严格读取:NOT_INSTALLED / NOT_DSH_PLUGIN / PLUGIN_METADATA_UNREADABLE,全部零写入零快照)→ 快照 → live-disable(先让 client bundle 下线,避免 404;回滚时尽力反向)→ 摘除该包补丁条目(`pnpm-workspace.yaml` 顶层 `patchedDependencies` 与 `package.json#pnpm.patchedDependencies`;残留条目会令 pnpm 以 `ERR_PNPM_UNUSED_PATCH` 整单失败,只精确匹配 `pkg` / `pkg@ver`,补丁文件本体保留并计入残留报告)→ remove → verify gone(仍在则回滚)。**不清理插件产生的数据/配置**,但把检测到的疑似残留路径(如 `~/.dsh/<plugin>.json`)列出报告。
|
|
120
120
|
- **升级**:**按需检查**(`dshm_outdated` / `dshm_list` 时实时比对本地版本 vs npm latest / GitHub release,半自动——展示升级计划,确认后执行,即重开一次安装事务)。**不做后台定时器**。
|
|
121
121
|
- **自更新**:dsh-m 对自己同样做版本比对 + 提示升级(设置页呈现);执行即 `install-npm` 事务(integrity 缺失直接拒绝,不进事务)。
|
|
122
|
-
- **开关(0.4.0,术语见
|
|
122
|
+
- **开关(0.4.0,术语见 GLOSSARY.md「开关/行覆盖/Bundle 选择/委派降级」)**:已装插件运行状态的可逆切换,**不经 Profile 变更事务**。粒度自动路由——包内补丁层单 insert 行且无配置补丁行 → 行覆盖(`cordis.patch.yml` 保注释 `disabled` 编辑,官方 `writePluginEnabled` 同款语义,启用写显式 `disabled: false`);多行/不可解析 → Bundle 选择(`dsh.profile.bundles` 数组增删)。写路径委派优先:运行时探测官方 `pluginManager` 服务(`ctx.get`,按存在性不判版本号)→ `setPluginEnabled`/`setBundleEnabled`(白拿保护判定、unaddressable 识别、hmr 活体重组;applied 以复读 `listPlugins()` 为准);服务缺席降级 loader `entry.update()` 直操作 + 锁内文件编辑(bundle 级恒 restart-required)。**必须持官方同款锁**(`withFileLock(<profile>/package.json)`)防与官方 UI 并发丢更新。架构决策见 [ADR-0001](./adr/0001-delegate-with-fallback-for-plugin-manager.md)。
|
|
123
123
|
- **运行相位(0.4.0)**:loader fiber 状态投影(active/failed/pending/loading/unloading;fiber 缺失即已停用),读路径始终自读 loader entries,读路径零服务依赖。
|
|
124
124
|
- **保护名单(0.4.0)**:`dsh-m` 自身 + 逐字镜像官方 `protectedModules` 16 项全集(锚点 `dsh-plugin-manager@0.1.7-rc.2 lib/index.js L1077`;DSH 升级后 diff 官方名单)。作用于**开关与卸载**(保护门为开关路径最先判定,纯名单检查先于在装枚举);升级不受影响;委派路径信任官方 `readOnlyReason`。
|
|
125
125
|
- **peer 兼容预检(0.4.0)**:install/upgrade 事务外 metadata 阶段,校验 `@deepseek-ai/dsh(-*)` peers 与运行时版本(`workspace:^/~/ *` 视为运行时版本;semver includePrerelease;官方 `evaluatePluginCompatibility` 同语义)。运行时版本解析失败或预检元数据读取失败 → 不拦(如实标注未检);不兼容 → GUI 弹确认 / agent 回结构化 issue / CLI `--force`;无豁免机制(确认即通道);GitHub 源明示 `compatSkipped`。
|
package/lib/cli.js
CHANGED
|
@@ -138,6 +138,9 @@ const HELP = `dshm — DSH Marketplace(DSH 插件市场:精选策展 + 社
|
|
|
138
138
|
dshm restart --yes
|
|
139
139
|
注意:变更互斥仅在进程内生效——变更执行期间不要同时从 GUI / Agent 工具发起另一次变更。
|
|
140
140
|
|
|
141
|
+
profile 目标(0.9.0):CLI 恒作用于 web profile;--profile web 为显式声明,
|
|
142
|
+
--profile desktop 会被拒绝——Desktop profile 的插件管理走官方 Desktop 插件管理页。
|
|
143
|
+
|
|
141
144
|
环境变量:DSHM_REGISTRY_URL(registry 源覆盖)、DSHM_TIMEOUT_MS、DSHM_CACHE_TTL_MIN、DSHM_CACHE_DIR、
|
|
142
145
|
DSHM_COMMUNITY_CATALOG=0(退出社区清单)、DSHM_COMMUNITY_CATALOG_PIN(锁定社区目录版本)
|
|
143
146
|
`;
|
|
@@ -166,6 +169,15 @@ async function runCliDispatch(argv, deps, io) {
|
|
|
166
169
|
};
|
|
167
170
|
const { cmd, flags } = parseArgs(argv);
|
|
168
171
|
const cfg = cliConfig();
|
|
172
|
+
// 0.9.0 双 profile(ADR-0005):CLI 是 Web-only 入口(独立进程拿不到宿主 profileContext,
|
|
173
|
+
// 目标恒为 web profile);--profile desktop / 其他非 web 值显式拒绝并指引官方入口,
|
|
174
|
+
// 绝不隐式回落 web(报告 §5.3「不能从不存在的 Desktop 模式推断」)。
|
|
175
|
+
const profileFlag = typeof flags.profile === 'string' ? flags.profile.trim() : '';
|
|
176
|
+
if (profileFlag !== '' && profileFlag !== 'web') {
|
|
177
|
+
err(`错误:dshm CLI 仅作用于 web profile,不支持 --profile ${profileFlag}。`);
|
|
178
|
+
err('Desktop profile 的插件管理请使用官方 Desktop 的插件管理页(Settings → Plugins),或回到 DSH Web 端使用 dsh-m。');
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
169
181
|
switch (cmd) {
|
|
170
182
|
case 'help':
|
|
171
183
|
case '--help':
|
package/lib/client.js
CHANGED
|
@@ -1219,6 +1219,8 @@ var ZH = {
|
|
|
1219
1219
|
"restart.failed": "\u91CD\u542F\u5931\u8D25\uFF1A{err}",
|
|
1220
1220
|
"restart.timeout": "\u91CD\u542F\u8D85\u65F6\uFF0C\u8BF7\u624B\u52A8\u68C0\u67E5 dsh web \u670D\u52A1\u72B6\u6001",
|
|
1221
1221
|
"restart.hint.done": "\u5DF2\u8BF7\u6C42\u91CD\u542F DSH web\uFF08via {via}\uFF09\u3002\u670D\u52A1\u6062\u590D\u540E DSH Web \u4F1A\u5728\u540E\u53F0\u81EA\u52A8\u91CD\u8FDE\u3002",
|
|
1222
|
+
"profile.restartHint": "\u53D8\u66F4\u5B8C\u6210\u3002Desktop \u63D2\u4EF6\u7531\u5B98\u65B9\u5E94\u7528\u7BA1\u7406\uFF1A\u8BF7\u9000\u51FA\u5E76\u91CD\u65B0\u6253\u5F00 Desktop \u5E94\u7528\uFF08\u5173\u95ED\u7A97\u53E3\u53EF\u80FD\u53EA\u662F\u9690\u85CF\uFF09\u4EE5\u52A0\u8F7D\u65B0\u72B6\u6001\u3002",
|
|
1223
|
+
"profile.chipTitle": "\u5F53\u524D DSH profile\uFF1A{name}\uFF08Desktop \u9996\u53D1\u4EC5\u652F\u6301\u53EA\u8BFB\u5E02\u573A\u3001\u5B89\u88C5\u65B0\u5305\u4E0E\u5F00\u5173\uFF09",
|
|
1222
1224
|
"phase.resolving": "\u89E3\u6790\u4F9D\u8D56",
|
|
1223
1225
|
"phase.downloading": "\u4E0B\u8F7D",
|
|
1224
1226
|
"phase.linking": "\u94FE\u63A5\u5B89\u88C5",
|
|
@@ -1472,6 +1474,8 @@ var EN = {
|
|
|
1472
1474
|
"restart.failed": "Restart failed: {err}",
|
|
1473
1475
|
"restart.timeout": "Restart timed out \u2014 check the dsh web service manually",
|
|
1474
1476
|
"restart.hint.done": "Restart requested (via {via}). DSH Web will reconnect in the background after the service returns.",
|
|
1477
|
+
"profile.restartHint": "Changes applied. Desktop plugins are managed by the official app: quit and reopen the Desktop app (closing the window may only hide it) to load the new state.",
|
|
1478
|
+
"profile.chipTitle": "Current DSH profile: {name} (Desktop first release supports read-only market, installing new packages, and toggles)",
|
|
1475
1479
|
"phase.resolving": "Resolving",
|
|
1476
1480
|
"phase.downloading": "Downloading",
|
|
1477
1481
|
"phase.linking": "Linking",
|
|
@@ -1913,7 +1917,7 @@ function TwoStepButton({ label, confirmLabel, className, onConfirm, disabled })
|
|
|
1913
1917
|
arm ? confirmLabel : label
|
|
1914
1918
|
);
|
|
1915
1919
|
}
|
|
1916
|
-
function RestartBanner({ note, onDone }) {
|
|
1920
|
+
function RestartBanner({ note, onDone, desktop }) {
|
|
1917
1921
|
const [phase, setPhase] = useState("idle");
|
|
1918
1922
|
const [err, setErr] = useState(null);
|
|
1919
1923
|
const restart = useCallback(async () => {
|
|
@@ -1956,7 +1960,7 @@ function RestartBanner({ note, onDone }) {
|
|
|
1956
1960
|
{ className: "dshm-banner-text" },
|
|
1957
1961
|
phase === "restarting" ? lookup("restart.doing") : phase === "waiting" ? lookup("restart.waiting") : err ? lookup("restart.failed", { err }) : note || lookup("banner.done")
|
|
1958
1962
|
),
|
|
1959
|
-
phase === "idle" && !err ? h("button", { className: "dshm-btn primary sm", onClick: restart }, lookup("restart.now")) : null,
|
|
1963
|
+
phase === "idle" && !err && !desktop ? h("button", { className: "dshm-btn primary sm", onClick: restart }, lookup("restart.now")) : null,
|
|
1960
1964
|
phase === "restarting" || phase === "waiting" ? Spin() : null,
|
|
1961
1965
|
phase === "idle" && err ? h("button", { className: "dshm-btn sm", onClick: () => onDone(false) }, lookup("common.ok")) : null,
|
|
1962
1966
|
phase === "idle" && !err ? h("button", { className: "dshm-btn sm", onClick: () => onDone(false) }, lookup("common.later")) : null
|
|
@@ -3332,10 +3336,13 @@ function MarketPanel({ onClose }) {
|
|
|
3332
3336
|
removeIds: useCallback((ids) => setFavList(favStore.removeIds(ids)), [favStore])
|
|
3333
3337
|
};
|
|
3334
3338
|
const [pluginVersion, setPluginVersion] = useState(null);
|
|
3339
|
+
const [profile, setProfile] = useState(null);
|
|
3335
3340
|
useEffect(() => {
|
|
3336
3341
|
let live = true;
|
|
3337
3342
|
api("ping").then((r) => {
|
|
3338
|
-
if (live)
|
|
3343
|
+
if (!live) return;
|
|
3344
|
+
setPluginVersion(typeof r?.version === "string" && r.version ? r.version : null);
|
|
3345
|
+
setProfile(r?.profile && typeof r.profile === "object" ? r.profile : null);
|
|
3339
3346
|
}).catch(() => {
|
|
3340
3347
|
});
|
|
3341
3348
|
return () => {
|
|
@@ -3421,8 +3428,12 @@ function MarketPanel({ onClose }) {
|
|
|
3421
3428
|
}, [toast]);
|
|
3422
3429
|
const notify = useCallback(({ kind, text, needsRestart }) => {
|
|
3423
3430
|
setToast({ kind, text });
|
|
3424
|
-
if (kind === "ok" && needsRestart)
|
|
3425
|
-
|
|
3431
|
+
if (kind === "ok" && needsRestart) {
|
|
3432
|
+
setBanner(
|
|
3433
|
+
profile && profile.kind === "desktop" ? { desktop: true, text: lookup("profile.restartHint") } : { text: lookup("banner.done") }
|
|
3434
|
+
);
|
|
3435
|
+
}
|
|
3436
|
+
}, [profile]);
|
|
3426
3437
|
return h(
|
|
3427
3438
|
"div",
|
|
3428
3439
|
{ className: full ? "dshm-overlay full" : "dshm-overlay", onClick: onClose },
|
|
@@ -3456,6 +3467,7 @@ function MarketPanel({ onClose }) {
|
|
|
3456
3467
|
),
|
|
3457
3468
|
h("span", { className: "dshm-spacer" }),
|
|
3458
3469
|
pluginVersion ? h(DshmVersionChip, { version: pluginVersion }) : null,
|
|
3470
|
+
profile && profile.kind !== "web" ? h("span", { className: "dshm-dshchip", title: lookup("profile.chipTitle", { name: profile.name }) }, profile.name) : null,
|
|
3459
3471
|
// 窗口控制组(0.7.7):最大化/还原 + 关闭,连体设计(系统化窗口按钮)
|
|
3460
3472
|
h(
|
|
3461
3473
|
"div",
|
|
@@ -3498,7 +3510,7 @@ function MarketPanel({ onClose }) {
|
|
|
3498
3510
|
{ className: toast.kind === "err" ? "dshm-banner err" : "dshm-banner" },
|
|
3499
3511
|
h("span", { className: "dshm-banner-text" }, toast.text)
|
|
3500
3512
|
) : null,
|
|
3501
|
-
banner ? h(RestartBanner, { note: banner.text, onDone: () => setBanner(null) }) : null
|
|
3513
|
+
banner ? h(RestartBanner, { note: banner.text, onDone: () => setBanner(null), desktop: banner.desktop === true }) : null
|
|
3502
3514
|
)
|
|
3503
3515
|
);
|
|
3504
3516
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActiveProfile(0.9.0 双 profile,ADR-0005):宿主当前 profile 的单一事实源。
|
|
3
|
+
*
|
|
4
|
+
* 事实源是官方 `ctx.get('profileContext')`(@deepseek-ai/dsh-app-boot,仅 dsh 启动的
|
|
5
|
+
* profile 存在);缺席(旧宿主 / 测试 / CLI)时降级 fallback = web 推导目录,且 source
|
|
6
|
+
* 如实标注 'fallback'。kind 决定能力表:desktop 只放行 install/set-enabled;
|
|
7
|
+
* 未知名一律拒写(报告 §4「对未知 profile 显式拒写」)。不得从页面 Origin、DSH_HOME、
|
|
8
|
+
* process.cwd() 或文件存在性猜 profile(报告 §4 同业旁证 #655/#744)。
|
|
9
|
+
*/
|
|
10
|
+
import { webProfileDir, WEB_PROFILE } from './env.js';
|
|
11
|
+
/** Desktop 首发能力表(ADR-0005):只读市场 + 安装新包 + 开关;其余结构化拒绝并指引官方入口。 */
|
|
12
|
+
const DESKTOP_ALLOWED = new Set(['install', 'set-enabled']);
|
|
13
|
+
/** 结构化拒绝的官方入口指引(zh;GUI/工具/HTTP 三入口共用同一文案源)。 */
|
|
14
|
+
export const WRITE_GUIDANCE = {
|
|
15
|
+
install: '',
|
|
16
|
+
'set-enabled': '',
|
|
17
|
+
upgrade: 'Desktop 暂不支持在市场内升级插件:请在官方 Desktop 的插件管理(Settings → Plugins)检查更新,或回到 DSH Web 使用 dsh-m 升级。',
|
|
18
|
+
uninstall: 'Desktop 暂不支持在市场内卸载插件:请使用官方 Desktop 的插件管理(Settings → Plugins)卸载。',
|
|
19
|
+
'self-upgrade': 'Desktop 暂不支持 dsh-m 自更新:请在 DSH Web 端执行,或关注官方 Desktop 的插件更新入口。',
|
|
20
|
+
restart: 'Desktop 不由 dsh-m 重启:请退出并重新打开 Desktop 应用(关闭窗口可能只是隐藏),让新安装的插件生效。',
|
|
21
|
+
};
|
|
22
|
+
export class ProfileUnsupportedError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
action;
|
|
25
|
+
profile;
|
|
26
|
+
guidance;
|
|
27
|
+
constructor(action, profile) {
|
|
28
|
+
const guidance = WRITE_GUIDANCE[action];
|
|
29
|
+
super(`当前 profile(${profile.name})不支持该操作(${action})。${guidance}`);
|
|
30
|
+
this.name = 'ProfileUnsupportedError';
|
|
31
|
+
this.code = 'unsupported-on-profile';
|
|
32
|
+
this.action = action;
|
|
33
|
+
this.profile = profile.name;
|
|
34
|
+
this.guidance = guidance;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function kindOf(name) {
|
|
38
|
+
if (name === 'desktop')
|
|
39
|
+
return 'desktop';
|
|
40
|
+
if (name === WEB_PROFILE)
|
|
41
|
+
return 'web';
|
|
42
|
+
return 'unknown';
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 守卫式解析:宿主注入 profileContext → 事实取宿主;缺席/抛错/畸形 → fallback web。
|
|
46
|
+
* 在 host 生命周期内只应调用一次(host.ts apply 期),结果作为不可变对象传递。
|
|
47
|
+
*/
|
|
48
|
+
export function resolveActiveProfile(ctx) {
|
|
49
|
+
let pc;
|
|
50
|
+
try {
|
|
51
|
+
const getter = ctx?.get;
|
|
52
|
+
if (typeof getter === 'function') {
|
|
53
|
+
pc = getter.call(ctx, 'profileContext');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
pc = undefined;
|
|
58
|
+
}
|
|
59
|
+
const name = typeof pc?.name === 'string' && pc.name.trim() !== '' ? pc.name.trim() : undefined;
|
|
60
|
+
const dir = typeof pc?.dir === 'string' && pc.dir.trim() !== '' ? pc.dir.trim() : undefined;
|
|
61
|
+
if (pc === undefined || pc === null || name === undefined || dir === undefined) {
|
|
62
|
+
return { name: WEB_PROFILE, kind: 'web', dir: webProfileDir(), source: 'fallback' };
|
|
63
|
+
}
|
|
64
|
+
return { name, kind: kindOf(name), dir, source: 'host' };
|
|
65
|
+
}
|
|
66
|
+
/** 能力表守卫:违反即抛结构化 ProfileUnsupportedError(HTTP 409 / 工具 error result 共用)。 */
|
|
67
|
+
export function assertWriteAllowed(profile, action) {
|
|
68
|
+
if (profile.kind === 'web')
|
|
69
|
+
return;
|
|
70
|
+
if (profile.kind === 'desktop' && DESKTOP_ALLOWED.has(action))
|
|
71
|
+
return;
|
|
72
|
+
throw new ProfileUnsupportedError(action, profile);
|
|
73
|
+
}
|
package/lib/core/community.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { mkdir, readFile, rm, rename, writeFile } from 'node:fs/promises';
|
|
10
10
|
import { isAbsolute, join, relative } from 'node:path';
|
|
11
|
-
import {
|
|
11
|
+
import { cacheRoot } from './env.js';
|
|
12
12
|
import { decodeUtf8Fatal, fetchBytesLimited, describeFetchFailure } from './httpx.js';
|
|
13
13
|
import { isExactVersion, npmLatest } from './versions.js';
|
|
14
14
|
import { communityOutcome } from './market.js';
|
|
@@ -106,8 +106,9 @@ const ROUTE_LABELS = ['jsdelivr', 'npmmirror', 'unpkg'];
|
|
|
106
106
|
const META_LATEST = 'meta.json';
|
|
107
107
|
const metaPinName = (v) => `meta-pin-${v}.json`;
|
|
108
108
|
const bodyName = (v) => `catalog-${v}.json`;
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
/** 社区缓存目录(0.9.0 双 profile:web=旧路径,非 web 加 `<root>/<profile>` 段)。 */
|
|
110
|
+
function awesomeDir(namespace, profile = 'web') {
|
|
111
|
+
return join(cacheRoot(profile), namespace, 'awesome');
|
|
111
112
|
}
|
|
112
113
|
/** 路径 containment:目标必须仍在 dir 内(防御拼接穿越)。 */
|
|
113
114
|
function contained(dir, p) {
|
|
@@ -198,7 +199,7 @@ function trimPin(cfg) {
|
|
|
198
199
|
}
|
|
199
200
|
async function runChain(cfg, opts, ctrl) {
|
|
200
201
|
const namespace = opts.namespace ?? 'host';
|
|
201
|
-
const dir = awesomeDir(namespace);
|
|
202
|
+
const dir = awesomeDir(namespace, opts.profile);
|
|
202
203
|
const pin = trimPin(cfg);
|
|
203
204
|
const budgetMs = opts.flightBudgetMs ?? COMMUNITY_CHAIN_BUDGET_MS;
|
|
204
205
|
const startedAt = Date.now();
|
|
@@ -341,7 +342,7 @@ function flightKey(opts, pin) {
|
|
|
341
342
|
const routesSig = opts.routes
|
|
342
343
|
? `${opts.routes.registryBase ?? ''}|${(opts.routes.fileBases ?? []).join(',')}`
|
|
343
344
|
: 'default';
|
|
344
|
-
return [ns, pin ?? '', opts.force ? 'force' : 'normal', routesSig].join('§');
|
|
345
|
+
return [ns, opts.profile ?? 'web', pin ?? '', opts.force ? 'force' : 'normal', routesSig].join('§');
|
|
345
346
|
}
|
|
346
347
|
function abortError() {
|
|
347
348
|
const err = new Error('Aborted');
|
package/lib/core/env.js
CHANGED
|
@@ -8,9 +8,21 @@ export function dshHome() {
|
|
|
8
8
|
export function webProfileDir() {
|
|
9
9
|
return join(dshHome(), 'profiles', WEB_PROFILE);
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* 缓存根(0.9.0 双 profile,ADR-0005):web 恒走旧路径(DSHM_CACHE_DIR 或
|
|
13
|
+
* `$DSH_HOME/dshm/cache`)——不迁移不清空;非 web profile 加同名段
|
|
14
|
+
* (`<root>/<profile>`),实现 profile 专属缓存互不污染。
|
|
15
|
+
* 测试经 DSHM_CACHE_DIR 指到临时目录(红线路径与既有用例一致)。
|
|
16
|
+
*/
|
|
17
|
+
export function cacheRoot(profile = WEB_PROFILE) {
|
|
18
|
+
const base = process.env.DSHM_CACHE_DIR || join(dshHome(), 'dshm', 'cache');
|
|
19
|
+
if (profile === WEB_PROFILE)
|
|
20
|
+
return base;
|
|
21
|
+
return join(base, profile);
|
|
22
|
+
}
|
|
23
|
+
/** registry 缓存目录(可被 DSHM_CACHE_DIR 覆盖,便于测试)。web 语义的历史别名 = cacheRoot('web')。 */
|
|
12
24
|
export function cacheDir() {
|
|
13
|
-
return
|
|
25
|
+
return cacheRoot(WEB_PROFILE);
|
|
14
26
|
}
|
|
15
27
|
/** 安装类操作的超时(毫秒) */
|
|
16
28
|
export function installTimeoutMs() {
|
package/lib/core/host-api.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* /dshm Host API dispatcher(DESIGN.md §4/§5):可注入的 method 分发 + 请求防护 +
|
|
3
3
|
* HTTP 状态映射,供 host.ts 与契约测试共用。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* 解析顺序固定:宿主信任检查(0.9.0 起委派官方 `connection.requestRejection`,
|
|
6
|
+
* 全 method 含 ping 与未知 method,任何 body/side effect 之前,fail-closed)→
|
|
7
|
+
* 只接受 POST → JSON Content-Type → 有上限读取并 drain body →
|
|
8
|
+
* 顶层必须是非 null/非数组对象且有 method → typed method/业务错误映射 → 其他 500。
|
|
8
9
|
*/
|
|
9
10
|
import { BOOT_ID, publicInstallStatus } from './dsh-cli.js';
|
|
10
11
|
import { resolveDshVersion } from './dsh-version.js';
|
|
@@ -16,9 +17,11 @@ import { getCommunitySummary } from './community.js';
|
|
|
16
17
|
import { RegistryConfigError } from './registry-controller.js';
|
|
17
18
|
import { checkRegistryEntries } from './registry-check.js';
|
|
18
19
|
import { CATEGORIES } from './registry.js';
|
|
19
|
-
import { servingPort, scheduleRestart
|
|
20
|
+
import { servingPort, scheduleRestart } from './restart.js';
|
|
20
21
|
import { togglePlugin, ToggleError } from './toggle.js';
|
|
21
22
|
import { IncompatibleError } from './compat-check.js';
|
|
23
|
+
import { ProfileUnsupportedError, assertWriteAllowed } from './active-profile.js';
|
|
24
|
+
import { DesktopOpsError, desktopInstallFromRegistry, desktopToggle } from './profile-ops.js';
|
|
22
25
|
export class BadJsonError extends Error {
|
|
23
26
|
}
|
|
24
27
|
export class BodyTooLargeError extends Error {
|
|
@@ -66,7 +69,20 @@ function readBody(req, maxBytes = BODY_MAX_BYTES) {
|
|
|
66
69
|
});
|
|
67
70
|
});
|
|
68
71
|
}
|
|
69
|
-
async function parseRequest(req) {
|
|
72
|
+
async function parseRequest(req, rejectRequest) {
|
|
73
|
+
// 宿主信任检查第一位(0.9.0 委派官方 requestRejection):先于 POST/Content-Type/body——
|
|
74
|
+
// 被拒请求零 body 消耗、零业务调用(报告 §3.2/§8)。返回 401/403 原样投影。
|
|
75
|
+
let rejection;
|
|
76
|
+
try {
|
|
77
|
+
rejection = rejectRequest(req);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// 宿主检查异常一律 fail-closed,异常细节不外泄(报告 §3.2)
|
|
81
|
+
rejection = 403;
|
|
82
|
+
}
|
|
83
|
+
if (rejection !== undefined) {
|
|
84
|
+
throw new ApiProtocolError(rejection, rejection === 401 ? '请求未通过宿主认证(未登录或凭据无效)' : '请求未通过宿主信任检查(不可信 Host/Origin 或跨站)');
|
|
85
|
+
}
|
|
70
86
|
if ((req.method || 'GET').toUpperCase() !== 'POST') {
|
|
71
87
|
throw new ApiProtocolError(405, '只接受 POST');
|
|
72
88
|
}
|
|
@@ -91,9 +107,6 @@ async function parseRequest(req) {
|
|
|
91
107
|
const method = typeof body.method === 'string' ? body.method.trim() : '';
|
|
92
108
|
if (!method)
|
|
93
109
|
throw new BadJsonError('缺少 method');
|
|
94
|
-
if (method !== 'ping' && !trustedRestartRequest(req)) {
|
|
95
|
-
throw new ApiProtocolError(403, '拒绝跨源请求');
|
|
96
|
-
}
|
|
97
110
|
return { method, body };
|
|
98
111
|
}
|
|
99
112
|
function strArg(body, key) {
|
|
@@ -139,6 +152,14 @@ function errorStatus(err) {
|
|
|
139
152
|
}
|
|
140
153
|
if (err instanceof ApiProtocolError)
|
|
141
154
|
return { status: err.status, payload: { ok: false, error: err.message } };
|
|
155
|
+
if (err instanceof ProfileUnsupportedError) {
|
|
156
|
+
// 0.9.0:Desktop/未知 profile 的能力表拒绝——结构化 code + 官方入口指引
|
|
157
|
+
return { status: 409, payload: { ok: false, error: err.message, code: err.code, action: err.action, profile: err.profile, guidance: err.guidance } };
|
|
158
|
+
}
|
|
159
|
+
if (err instanceof DesktopOpsError) {
|
|
160
|
+
// 0.9.0:Desktop adapter 失败(no-manager/install-refused/enable-failed/verify-failed)
|
|
161
|
+
return { status: 409, payload: { ok: false, error: err.message, code: err.code } };
|
|
162
|
+
}
|
|
142
163
|
if (err instanceof ToggleError) {
|
|
143
164
|
const status = err.code === 'protected' ? 403 : err.code === 'not-installed' ? 404 : 409;
|
|
144
165
|
return { status, payload: { ok: false, error: err.message, code: err.code } };
|
|
@@ -200,8 +221,14 @@ export function createApiDispatcher(ctx) {
|
|
|
200
221
|
resolveDshVersion: ctx.deps?.resolveDshVersion ?? resolveDshVersion,
|
|
201
222
|
getService: ctx.deps?.getService,
|
|
202
223
|
toggle: ctx.deps?.togglePlugin ?? togglePlugin,
|
|
224
|
+
desktopInstall: ctx.deps?.desktopInstall ?? desktopInstallFromRegistry,
|
|
225
|
+
desktopToggle: ctx.deps?.desktopToggle ?? desktopToggle,
|
|
203
226
|
};
|
|
204
227
|
const cfg = () => ctx.controller.config;
|
|
228
|
+
// 0.9.0:宿主信任检查委派(含 ping 与未知 method,fail-closed);
|
|
229
|
+
// profile 为 host 生命周期内不可变对象(host.ts apply 期解析一次)
|
|
230
|
+
const rejectRequest = ctx.deps?.rejectRequest ?? (() => 403);
|
|
231
|
+
const profile = ctx.profile;
|
|
205
232
|
// DSH 版本一次性解析,dispatcher 创建即预热(首个 ping 不吃 spawn 回退的延迟)
|
|
206
233
|
const dshVersionP = Promise.resolve()
|
|
207
234
|
.then(() => d.resolveDshVersion())
|
|
@@ -210,7 +237,7 @@ export function createApiDispatcher(ctx) {
|
|
|
210
237
|
const abort = new AbortController();
|
|
211
238
|
res.once('close', () => abort.abort());
|
|
212
239
|
try {
|
|
213
|
-
const { method, body } = await parseRequest(req);
|
|
240
|
+
const { method, body } = await parseRequest(req, rejectRequest);
|
|
214
241
|
const signal = abort.signal;
|
|
215
242
|
let payload;
|
|
216
243
|
switch (method) {
|
|
@@ -222,6 +249,8 @@ export function createApiDispatcher(ctx) {
|
|
|
222
249
|
boot: BOOT_ID,
|
|
223
250
|
// DSH 运行版本(头部 chip 数据源);解析失败 → undefined → JSON 序列化时字段缺席
|
|
224
251
|
dshVersion: (await dshVersionP) ?? undefined,
|
|
252
|
+
// 0.9.0 双 profile:GUI/工具展示当前 profile 与能力边界
|
|
253
|
+
profile: { name: profile.name, kind: profile.kind, source: profile.source },
|
|
225
254
|
};
|
|
226
255
|
break;
|
|
227
256
|
case 'self-check': {
|
|
@@ -238,6 +267,8 @@ export function createApiDispatcher(ctx) {
|
|
|
238
267
|
break;
|
|
239
268
|
}
|
|
240
269
|
case 'self-upgrade': {
|
|
270
|
+
// 0.9.0:Desktop 结构化拒绝(能力表),Web 走既有路径
|
|
271
|
+
assertWriteAllowed(profile, 'self-upgrade');
|
|
241
272
|
// M2 Task 3:self-upgrade 收编 market.selfUpgrade(统一 mutation session + 守卫,直调事务旁路封死)
|
|
242
273
|
const result = await d.selfUpgrade(ctx.pkg.name, ctx.pkg.version, cfg(), { signal });
|
|
243
274
|
payload = {
|
|
@@ -327,7 +358,8 @@ export function createApiDispatcher(ctx) {
|
|
|
327
358
|
const target = strArg(body, 'pkg');
|
|
328
359
|
if (!target)
|
|
329
360
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
330
|
-
|
|
361
|
+
// 0.9.0 双 profile:README 只读当前 profile 的 node_modules
|
|
362
|
+
const result = await readInstalledPluginReadme(target, profile.dir);
|
|
331
363
|
payload = { ...result };
|
|
332
364
|
break;
|
|
333
365
|
}
|
|
@@ -338,6 +370,18 @@ export function createApiDispatcher(ctx) {
|
|
|
338
370
|
const id = strArg(body, 'id');
|
|
339
371
|
if (!id)
|
|
340
372
|
throw new ApiProtocolError(400, '缺少 id');
|
|
373
|
+
// 0.9.0:desktop → 官方 pluginManager 委派(新包);web → 既有事务
|
|
374
|
+
if (profile.kind !== 'web') {
|
|
375
|
+
assertWriteAllowed(profile, 'install');
|
|
376
|
+
payload = { ...(await d.desktopInstall(id, cfg(), {
|
|
377
|
+
version: typeof body.version === 'string' ? body.version : undefined,
|
|
378
|
+
forceIncompatible: boolArg(body.forceIncompatible),
|
|
379
|
+
namespace: 'host',
|
|
380
|
+
profile: profile.name,
|
|
381
|
+
signal,
|
|
382
|
+
})) };
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
341
385
|
const version = typeof body.version === 'string' ? body.version : undefined;
|
|
342
386
|
const result = await d.installFromRegistry(id, cfg(), {
|
|
343
387
|
version,
|
|
@@ -352,6 +396,8 @@ export function createApiDispatcher(ctx) {
|
|
|
352
396
|
const target = strArg(body, 'pkg');
|
|
353
397
|
if (!target)
|
|
354
398
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
399
|
+
// 0.9.0:Desktop 结构化拒绝(官方入口指引)
|
|
400
|
+
assertWriteAllowed(profile, 'uninstall');
|
|
355
401
|
const result = await d.uninstallPlugin(target, cfg(), { namespace: 'host', signal });
|
|
356
402
|
payload = { ...result };
|
|
357
403
|
break;
|
|
@@ -362,6 +408,12 @@ export function createApiDispatcher(ctx) {
|
|
|
362
408
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
363
409
|
if (typeof body.enabled !== 'boolean')
|
|
364
410
|
throw new ApiProtocolError(400, '缺少 enabled(必须为 boolean)');
|
|
411
|
+
// 0.9.0:desktop → 官方管理器委派(服务缺席结构化拒绝,禁 fallback 文件写)
|
|
412
|
+
if (profile.kind !== 'web') {
|
|
413
|
+
assertWriteAllowed(profile, 'set-enabled');
|
|
414
|
+
payload = { ...(await d.desktopToggle(target, body.enabled, { getService: d.getService, profileDir: profile.dir })) };
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
365
417
|
const result = await d.toggle(target, body.enabled, { getService: d.getService });
|
|
366
418
|
payload = { ...result };
|
|
367
419
|
break;
|
|
@@ -370,6 +422,8 @@ export function createApiDispatcher(ctx) {
|
|
|
370
422
|
const target = strArg(body, 'pkg');
|
|
371
423
|
if (!target)
|
|
372
424
|
throw new ApiProtocolError(400, '缺少 pkg');
|
|
425
|
+
// 0.9.0:Desktop 结构化拒绝(无官方 upgrade API,指引 Settings → Plugins)
|
|
426
|
+
assertWriteAllowed(profile, 'upgrade');
|
|
373
427
|
const result = await d.upgradePlugin(target, cfg(), {
|
|
374
428
|
forceIncompatible: boolArg(body.forceIncompatible),
|
|
375
429
|
namespace: 'host',
|
|
@@ -379,6 +433,8 @@ export function createApiDispatcher(ctx) {
|
|
|
379
433
|
break;
|
|
380
434
|
}
|
|
381
435
|
case 'restart': {
|
|
436
|
+
// 0.9.0:Desktop 不触发旧 helper(Electron 生命周期归官方;杀子进程=红线)
|
|
437
|
+
assertWriteAllowed(profile, 'restart');
|
|
382
438
|
const result = d.scheduleRestart(servingPort(req));
|
|
383
439
|
payload = { ...result };
|
|
384
440
|
break;
|