两台 Mac 之后,我是怎么同步和管理开发环境的

2026年4月14日星期二 / , , , ,
摘要:
多了一台 Mac mini 之后,一份超大的 nix 配置和手动复制的 dotfiles 太麻烦了。于是我把系统配置拆成可以组合的模块,再用 Go 写了一个按清单备份和恢复的小工具 envctl。

这几年我的开发环境一直只在一台 MacBook Pro 上,所以管理起来也比较随意。系统这一层是 nix-darwin,所有东西都写在 /etc/nix-darwin/flake.nix 这一个文件里,越写越长。home 目录下的各种配置文件,比如 zsh、git、终端和编辑器的配置,以前则是放在一个 ~/dot 仓库里,靠一个小脚本把它们 cp 进仓库,再手动提交。

Shell
# duplicate configuration file
cp ~/.config/starship.toml ./config # starship
cp -r ~/.config/alacritty ./config # alacritty
cp -r ~/.config/helix ./config # helix
cp ~/.zshrc ./home/.zshrc

一台电脑的时候,这样凑合着也能用。但是最近我新购入了一台 Mac mini M4,问题一下子就都冒出来了。

两台电脑大部分的东西是一样的,命令行工具、shell、终端、git 的配置,我都希望完全一致。可是它们又有不少不同的地方,MacBook 上装着 JetBrains 全家桶、虚拟机、各种逆向和硬件工具,而 Mac mini 主要是常开着跑一些服务,用不着这些。mise 里装的语言不一样,Zed 的设置不一样,每台机器自己的环境变量也不一样。

一个超大的 flake.nix 显然塞不下两台机器,而 cp 脚本只能单向地把一台电脑的配置复制进仓库,根本不知道另一台电脑的存在。所以我花了一些时间,把整套环境重新整理了一遍。

一个仓库,三层

我先把所有东西都收进了同一个仓库 ~/env,然后按照职责分成了几层。

层 负责的东西 工具
系统 系统设置、命令行工具、Homebrew 的 cask、launchd 服务、fish 本身 nix-darwin
home $HOME 下面的配置文件 envctl
运行时 编程语言和开发工具 mise
shell 插件 fish 的插件 fisher
TEXT
~/env
├── flake.nix
├── nix/
│   ├── hosts/              # 每台机器一个文件
│   └── modules/            # 可以组合的模块
├── home/
│   ├── shared/             # 所有机器共用的配置文件
│   └── hosts/<host>/       # 某台机器独有的配置文件
├── sync/manifest.toml      # 哪些文件要同步、归谁
├── cmd/envctl/             # envctl 的入口
└── internal/envctl/        # envctl 的实现

这个仓库是私有的,因为里面有一些只属于我自己的东西,比如每台机器的环境变量。

把 nix 配置拆开

原来那个 flake.nix 里,系统设置、命令行工具、几十个 cask 全都混在一起。这次我把它拆成了一个个小模块,每个模块只管一类东西。

TEXT
nix/modules/
├── common/
│   ├── base.nix               # nixpkgs、flakes、stateVersion
│   ├── cli.nix                # 日常命令行工具
│   ├── devtools.nix           # 编译器和开发工具
│   └── package-overrides.nix  # overlay
├── darwin/
│   ├── defaults.nix           # 系统设置、Touch ID sudo
│   ├── fish.nix
│   ├── homebrew.nix           # Homebrew 的公共设置
│   ├── launchd.nix
│   └── shell-env.nix
├── apps/                      # 按类别分组的 cask
│   ├── editors.nix
│   ├── fonts.nix
│   ├── jetbrains.nix
│   ├── terminals.nix
│   └── ...
├── profiles/                  # 某个方向的一整套工具
│   ├── network-tools.nix
│   ├── ai-tools.nix
│   └── media.nix
└── private/local.nix

模块都很短,比如终端这个模块,就只有两个 cask。

NIX
{ ... }:
{
  homebrew.casks = [
    "alacritty-full"
    "ghostty"
  ];
}

nix-darwin 的模块系统会把同一个选项在不同模块里的值合并起来,所以 homebrew.casks 可以分散写在很多个文件里,最后会被拼成一个完整的列表。这一点让拆分变得很自然。

然后每台机器有一个自己的 host 文件,选择要用哪些模块,再加上这台机器独有的东西。MacBook 的大概是这样的。

