# 设计稿:多机迁移 —— 导出 / 导入 / 清空(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` 拒绝执行 - 边界:清单外的引用名不被动