2台の Mac 体制になってから、私は開発環境をどう同期・管理しているのか

2026年4月14日火曜日 / , , , ,
簡体中文日本語
要約:
Mac mini が増えたことで、巨大な nix 設定と手動でコピーしていた dotfiles の管理が面倒になった。そこでシステム設定を組み合わせ可能なモジュールに分割し、Go でマニフェストに基づいてバックアップ・復元する小さなツール envctl を書いた。

ここ数年、私の開発環境はずっと1台の MacBook Pro だけにあったので、管理もかなり気楽でした。システム層は nix-darwin で、すべてを /etc/nix-darwin/flake.nix という1つのファイルに書いていて、次第に長くなっていきました。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

1台のコンピュータなら、これでも何とか使えていました。しかし最近 Mac mini M4 を新しく購入したことで、問題が一気に噴き出しました。

2台のコンピュータは大部分が同じで、コマンドラインツール、shell、ターミナル、git の設定は完全に一致させたいと考えています。一方で、違う点も少なくありません。MacBook には JetBrains の全製品、仮想マシン、各種リバースエンジニアリングやハードウェア関連のツールが入っていますが、Mac mini は主に常時起動してサービスを動かすためのもので、これらは必要ありません。mise でインストールする言語も違えば、Zed の設定も違い、各マシン固有の環境変数も異なります。

1つの巨大な flake.nix では明らかに2台のマシンを収められませんし、cp スクリプトは1台のコンピュータの設定を一方向にリポジトリへコピーするだけで、もう1台の存在などまったく知りません。そこで私は少し時間をかけて、環境全体を整理し直しました。

1つのリポジトリ、3つの層

まずすべてを1つのリポジトリ ~/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 がすべて混在していました。今回はそれを小さなモジュールに分割し、各モジュールが1種類のものだけを担当するようにしました。

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 が2つしかありません。

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

nix-darwin のモジュールシステムは、同じオプションの値を異なるモジュール間でマージするので、homebrew.casks は多くのファイルに分散して書くことができ、最終的には1つの完全なリストにまとめられます。このおかげで分割がとても自然になりました。

次に、各マシンごとに 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";
  };

こうしておけば、2台のコンピュータ両方にツールを追加したいときは共通モジュールを変更し、どちらか一方にだけ追加したいときはそのマシンの host ファイルを変更するだけです。将来マシンがもう1台増えても、新しい host ファイルを書いて、いくつかのモジュールを選んで組み合わせるだけで済みます。

home ディレクトリ配下の設定

システム層には nix-darwin がありますが、では home ディレクトリ配下の設定ファイルはどうするのでしょうか。

最初は chezmoi を使い、その外側にさらに shell スクリプトを被せて、よく使う操作を backup-home、apply-home、merge-home というコマンドにまとめていました。しかし2か月ほど使ってみて、その働き方が自分の習慣とはあまり合わないことに気づきました。

chezmoi の考え方は、リポジトリ内のソースファイルこそが唯一の正であり、ソースファイルを変更してから apply で home ディレクトリに反映する、というものです。しかし私の普段の習慣はまったく逆で、ほとんどの場合は home ディレクトリ配下のファイルを直接変更しますし、多くの設定はそもそも手動で変更したものではありません。たとえば Zed の設定は、Zed の画面で何度かクリックすると、Zed 自身が書き戻してくれます。そのため、ほぼ毎回逆方向に re-add して、変更をリポジトリへ戻さなければならず、両方で変更されている場合は3ウェイマージまでする必要がありました。

また chezmoi はソースファイルの名前を変えるので、~/.config/fzf/fzf-preview.sh はリポジトリ内では dot_config/fzf/executable_fzf-preview.sh という名前になり、リポジトリを見るときはいつも頭の中で変換しなければなりません。2台のマシン間の差異については、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 のファイルはリポジトリ内に1つだけ保存し、home/shared 配下に置いて、すべてのマシンで共通利用します。host のファイルはマシンごとに1部ずつ保存し、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 ディレクトリへ上書きするような操作は、毎回よく考えたうえでだけ行いたいからです。

システム層もまとめて管理する

せっかくこうしたツールができたので、システム層の操作も取り込み、リポジトリ全体の入口を1つにしました。

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 が成功した後、ついでにログインシェルも確認します。nix 設定から system.primaryUser を読み取り、fish がインストール済みで /etc/shells に含まれていることを確認したうえで、このユーザーの現在のログインシェルが何かを見ます。fish でなければ、dscl で変更します。そのため、新しいコンピュータで system apply を実行し終えると、デフォルトシェルはすでに 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 とほとんど違いません。ただ、Mac mini には不要なソフトウェアが入っていないだけです。

実装

envctl は全体でおよそ3000行の Go コードで、そのうちかなりの量がテストです。依存関係は TOML を解析するライブラリが1つだけあり、あとは標準ライブラリのみです。

壊さないように、実装はかなり慎重にしています。たとえばマニフェストは使用前に一度チェックされ、ルールの id とパスは重複できず、パスは $HOME の外に出られず、home/shared 配下のファイルは実際には host に属することができず、リポジトリ内にもどのルールからも管理されないファイルがあってはなりません。さらに、書き込む前には対象ファイルを再チェックし、計画と実行の間にファイルが変更されていたら、そのまま中止して、もう一度実行するように求めます。シンボリックリンクは決して追跡せず、リンク自体だけをコピーし、リンク先はコピーしません。

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