NIX
{ ... }:
{
  imports = [
    ../modules/common/base.nix
    ../modules/common/cli.nix
    ../modules/common/devtools.nix
    ../modules/darwin/defaults.nix
    ../modules/darwin/fish.nix
    ../modules/darwin/homebrew.nix
    ../modules/apps/editors.nix
    ../modules/apps/fonts.nix
    ../modules/apps/jetbrains.nix
    ../modules/apps/terminals.nix
    ../modules/profiles/network-tools.nix
    ../modules/profiles/ai-tools.nix
    ../modules/profiles/media.nix
    # ...
  ];

  networking.hostName = "brian-mbp-m1max";
  system.primaryUser = "brian";

  homebrew.casks = [
    # Virtualization and remote access
    "parallels"
    "utm"
    "vmware-fusion"

    # Reverse engineering, hardware, and inspection
    "imhex"
    "qflipper"
    # ...
  ];
}

Mac mini 则少了 devtools、jetbrains 和 media 这几个模块,多了一些只在它上面跑的服务,比如监控用的 beszel-agent。

NIX
{ pkgs, ... }:
{
  imports = [
    ../modules/common/base.nix
    ../modules/common/cli.nix
    ../modules/darwin/defaults.nix
    ../modules/darwin/fish.nix
    ../modules/darwin/homebrew.nix
    ../modules/apps/terminals.nix
    ../modules/profiles/network-tools.nix
    # ...
  ];

  networking.hostName = "brian-macmini-m4";
  system.primaryUser = "brian";

  homebrew = {
    taps = [ "henrygd/beszel" ];
    brews = [
      {
        name = "beszel-agent";
        restart_service = "changed";
        start_service = true;
      }
    ];
  };
}

最后 flake.nix 只剩下很少的几行,用一个小函数给每台机器生成一份配置。

NIX
outputs = inputs@{ self, nix-darwin, nixpkgs, ... }:
  let
    mkDarwin = host: system:
      nix-darwin.lib.darwinSystem {
        inherit system;
        specialArgs = { inherit inputs self; };
        modules = [ ./nix/hosts/${host}.nix ];
      };
  in
  {
    darwinConfigurations."brian-mbp-m1max" = mkDarwin "brian-mbp-m1max" "aarch64-darwin";
    darwinConfigurations."brian-macmini-m4" = mkDarwin "brian-macmini-m4" "aarch64-darwin";
  };

这样一来,想给两台电脑都加一个工具,就改公共的模块,只想给某一台加,就改它的 host 文件。以后要是再多一台机器,也只需要写一个新的 host 文件,挑几个模块组合起来。

home 目录下的配置

系统这一层有 nix-darwin,那么 home 目录下的配置文件呢。

一开始我用的是 chezmoi,外面再包了一层 shell 脚本,把常用的操作封装成 backup-home、apply-home、merge-home 这几个命令。但是用了两个多月,我发现它的工作方式和我的习惯不太合。

chezmoi 的思路是,仓库里的源文件才是唯一的真相,你应该去改源文件,然后 apply 到 home 目录里。可是我平时的习惯恰好相反,大部分时候我都是直接改 home 目录下的文件,而且很多配置根本不是我手动改的,比如 Zed 的设置,是在 Zed 的界面里点几下,它自己写回去的。所以我几乎每次都要反过来 re-add,把改动收回到仓库里,两边都改过的时候还要做三方合并。

另外 chezmoi 会给源文件改名,~/.config/fzf/fzf-preview.sh 在仓库里叫 dot_config/fzf/executable_fzf-preview.sh,翻仓库的时候总要在脑子里转换一下。至于两台机器之间的差异,chezmoi 的做法是用模板,在文件里写条件判断,这对我来说又太重了。

想了一下,我需要的其实很简单,和以前那个 cp 脚本差不多,只是要能分清哪些文件是共用的、哪些是某台机器自己的,并且两个方向都能走。所以我干脆用 Go 为这个仓库写了一个专用的小工具,叫 envctl。

envctl

envctl 的唯一任务就是按照一份清单,在 home 目录和仓库之间复制文件。

  • 方向由命令决定。home backup 永远是从 home 目录复制到仓库,home restore 永远是从仓库复制到 home 目录,程序自己从来不判断该往哪边同步。
  • 不做合并。没有隐藏的合并基准,也没有本地的同步数据库。两边都改了,就先 diff 看清楚,再决定用哪边,必要的话手动改好之后再同步。
  • 没有模板。每台机器不一样的文件,就直接各存一份。
  • 改之前先备份。每次 backup 或者 restore 要覆盖文件之前,都会把要被覆盖的东西先复制一份到 /tmp/envctl/ 下面,改错了随时可以拿回来。

清单

要同步哪些文件,都写在 sync/manifest.toml 里。每一条规则有一个 id、一个路径、类型是单个文件还是整个目录,以及最重要的归属,也就是 shared 还是 host。

TOML
version = 1

ignore = [
  "**/.DS_Store",
]

