有了兩台 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...