# 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/-/ 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 与 UI 分组(默认关) `~/.zcode/v2/config.json` 里通常是**活的 apiKey**,所以默认完全不碰(快照里无模型)。 需要同步时导出导入两边都显式开: ```bash ZCODE_SYNC_WITH_MODELS=1 node export.mjs # 导出:脱敏结构明文 + 真密钥进加密束 ZCODE_SYNC_WITH_MODELS=1 node import.mjs # 导入:结构合并 + 密钥回填 ``` ### 两份文件都要同步(只同步一份会导致「UI 分组少了」) | 文件 | 管什么 | 快照里的名字 | |---|---|---| | `v2/config.json` 的 `provider` | provider **能不能调**(kind、options、models 定义) | `v2.providers.json` | | `v2/provider_config.json` | UI **怎么分组显示**(providerOrder、group、access、personalModelIds、modelOrder,以及 contextWindow 等模型级配置) | `v2.provider-config.json` | 两份各带一份 `access.apiKey`,**不保证重叠**:有些分组只存在于 `provider_config.json` (在 UI 里建了但没落进 `config.json`)。只同步前者时那些分组到对面就是缺失的 —— 表现为「provider 数量对,但 UI 分组少几个」。所以现在两份都导出、都回填。 导出:两个文件都写**脱敏版明文**(密钥位置是 `${MODEL_SECRET_REF}`), 真密钥分别以 `MODELS//` 与 `PROVIDER_CONFIG//` 条目并入 `secrets.enc`;manifest 只记摘要 + 密钥哈希(`modelKeys` / `providerConfigKeys`),不记值。 导入(`ZCODE_SYNC_WITH_MODELS=1` 时才动这两个文件,先备份): - 快照有、本机无的 provider / 分组:整体新增 - 已有 provider:只补缺的模型定义与非密钥字段,不碰本机已有密钥 - 已有分组:按 `providerId` 合并,补缺失字段(含 `group`/`personalModelIds`/`modelOrder`) - 模型级配置:按 `(providerId, modelId)` 去重合并 - `providerOrder`:以快照顺序为准,本机特有的分组追加尾部(不丢本机分组) - 密钥回填:只填**空位**(占位符/空/过短);本机已有密钥默认跳过保护, 确需覆盖再加 `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//` 格式 | | `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//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/<时间>/`,报告里给出实际路径