[[rules]]
id = "fish"
path = "~/.config/fish"
type = "tree"
ownership = "shared"
ignore = [
  "fish_variables",
  "completions/**",
  "themes/**",
]

[[rules]]
id = "fish-env"
path = "~/.config/fish/conf.d/05-env.fish"
type = "file"
ownership = "host"

[[rules]]
id = "mise-config"
path = "~/.config/mise/config.toml"
type = "file"
ownership = "host"

[[rules]]
id = "zed"
path = "~/.config/zed"
type = "tree"
ownership = "host"
ignore = [
  "conversations/**",
  "embeddings/**",
  "prompts/**",
]

[[rules]]
id = "zed-themes"
path = "~/.config/zed/themes"
type = "tree"
ownership = "shared"

shared 的文件在仓库里只存一份,放在 home/shared 下面,所有机器共用。host 的文件则每台机器各存一份,放在 home/hosts/<host> 下面。比如 fish 的配置整体是共用的,但其中的 05-env.fish 是每台机器自己的环境变量,所以单独拿出来设成 host。Zed 也是一样,设置每台机器不同,但主题是共用的。

规则之间可以嵌套,更具体的子规则会覆盖外层的规则。ignore 则用来排除那些插件生成的、或者应用自己的状态文件,比如 fisher 装进来的插件文件、Zed 的对话记录,这些都不需要同步。

当前是哪台机器,envctl 会用 scutil --get LocalHostName 自动判断,和 nix-darwin 的 host 名字是同一个。

日常使用

平时在某台电脑上改了配置,就先看一下改了什么,然后备份进仓库。

Shell
go run ./cmd/envctl home diff
go run ./cmd/envctl home backup --all --yes
git commit -am "update fish config"
git push

home diff 会像 git diff 一样输出统一格式的差异,文本文件显示具体改了哪几行,二进制文件则显示大小和哈希的变化。也可以只看或者只备份某一部分,比如 --id zed,或者 --path ~/.config/fish。

到了另一台电脑上,拉下最新的仓库,再恢复共用的那部分。

Shell
git pull
go run ./cmd/envctl home restore --ownership shared --yes

restore 必须指定要恢复哪部分,不带参数会直接报错,因为把仓库里的东西一股脑盖到 home 目录上,这种操作我希望每次都是想清楚了才做的。

系统这一层也一起管

既然有了这么一个工具,我就把系统层的操作也收了进来,这样整个仓库只有一个入口。

Shell
go run ./cmd/envctl system dry-run   # 只构建,不切换
go run ./cmd/envctl system apply     # sudo darwin-rebuild switch --flake ~/env#<host>
go run ./cmd/envctl fisher apply     # 按 fish_plugins 安装缺少的插件
go run ./cmd/envctl doctor           # 打印系统、shell、Homebrew、mise 的状态

system apply 在 darwin-rebuild switch 成功之后,还会顺便检查一下登录 shell。它从 nix 配置里读出 system.primaryUser,确认 fish 已经装好并且在 /etc/shells 里,再看这个用户当前的登录 shell 是什么,如果不是 fish,就用 dscl 改过去。所以一台新电脑跑完 system apply,默认 shell 就已经是 fish 了。

新电脑

所有这些步骤串起来,就是 bootstrap。一台全新的 Mac,只需要先装好 Nix 和 Homebrew,然后克隆仓库,借 Nix 临时拿一个 Go 来运行就行。

Shell
git clone <私有仓库> ~/env
cd ~/env
nix shell nixpkgs#go
go run ./cmd/envctl bootstrap
Mermaid diagram

bootstrap 会依次做这几件事。先应用 nix-darwin,把命令行工具、cask、系统设置和 fish 都装好,再恢复共用的配置文件和这台机器自己的配置文件,然后装 fish 插件,最后 mise install 把语言环境装上。剩下需要手动做的,就只有登录各个应用的账号了。

Mac mini 就是这么装起来的,跑完之后打开终端,和 MacBook 上几乎没有区别,只是少了那些它用不着的软件。

实现

envctl 一共三千行左右的 Go,其中还有有不少是测试。而依赖只用了一个解析 TOML 的库,其他都是标准库。

为了不把东西搞坏,我的实现非常的谨慎。比如清单在使用之前会先做一遍检查,规则的 id 和路径不能重复,路径不能跑到 $HOME 外面,home/shared 下面的文件不能实际归属于 host,仓库里也不能有任何规则都管不到的文件。再比如在写入之前,它会重新检查一遍目标文件,如果在计划和执行之间文件被改过了,就直接中止,让我重新跑一遍。符号链接一律不跟随,只复制链接本身指向的路径。

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...