# 安装渠道 B:把 preset 注册进 profile(不复制)
本文记录 **`preset/install.mjs`** 这条安装渠道:为什么需要它、它到底改什么、升级时会发生什么、
以及它相对"复制 preset 到 `~/.dsh/.agent-presets`"的取舍。所有结论都在本机对着已安装的
harness 实测过,实测方法见文末。
## 为什么需要它
`dsh plugin --profile
add dsh-molbio-tools` 会把包装进 profile,并让 **52 个工具**在该
profile 的所有会话里可用。但它**不会**让 preset 出现在模式选择器里:
- `dsh plugin` 管理的是 **profile bundle**(它只是 `pnpm` 的转发器,见 `dsh/lib/plugin-*.js`);
- agent preset 只从两处发现(`@deepseek-ai/dsh-agent-presets`):
包自己的 `presets/` 目录,和 `/.agent-presets`。
于是出现了缺口:**推荐给用户的 preset 渠道需要手工复制目录**,而手工复制在升级时还要重来一遍
(版本目录规则要求每次发布新建 `dsh-molbio-tools-vN`,复制过去的那份就冻结在旧版本)。
`preset/install.mjs` 补上这个缺口:把**已安装包内的 preset 目录**注册为 profile 的额外
preset 扫描根。配置文件改一次,之后升级不用再动。
## 用法
```powershell
# 1. 把包装进 profile(工具 + bundle 层)
dsh plugin --profile web add dsh-molbio-tools
# 2. 注册 preset(幂等)—— 脚本随包分发,直接在包里执行
node <包目录>\preset\install.mjs --profile web
# 典型位置:/node_modules/dsh-molbio-tools/preset/install.mjs
# 开发检出:<仓库>/preset/install.mjs(若 profile 里已装该包,脚本会自动优先用装好的那份)
```
参数:
| 参数 | 作用 |
| --- | --- |
| `--profile ` | 要注册的 profile,默认 `web` |
| `--dsh-home ` | harness home,默认 `$DSH_HOME`,否则 `~/.dsh` |
| `--dry-run` | 只打印将要写入的条目,不落盘 |
| `--check` | 只校验是否已注册,未注册时退出码 1(可做健康检查) |
装好后**重启该 profile**,新建会话时选择器里会出现 **Molecular Biology Lab**。
> 在受限沙箱里运行时,脚本可能无法启动 `dsh --dump-config` 做自检(子进程管道被拒绝,报
> `EPERM`)。脚本会区分两种情况并明确提示:**跑不起来**只是警告(补丁已写入,退出码 0);
> **跑起来但结果不对**才判定失败(退出码 1,并提示从备份恢复)。请按提示自行 compose 验证。
## 它到底改什么
在 profile 的 `cordis.patch.yml` 里加一条针对 roster 行的补丁:
```yaml
- id: agent-presets
config:
default: standard
roots:
- path: <已安装包>/preset
trust: system
```
四个要点,每个都有原因:
1. **`config` 是整体替换**,不是合并 —— harness 的补丁语义如此,所以必须重述 `default`。
2. **`roots.path` 必须是绝对路径** —— 发现层用 `path.resolve(expandHomePath(path))` 解析,
相对路径会按**进程 CWD**解析(app-boot 全程不 `chdir`),随启动目录漂移。
`~` 前缀可用,但我们的路径在 `$DSH_HOME` 下,`~` 展开不到,所以由脚本写入绝对路径。
3. **`trust: system`** —— 该目录属于包,只读是正确语义:它不能被用户从 preset UI 编辑/删除
(`trust !== 'user'` 时删除会被拒),也不会去查内置显示名(那对 `system` 且命中内置键的才查),
显示名照常来自 `preset.yml`。**不要关 `includeUserRoot`**,否则 `writableRoot` 失效,
"创作 preset" 入口整体不可用。
4. **`[]` 占位符必须被替换而不是追加** —— 新初始化的 profile 的 patch 文件内容就是裸的
`[]`;在它后面追加会产生第二个顶层节点,harness 报
`end of the stream or a document separator is expected`。脚本按"只剩注释 + `[]`"判定并替换。
脚本先备份(`cordis.patch.yml.bak-<时间戳>`)再写,写入后用 `dsh --profile --dump-config`
验证组合树确实带上了新 root。
## 升级会发生什么
这是这条路线的核心价值。**升级只跑一条命令,不用动任何配置、不用重新复制**:
```powershell
dsh plugin --profile web update dsh-molbio-tools
```
为什么这就够了:
- **root 路径跨版本恒定**:包始终落在 `/node_modules/dsh-molbio-tools`(`link:` 安装是
符号链接,npm/tarball 安装是真实目录)。实测把安装来源从 `link:` 换成 tarball 后,注册的
root 路径没有变化,`--check` 仍然通过。
- **新会话自动用上新代码**:preset 挂载按 `agent.cordis.yml` 的 `{mtimeMs, size}` 戳判定代际
(`agent-presets/lib/types/index.js` 的 `ensureStanding`),戳一变就为**新会话**开新一代。
发布时该文件里的行会指向新的 `dsh-molbio-tools-vN`,所以新代码随之生效。
- **不会撞 ESM 缓存**:插件内部 import 全是相对路径(`./lib.mjs` 等),新版本目录产生全新
文件 URL,模块缓存自然未命中 —— 这正是仓库"版本目录规则"能成立的原因。
### 升级 Runbook(三条必须遵守的纪律)
1. **别立刻删旧的 `vN` 目录。** 已加入旧世代的运行中会话仍指回旧目录;删掉会让它们的后续
工具调用找不到模块。README 里"旧目录可删除(已运行 generation 持有内存中的模块)"是
**同一进程内热升级**的说法,不适用于这种跨进程的 `pnpm update`。等老会话结束后再删。
2. **发布时 preset 目录里的插件拷贝要一起更新。** 包内 `preset/molbio-lab/plugins/dsh-molbio-tools-vN/`
是独立的一份 `.mjs` 拷贝(preset 必须自包含),发布流程要把 `agent.cordis.yml` 的行和这份
拷贝同步推进。
3. **patch 必须重述 `default` 与两个 `include*`。** 见上文第 1、3 点;漏写 `default` 会直接校验失败。
### 升级后出问题怎么看
preset 组合里有行解析不了时,发现层会把它列为 **broken 并给出原因**,而不是悄悄隐藏
(这是设计如此)。所以"选择器里 preset 不见了/标红"要去预设列表看原因,通常是:
包被卸了、`vN` 目录没跟上、或 profile 没重启。
## 与复制方案的取舍
| | 复制到 `~/.dsh/.agent-presets` | 本渠道(注册包内目录) |
| --- | --- | --- |
| 安装动作 | 复制整个 preset 目录(2.4 MB,含历史 `vN`) | 跑一次脚本(改 8 行配置) |
| 升级动作 | **每个新版本都要重新复制**到新的 `vN` 目录 | `dsh plugin update`,**配置不动** |
| 升级丢失风险 | 高(忘记复制 → 跑旧代码) | 无(路径恒定,随包更新) |
| preset 是否自包含 | 是(可离线带走) | 依赖已安装的包 |
| 可否被用户编辑 | 可以(在 user 根下) | 否(`trust: system`,只读) |
| 适用场景 | 无法改 profile 配置的环境;要交付一个"带走即用"的目录 | 自己的机器/团队 profile;长期使用并跟随升级 |
两者可以共存(复制那份在 `user` 根,本渠道在配置根,重复 id 时**配置根胜出**)。
若同时存在且希望完全跟随包升级,删掉 `~/.dsh/.agent-presets/molbio-lab` 即可。
## 实测方法(可复现)
全部在临时 `DSH_HOME`(工作区内,不触碰真实 `~/.dsh`)上做:
```powershell
$env:DSH_HOME = "D:\path\to\tmp-dsh-home"
dsh --profile web --help # 初始化一个真实 web profile
dsh plugin --profile web add link:<仓库路径> # 装入包
node preset/install.mjs --profile web --dsh-home $env:DSH_HOME
dsh --profile web --dump-config # 组合树应带上新 root
```
验证覆盖:组合树解析与 root 出现、幂等(二次运行零改动)、`--check`、`--dry-run` 不落盘、
无 roster 行的 profile 被守卫拒绝、发现层对**已安装副本**判定 `healthy` 且四个内置 preset 仍在、
安装来源从 `link:` 换成 tarball 后 root 路径不变。补丁形状还用 harness 自己的 YAML 解析器
跑了 5 种边界(裸 `[]`、无尾换行、CRLF、已有条目、已有条目无尾换行)。
## 已知限制
- **不是零配置。** 仍要跑一次脚本(改 profile 配置)。真正的零配置需要 harness 提供
`dsh preset add`,或允许 bundle 自带 preset 发现根 —— 目前都没有。
- **依赖 profile 的 roster 行存在。** 只有包含 `@deepseek-ai/dsh-web-app` 的 profile
(如随附 `web` 模板)才有 `agent-presets` 行;脚本会先检查 bundle 列表并明确拒绝其它 profile。
- **包名 vs root 平面的耦合。** preset 中若新增**需要从 profile 解析的裸包名行**,其健康检查
依赖发现时的 base 落在 harness/profile 平面。当前 preset 只有一行相对 specifier,不受影响。
- **沙箱**:脚本要写 `$DSH_HOME` 下的文件,受限沙箱可能拒绝;且其自检子进程可能被拒绝(见上文)。