问题:只同步 v2/config.json 时,只在 v2/provider_config.json 里存在的 provider 分组到对面就是缺失的 —— 表现为「provider 数量对,但 UI 分组少几个」。 实测:9 个分组里有 4 个(GINKA API / zxcbug / zxcbug-p / d1)纯规则型, 密钥只存在 provider_config.json 的 config.access.apiKey 里,旧实现完全没收集。 修复: - sync-core:新增 V2_PROVIDER_CONFIG、extract/sanitize/collectProviderConfigSecrets、 providerConfigKeyManifest;密钥命名空间 PROVIDER_CONFIG/<providerId>/<key> - export:导出 v2.provider-config.json(脱敏),密钥并入加密束,manifest 记摘要与哈希 - import:按 providerId 合并规则、回填密钥空位、按 (providerId,modelId) 合并模型级配置、 providerOrder 以快照为准并保留本机特有分组;重复导入幂等 - import:provider_config 路径补「护住本机已有密钥 N 个」提示(原来只在 config.json 侧有) - 测试:单元 6 项 + E2E 2 项(分组完整同步/强制覆盖),全量 72 项通过
ZCode 环境同步插件(zcode-env-sync)
团队共享场景:一键导出 / 导入本机 ZCode 环境,默认四项全量:
- 官方插件清单 — marketplace 版本 + 已启用列表(
data/目录即启用态) - 自研插件源码 —
~/.zcode/plugins/*整体按字节打包:你自己写的插件全在里面 (如本插件自身、zcode-tps、zcode-usage,以后新增的也自动带上);node_modules/.git排除,以.开头的备份目录不算插件, 两个插件 manifest 同名直接报错(否则快照里互相覆盖) - MCP 配置 —
cli/config.json的mcp.servers+plugins.dirs,路径占位符化 (gitea 的-H地址、GITEA_ACCESS_TOKEN_FILE指针都在这,身份各用各的见下) - 密钥束 —
~/.dsh/secrets/*+~/.ssh/ssh-profiles.json,AES-256-GCM 加密后才进仓 (含gitea-token.txt本体)
可选第 5 项(ZCODE_SYNC_WITH_MODELS=1):模型 provider 脱敏快照,见下。
后端:Gitea 私仓 root/zcode-env-sync。
安装
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 自动定位脚本,命令文档里不写死绝对路径——
快照要跨机器还原,写死的路径到了别的机器就是废的:
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 位时脚本直接报错退出,不会用弱默认口令。
快照保留与清理
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 设默认保留数。
快照校验
node verify.mjs # 校验 latest 指向的那个
node verify.mjs <快照名> # 校验指定快照
node verify.mjs --all # 扫全部:逐个标「结构OK / 可解密 / 口令不符」,给出能用的是哪个
node verify.mjs --strict # 有警告也算失败(退出码 1)
--all 用在团队仓里混着不同口令导出的快照时:GCM 认证失败无法区分"口令错"与"密文损坏",
它把每个都拿当前口令试一遍,直接告诉你 /sync-import <哪个> 能用。
快照结构
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 与 UI 分组(默认关)
~/.zcode/v2/config.json 里通常是活的 apiKey,所以默认完全不碰(快照里无模型)。
需要同步时导出导入两边都显式开:
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>/<key> 与 PROVIDER_CONFIG/<providerId>/<key>
条目并入 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/<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 会永久报「版本不同」。
测试
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/<时间>/,报告里给出实际路径