218 lines
12 KiB
Markdown
218 lines
12 KiB
Markdown
# ZCode 环境同步插件(zcode-env-sync)
|
||
|
||
团队共享场景:一键导出 / 导入本机 ZCode 环境,默认四项全量:
|
||
|
||
1. **官方插件清单** — marketplace 版本 + 已启用列表(`data/` 目录即启用态)
|
||
2. **自研插件源码** — `~/.zcode/plugins/*` 整体按字节打包:**你自己写的插件全在里面**
|
||
(如本插件自身、`zcode-tps`、`zcode-usage`,以后新增的也自动带上);
|
||
`node_modules`/`.git` 排除,以 `.` 开头的备份目录不算插件,
|
||
两个插件 manifest 同名直接报错(否则快照里互相覆盖)
|
||
3. **MCP 配置** — `cli/config.json` 的 `mcp.servers` + `plugins.dirs`,路径占位符化
|
||
(gitea 的 `-H` 地址、`GITEA_ACCESS_TOKEN_FILE` 指针都在这,身份各用各的见下)
|
||
4. **密钥束** — `~/.dsh/secrets/*` + `~/.ssh/ssh-profiles.json`,AES-256-GCM 加密后才进仓
|
||
(含 `gitea-token.txt` 本体)
|
||
|
||
可选第 5 项(`ZCODE_SYNC_WITH_MODELS=1`):**模型 provider 脱敏快照**,见下。
|
||
|
||
后端:Gitea 私仓 `root/zcode-env-sync`。
|
||
|
||
## 安装
|
||
|
||
```bash
|
||
cp -r <本目录> ~/.zcode/plugins/zcode-env-sync
|
||
# 把该路径追加进 ~/.zcode/cli/config.json 的 plugins.dirs,然后重启 ZCode
|
||
```
|
||
|
||
## 用法
|
||
|
||
| 命令 | 动作 |
|
||
|---|---|
|
||
| `/sync-export` | 导出本机快照 → 推 Gitea 私仓 |
|
||
| `/sync-import` | 从私仓拉快照 → 还原到本机(先自动备份) |
|
||
| `/sync-status` | 对比本机与远端快照差异(只读) |
|
||
| `/sync-list` | 列出远端全部快照,标出 `latest` 指向哪个(只读) |
|
||
| `/sync-verify` | 校验快照完整性:hash / 密钥束 / 占位符(只读,只比哈希不落盘) |
|
||
| `/sync-prune` | 清理远端旧快照(默认只预演,`--apply` 才真删) |
|
||
| `/sync-doctor` | 自诊断:一次查完前置条件,告诉你同步为什么不能用(只读) |
|
||
|
||
**排查顺序**:`/sync-doctor` → `/sync-verify` → `/sync-status`。
|
||
先说"同步不工作"就跑 doctor(缺口令、latest 解不开、追踪引用分叉都在这层);
|
||
怀疑快照坏了跑 verify(它会用当前口令试解 `secrets.enc`);
|
||
只想看差异跑 status。
|
||
|
||
七个命令统一用 `SYNC_PLUGIN` 自动定位脚本,**命令文档里不写死绝对路径**——
|
||
快照要跨机器还原,写死的路径到了别的机器就是废的:
|
||
|
||
```bash
|
||
SYNC_PLUGIN="${ZCODE_SYNC_PLUGIN:-$(find ~/.zcode/plugins -maxdepth 3 -name export.mjs -path '*env-sync*' | head -1)}"
|
||
node "$SYNC_PLUGIN"
|
||
```
|
||
|
||
口令:环境变量 `ZCODE_SYNC_PASSPHRASE`(团队线下约定,**不进仓**)。
|
||
未设置或短于 8 位时脚本直接报错退出,不会用弱默认口令。
|
||
|
||
### 快照保留与清理
|
||
|
||
```bash
|
||
node prune.mjs # 预演:列出将删哪几个、各多大、共释放多少
|
||
node prune.mjs --keep 5 # 预演,只保留最近 5 个
|
||
node prune.mjs --keep 5 --apply # 真删并推送
|
||
node prune.mjs --exclude <快照名> # 钉住某个快照(可重复)
|
||
```
|
||
|
||
永远不动:`latest.json` 指向的那个、最新的那个、`--exclude` 钉住的。
|
||
删完若 `latest` 指向被删的快照会自动重指到存活的一个。`ZCODE_SYNC_KEEP` 设默认保留数。
|
||
|
||
### 快照校验
|
||
|
||
```bash
|
||
node verify.mjs # 校验 latest 指向的那个
|
||
node verify.mjs <快照名> # 校验指定快照
|
||
node verify.mjs --all # 扫全部:逐个标「结构OK / 可解密 / 口令不符」,给出能用的是哪个
|
||
node verify.mjs --strict # 有警告也算失败(退出码 1)
|
||
```
|
||
|
||
`--all` 用在**团队仓里混着不同口令导出的快照**时:GCM 认证失败无法区分"口令错"与"密文损坏",
|
||
它把每个都拿当前口令试一遍,直接告诉你 `/sync-import <哪个>` 能用。
|
||
|
||
|
||
## 快照结构
|
||
|
||
```text
|
||
snapshots/<hostname>-<yyyyMMdd-HHmmss>/
|
||
manifest.json # 清单:官方插件/自研插件 hash/MCP 名/密钥指针/provider 摘要
|
||
mcp.servers.json # 占位符化后的 MCP 配置(明文)
|
||
plugins-dirs.json # plugins.dirs 占位符化(明文)
|
||
v2.providers.json # 仅 WITH_MODELS=1:provider 脱敏版(密钥位置是占位符)
|
||
custom-plugins/ # 自研插件源码(明文,团队内私仓)
|
||
secrets.enc # 密钥束 AES-256-GCM 密文
|
||
secrets.manifest.json # 密钥清单(只有路径+sha256,无值)
|
||
latest.json # 指向最新快照
|
||
```
|
||
|
||
## 跨机器路径还原
|
||
|
||
导出时把本机绝对路径替换为占位符,导入时按本机实际路径反向替换:
|
||
|
||
| 占位符 | 含义 |
|
||
|---|---|
|
||
| `${ZCODE_HOME}` / `${ZCODE_HOME_WIN}` | `~/.zcode`(正/反斜杠两种写法) |
|
||
| `${DSH_HOME}` / `${DSH_HOME_WIN}` | `~/.dsh` |
|
||
| `${USER_HOME}` / `${USER_HOME_WIN}` | 用户主目录 |
|
||
| `${USERS_POSIX}` / `${USERS_WIN}` | `Users/<用户名>` 片段 |
|
||
|
||
替换按 **JSON 值**逐个进行,不是对序列化文本做字符串替换——
|
||
文本里反斜杠是转义态(`\\Users\\`),对文本替换会整片漏掉。
|
||
|
||
机器特有的 host/port/command 可写 `~/.zcode-env-sync/overrides.json`(**不进仓**),
|
||
导入时自动深度合并覆盖。
|
||
|
||
## 模型 provider(默认关)
|
||
|
||
`~/.zcode/v2/config.json` 里通常是**活的 apiKey**,所以默认完全不碰(快照里无模型)。
|
||
需要同步时导出导入两边都显式开:
|
||
|
||
```bash
|
||
ZCODE_SYNC_WITH_MODELS=1 node export.mjs # 导出:脱敏结构明文 + 真密钥进加密束
|
||
ZCODE_SYNC_WITH_MODELS=1 node import.mjs # 导入:结构合并 + 密钥回填
|
||
```
|
||
|
||
导出:`v2.providers.json` 脱敏版明文(密钥位置是 `${MODEL_SECRET_REF}`),
|
||
真密钥以 `MODELS/<provider>/<key>` 条目并入 `secrets.enc`;
|
||
manifest 只记摘要 + 密钥哈希(`modelKeys`),不记值。
|
||
|
||
导入(`ZCODE_SYNC_WITH_MODELS=1` 时才动 `v2/config.json`,先备份):
|
||
|
||
- 快照有、本机无的 provider:整体新增
|
||
- 已有 provider:只补缺的模型定义与非密钥字段,不碰本机已有密钥
|
||
- 密钥回填:只填**空位**(占位符/空/过短);本机已有密钥默认跳过保护,
|
||
确需覆盖再加 `ZCODE_SYNC_OVERWRITE_MODEL_SECRETS=1`
|
||
- 选中态(当前用哪个 provider/model)默认不动,
|
||
加 `ZCODE_SYNC_APPLY_MODEL_SELECTION=1` 才应用(同样先备份 `v2/setting.json`)
|
||
|
||
| 变量 | 说明 |
|
||
|---|---|
|
||
| `ZCODE_SYNC_OVERWRITE_MODEL_SECRETS=1` | 允许用快照密钥覆盖本机已有密钥(默认跳过保护) |
|
||
|
||
## 内置闸门
|
||
|
||
导出过程有数道自检,命中即**中断且不推送**:
|
||
|
||
| 闸门 | 行为 |
|
||
|---|---|
|
||
| 明文区出现真密钥值 | 硬失败(扫全部非 `secrets.enc` 文件) |
|
||
| 配置区残留未占位符化的绝对路径 | 硬失败(按解析后的值判断,不限于本机用户名) |
|
||
| provider 脱敏不彻底 | 硬失败 |
|
||
| 自研插件 manifest 同名 | 硬失败(否则快照里互相覆盖) |
|
||
| 自研插件源码里的硬编码路径 | 警告并列出文件(源码按字节归档,不擅自改写) |
|
||
|
||
放行开关:`ZCODE_SYNC_ALLOW_SECRETS`(逗号分隔路径白名单)、`ZCODE_SYNC_ALLOW_PATH_LEAK=1`。
|
||
|
||
## 环境变量
|
||
|
||
| 变量 | 默认 | 说明 |
|
||
|---|---|---|
|
||
| `ZCODE_SYNC_PASSPHRASE` | — | **必填**,加密/解密密钥束 |
|
||
| `ZCODE_SYNC_REPO` | `root/zcode-env-sync` | 私仓坐标 |
|
||
| `ZCODE_SYNC_HOST` | `https://gitea.mymoyu.top` | 托管地址 |
|
||
| `ZCODE_SYNC_WORK` | `~/.zcode-env-sync` | 本地工作区(repo clone + backup) |
|
||
| `ZCODE_SYNC_WITH_MODELS` | 关 | `1` 时连带同步 provider(脱敏) |
|
||
| `ZCODE_SYNC_APPLY_MODEL_SELECTION` | 关 | `1` 时导入应用模型选中态 |
|
||
| `ZCODE_SYNC_SKIP_SECRETS` | 空 | 逗号分隔的密钥 rel,导入时跳过不落盘(只报告)。团队场景:连接方式统一、身份各用各的。例:`ZCODE_SYNC_SKIP_SECRETS='DSH_SECRETS/gitea-token.txt'`(每人保留自己的 gitea token,只同步 `-H` 地址等配置);模型密钥用 `MODELS/<provider>/<key>` 格式 |
|
||
| `ZCODE_SYNC_NO_PUSH` | 关 | `1` 只落本地,不推远端 |
|
||
| `ZCODE_SYNC_GIT_REMOTE` | 自动 | 覆盖远端地址(测试用) |
|
||
| `ZCODE_SYNC_APPLY_OFFICIAL` | 开 | `0` 跳过官方插件差异报告 |
|
||
| `ZCODE_SYNC_KEEP` | `10` | `/sync-prune` 默认保留数量 |
|
||
| `ZCODE_SYNC_VERIFY_STRICT` | 关 | `1` 等价于 `verify.mjs --strict`(有警告即失败) |
|
||
| `ZCODE_SYNC_ALLOW_PATH_LEAK` | 关 | `1` 放行路径残留自检(确认无害时才用) |
|
||
|
||
## 换行符与字节一致性
|
||
|
||
快照是**字节级归档**:仓库里锁了 `.gitattributes`(`* -text`)并把本仓
|
||
`core.autocrlf` 设为 `false`。否则 Windows 上常见的 `core.autocrlf=true`
|
||
会在 add/checkout 时改写 CRLF,导致 manifest 里的 hash 与克隆下来的内容对不上,
|
||
`/sync-status` 会永久报「版本不同」。
|
||
|
||
## 测试
|
||
|
||
```bash
|
||
npm test # 63 项:单元 + 沙箱 E2E + 新命令(prune/verify/doctor/list)
|
||
npm run test:core # 纯函数单测(35 项)
|
||
npm run test:e2e # 沙箱端到端(11 项,伪造两台机器 + 本地裸仓)
|
||
npm run test:tools # 新命令(17 项:prune/verify/doctor/list/推送隔离)
|
||
node tests/live-roundtrip.mjs # 真机往返(需私仓已建 + 口令)
|
||
```
|
||
|
||
E2E 用 `USERPROFILE` 把 HOME 重定向到临时目录、用本地裸仓冒充 Gitea,
|
||
全程不碰真实 HOME、不走网络。覆盖:跨机路径还原、密钥解密落地、覆盖前备份、
|
||
密钥/路径泄漏闸门、CRLF 字节完整性、provider 脱敏与密钥跨机回填;
|
||
tools 那组覆盖 prune 保留策略与 latest 重指、verify 的 hash 篡改检测与口令不符分支、
|
||
doctor 的三种 fail 场景、status --list、以及**推送隔离**
|
||
(`git add` 只加本次快照,不把别的会话未推送的快照连带推上去)。
|
||
|
||
## 故障排查
|
||
|
||
**先说一句**:跑 `/sync-doctor`,它把这页大部分症状直接指出来并给修复命令。
|
||
|
||
| 现象 | 原因与处理 |
|
||
|---|---|
|
||
| `未设置 ZCODE_SYNC_PASSPHRASE` | 先 `export ZCODE_SYNC_PASSPHRASE='...'`(≥8 位) |
|
||
| `密钥束解密失败:口令不对或文件损坏` | 双方口令不一致,或 `secrets.enc` 被改过。先 `node verify.mjs --all` 看当前口令能用哪版 |
|
||
| `推送失败` | 私仓没建 / token 无写权限;token 读 `~/.dsh/secrets/gitea-token.txt` |
|
||
| token 权限不足建仓 | Gitea 个人建仓需 `write:user`;管理员可用 `POST /api/v1/admin/users/<user>/repos` |
|
||
| `自研插件名冲突` | 两个插件的 `plugin.json` 里 `name` 相同,改名或移走一个 |
|
||
| status 一直报「版本不同」 | 检查仓库 `.gitattributes` 是否为 `* -text`、`core.autocrlf` 是否为 `false` |
|
||
| `配置区残留未占位符化的绝对路径` | 配置里有写死的绝对路径,改成占位符或确认后设 `ZCODE_SYNC_ALLOW_PATH_LEAK=1` |
|
||
| `HEAD != origin/main` | 本地与远端分叉,先 `/sync-status` 看差异再决定推还是拉 |
|
||
| 快照太多占空间 | `node prune.mjs --keep 10` 预演,确认后加 `--apply` |
|
||
| 导出报告「落下 N 项非本次内容」 | 正常:仓库里有别人的未推送内容,`git add` 只加本次快照。那些文件仍在本地,不会丢 |
|
||
| `latest` 指向的快照解不开 | 该快照可能用了别的口令。`node verify.mjs --all` 找出当前口令能用的版本,再 `/sync-import <快照名>` |
|
||
|
||
## 安全说明
|
||
|
||
- 除 `secrets.enc` 外不含任何密钥明文;导出时有脱敏扫描,命中即中断
|
||
- git 网络操作使用带 token 的临时 URL,`origin` 只存干净地址,token **不落** `.git/config`
|
||
- 密钥还原时拒绝目录穿越与绝对路径,权限设为 `600`
|
||
- 私仓保持 **private**;换口令即换 `ZCODE_SYNC_PASSPHRASE` 后重导
|
||
- 导入前自动备份被覆盖文件到 `~/.zcode-env-sync/backup/<时间>/`,报告里给出实际路径
|