# 设计稿:多机迁移 —— 导出 / 导入 / 清空(v0.10.0)
> 状态:**已实现**(2026-09-25)。事实来源是 `README.md` 的「在多台机器间使用」一节,
> 与 `lib/portability.js` 的模块注释;本文件保留为**决策记录**——记下当时为什么这样定、刻意不做什么。
> 接续 `DESIGN-lessons.md` 第五节的「留给下一轮:经验的导出/导入」。
---
## 一、问题
个人往往同时使用多台机器(家里的、工位的、新买的),而且往往不是「换」而是「并存」。
积累在一台上,要在其他机器上也能用——否则要重新找、重新录、重新踩一遍坑。
现状只有一张手工表:`README.md` 的「换一台机器」——拷哪些文件、别拷哪个。
它只回答了**换**(旧机退役、单向一次),没回答**并**(多机并存、双向持续):
| 情形 | 形态 | 手工表够不够 |
|---|---|---|
| 换机 | 旧机退役、新机接替,单向一次 | 够 |
| **多机并存** | 家、工位同时在用,双向持续 | **不够——会覆盖或丢** |
三台机器同时活着时,A 机导出、B 机还在长新经验;之后 A 从 B 导入,A 原来那部分只活在旧导出件里。
**整条链上没有一处是权威副本**,而「不必重新积累」正是这件事要防的。
另外,手工表只搬文件,不搬**值**。多机可用的最后一步——把钥匙配上——现在没有路径。
## 二、目标(由使用者给定)
1. 新增**导出 / 导入 / 清空**三种功能。
2. 导出**默认不带值**;勾选带值则为**明文,不加密**。
3. 导入:**值覆盖,积累并集**。
4. 清空:清掉本机 stash 相关痕迹,包括凭据的值;**只清数据,不卸载插件**。
5. 只改程序,不碰任何真实账号与值(测试全部跑在临时 `DSH_HOME`)。
第 3 条是整个设计的支点:**值和积累是两种语义**。值一个引用名只对应一个值,覆盖即正确;
经验 / 台账 / 库条目 / 语料是**集合**,对集合用覆盖等于删数据。
## 三、决定
### 3.1 入口:人类命令,不做成 model tool
值的搬运**不能经过模型**——这是整套设计的前提(`README.md` 的「安全边界」)。
所以三个功能都落在已有的 `/stash` 命令下(不耗 token、不经模型):
```
/stash export [--with-values] [--corpus] [--out
]
/stash import [--dry-run]
/stash wipe
```
**连带后果**:`README.md` 现有那句「**值只有一条路径**:浏览器 → 凭据服务」不再成立。
实现后要改写为**两条受控路径**:钥匙面板,与 `/stash import`(人类入口,不经过模型)。
口号不同步改,文档就是在说谎。
### 3.2 导出
**形态:一个目录,不是压缩包。** 理由:Node 无内置 zip(本包零依赖),而目录可 diff、可人工检查、
可挑着拷——三个性质对「私人介质搬运」都用得上。
```
stash-export-YYYYMMDD-HHmm/
├── manifest.json 包元信息 + 校验清单(每文件条目数 + 哈希)
├── sources.mjs 手写注册表,原样拷
├── sources.local.json 代写分片(库条目 + 钥匙台账)
├── ledger.ndjson 取数台账
├── lessons.json 经验库
├── credentials-guide.md 「这把钥匙从哪来」——见下
├── corpus/ 可选(--corpus)
└── values.json 仅带值模式(--with-values)
```
**`credentials-guide.md` 是默认模式的核心价值。** 它从台账里把每个引用名的 `ref` / `label` /
`url` / `notes` / `inject` 抽出来,排成一份可照着办的清单。
它要解决的是「资源多了记不住」——**真正的痛点不是记不住名字,是要逐个回到来源网站重新申请。**
比如本机有个 PAT,它的 `notes` 写明「在服务商的 account/tokens 页生成,形如 `sbp_`」,
换机器时照着复办一遍即可,不需要凭记忆。**让"怎么重新拿到这把钥匙"随行,而不是让钥匙本身随行。**
**带值模式(`--with-values`)**:
- 值以**明文**写进 `values.json`。这是使用者的明确决定,风险模型是「私有介质、自己的机器之间」。
- 导出时必须提示:**默认保存路径避开桌面与文档**(OneDrive 等默认同步这两个目录)。
落错目录就等于上云——这与加不加密无关,是介质纪律里最容易破的一环。
- 导出**不删源**。删除是独立动作,见 3.4。
**语料与校验码**:
- `--corpus` 时,语料**逐个文件进校验清单**(`manifest.json` 的 `files` 带路径 + 字节数 + sha256)。
否则包被动过时只有载荷看得见,语料是一道暗门。哈希用分块流式读,几百 MB 的导出件不会炸内存。
- `.last-export.json` 保留**最近 5 次**导出的校验码。人常常导出好几遍,只留最后一次会让早先那个包
导入回来的码对不上——而清空**接受其中任意一枚**。
### 3.3 导入
流程:
```
读包 → 校验 manifest(条目数/哈希对不上就停)→ 逐文件合并 → 按 inject 恢复值
→ 输出对账单 → env 落点提示重启
```
**合并规则(逐文件):**
| 文件 | 规则 | 去重键 |
|---|---|---|
| 凭据的值 | **覆盖**(新值即当前值) | 引用名 |
| `lessons.json` | **并集** | `(来源库, title)` |
| `ledger.ndjson` | **只拼接,不去重** | — |
| `sources.local.json` 条目 | **并集** | `id` |
| `corpus/` | 文件级并集 | 相对路径 + 内容哈希 |
**为什么去重键不是 `id`:** 经验的 id 是 `sha256(at|source|title)`(`lib/lessons.js`),
台账的 id 是 `sha256(at|source|action|hash)`(`lib/ledger.js`)——**都含时间戳**。
同一件事在两台机器上各发生一次,id 必然不同,按 id 去重根本去不掉。
**台账为什么不按 `contentHash` 去重:** `fingerprint()` 的投影在失败时全是 `null`
(`lib/ledger.js` 的 `status / bytes / count / fetchedAt / items` 都取不到),
所以**所有失败记录的 `contentHash` 相同**。本机实测:三条不同的 400 记录哈希完全一致。
按它去重会把不同失败合并成一条。台账是历史留痕,「我在两台机器各查过一次」本身就是事实,拼接最忠实。
**冲突(只有这一类需要人研判):**
| 情况 | 处理 |
|---|---|
| 只有一侧有 | 直接加入 |
| 两侧都有、内容相同 | 去重跳过 |
| 两侧都有、内容不同 | **报冲突,不自动决定** |
冲突面:经验(同来源库 + 同标题、正文不同)、库条目(同 id、定义不同)、语料(同路径、内容不同)。
**值的落点:必须按 `inject` 分别恢复。**
| `inject` | 写到哪 | 后续 |
|---|---|---|
| `header:...` / `query:...` / 其余 | 凭据服务(`$DSH_HOME/.credentials.yaml`) | 即时生效 |
| `env:NAME` | **凭据服务 +** `$DSH_HOME/.env` 的 `NAME=` | 面板即时绿;读 `process.env` 的组件**要重启** |
> ⚠️ 2026-09-26 修正:`env:` 落点原先**只写 `.env`**,那是错的——面板贴值的路径是
> **两处都写**(浏览器写凭据服务 + 端点镜像 `.env`),导入没有浏览器那一跳,于是漏了前半截。
> 后果是导入后**要重启才生效,重启前面板显示"未配置"**,看起来像导入失败。
> 现在两条路都走 `installCredentialValue()`(`lib/credential-store.js`):先写凭据服务,
> 再按落点镜像 `.env`,落到同样的状态。
>
> 这条是逐项核对"导出/导入/清空覆盖了哪些数据"时发现的,不是推演出来的。
落点写错,就会原样重现「面板一片绿、功能却是断的」——本机已经踩过一次
(一个引用名走了凭据库,另一个只认 `process.env`,两个都配了,只有一个真的生效)。
所以 `inject` 从「说明性字段」升级为**迁移必需字段**。
**对账单是硬要求。** 导入结束必须逐条列出:引用名 → 目标落点 → 结果(写入成功 / 需写 `.env` 并重启 / 缺失)。
根因是宿主凭据服务**无法枚举引用名**(`README.md`「已知限制」),
导入完没有任何服务端接口能反查「恢复齐了没有」,唯一事实来源就是台账与库声明——
不给对账单,多机之间必然留盲区。
### 3.4 清空
```
/stash wipe
```
清四件(顺序即执行顺序):
1. `$DSH_HOME/stash/` 目录内容。
2. **按引用名**删除凭据的值——清单来自三处:台账 `accounts[].fields[].ref`、
库声明的 `source.credentials[]`、`.env` 里 stash 写入的键。
3. 删 `.env` 里**那几个键**(按名删行,其他行不动)。
4. 输出报告。
**`corpus/` 单独勾选,默认不删。** 它常是用户手工放进去的原始语料、往往只此一份,
和「清空元数据」不是一个量级的风险,不放在同一个删除键下面。
**铁律:删除前必须先有一次成功的导入验证。**
删除不可逆,而「导出件丢了 / 坏了 / 口令忘了」比「本机残留」现实得多。
所以顺序是**先在新机器导入成功、再回旧机器清空**,不是「导出后就能删」。
实现上:`manifest.json` 里的校验清单(条目数 + 哈希)就是对账凭据;
`wipe` 要求输入最近几次导出中的任意一枚校验码才执行。
**边界(不做什么):**
- `.credentials.yaml` **文件本身不动**。它装着本机全部密钥,其中包括与 stash 无关的
(本机就有 `DEEPSEEK_API_KEY`)。只按引用名 `unset`,绝不删文件。
- 删除前列出完整清单与计数,二次确认——不是「一次点击」。
- **不卸载插件 bundle。** 要这台机器彻底不再用 stash,是两步:先清空数据,
再 `pnpm dsh plugin --profile web remove dsh-stash`。
## 四、明确不做的事
1. **不加密。** 使用者的风险模型是私有介质、自己的机器之间,明文的边际风险可接受。
代价已写明在 3.2:导出件一旦落到同步目录或丢失,等同全部密钥泄露。
2. **不做云同步、不做自动同步。** 介质由使用者控制,插件不引入网络通道。
3. **不做语义合并。** 经验的相似度判重不做——那需要判断「两条是不是同一个坑」,
等于替使用者决定他的经验长什么样(与 `DESIGN-lessons.md` 第二节同一条边界)。
4. **不删无关凭据。** 只删清单内的引用名,清单外的一律不碰。
5. **不把三个功能做成 model tool。** 见 3.1。
6. **不卸载插件。** 见 3.4。
## 五、留给下一轮
- **面板入口。** 现在定的是 `/stash` 子命令;要点按钮就得新增 client 页面。命令行先行。
- **时钟不一致时「以新盖旧」的判据。** 值没有时间戳,本次按「导入包里的值即新值」处理;
多机来回导入时是否需要一个时间戳,待真遇到再定。
- **清单分叉的自动解决。** 现在靠冲突报告 + 人。哪天冲突频繁到烦人,再谈规则化。
## 六、连带要改的既有内容
| 位置 | 改什么 |
|---|---|
| `README.md`「安全边界」 | 「值只有一条路径」→ 两条受控路径(面板 + `/stash import`) |
| `README.md`「换一台机器」 | 升级为「在多台机器间使用」:换机 + 并存两种情形、合并语义、值不可枚举的风险 |
| `README.md` 工具 / 命令表 | 加 `export` / `import` / `wipe`;`stash_catalog` 现在带出 `inject` 落点 |
| `README.md`「已知限制」 | 补迁移的诚实边界:清空是**人工对账**不是密码学证明、带值包是明文、引用名无法枚举导致删除范围只能来自台账 + 库声明 |
| `lib/http.js` | 400 的提示从写死的贸易接口文案改成通用文案(指向结果的 `body`) |
| `DESIGN-lessons.md` 第五节 | 「留给下一轮」里的导出/导入一条可划掉 |
| `CHANGELOG.md` | 新增版本段与破坏性说明(口令路径变更) |
## 七、实现清单(范围钉在这里)
1. `lib/portability.js` 合并器:逐文件合并规则 + 冲突收集 + 去重键。
2. `/stash export`:目录打包 + `manifest.json` + `credentials-guide.md` + 可选 `values.json`。
3. `/stash import`:校验 → 合并 → 按 `inject` 恢复值 → 对账单 → 重启提示。
4. `/stash wipe`:四件清理 + 导入验证前置 + 二次确认(`corpus/` 默认不删)。
5. 测试(临时 `DSH_HOME`,不碰真实数据):
- 值覆盖 / 经验并集 / 台账拼接 各自的语义
- 三类冲突都能被报出且不自动决定
- 去重键:同 (来源库, title) 的经验跨机合并后只剩一条;台账不去重
- 落点:`env:` 与 `header:` 分别落到正确文件
- 铁律:没有成功的导入验证时,`wipe` 拒绝执行
- 边界:清单外的引用名不被动