dotfilesで再構築可能なターミナル環境構築を目指してみた

2024.05.08

こんにちは、ターミナル住人の平野です。

私はターミナル住人なので 普段の業務は基本的にターミナルとブラウザとSlackで完結するような生活を送っています。 また私はプレーンテキスト原理主義者でもあるので テキストファイルで設定できないツールはあまり使いたくないな〜と思っています。 ターミナルにおける環境はたいていテキストファイルで設定ができますので、いわゆるdotfilesで管理しています。

そして私はコンピュータ内の環境はできるだけクリーンな状態を保ちたいという強めの思想を持っています。 これがどれほど一般的なのかは分かりませんが、私のこの感覚はかなり強い情動に繋がることがあり、 現在の環境をすべて破壊して作り直したい!!!! と衝動的に環境を破壊してしまうことがまれによくあります。

そこで、最近業務で使うmacが新しいマシンに切り替わったので、 このタイミングでできるだけ破壊可能なdotfilesを作ろうと試行錯誤してみました。 とりあえず一定の納得ができるレベルにはなったので、その内容をブログにしておきたいと思います。

なお最初に書いておきますが、 この記事で実現しようとしていることによって得られるものってほとんど自己満足だけですw 別に多少クリーンじゃない環境になったところで、必要な機能は大抵動きますので...。

方針と妥協点

構築・解体は1コマンドで

install.shuninstall.shを用意して、 それぞれの実行で環境・解体を作るようにします。 1コマンドで達成できるようにするところが大事で、 いつでも壊して作り直せるということが安心感につながります。

なおuninstall.shはツールの類を強行的に削除します。 これは本人が明確な意思を持って実行していることを前提としているので、 特に確認などは設けていません。 逆にそれくらい「気軽に環境はぶっ壊しても良い」というものになることを目指します。

冪等性

install.shuninstall.shを実行した結果には冪等性(何度も実行しても同じ結果になる)があるようにします。 こうしておけば、安心して何度も実行できます。 とにかく気楽に壊せる、作りなおせるが大事です。

rootユーザでのインストールを許容する

macで使うユーザは一つだけで、もちろん使うのは私一人だけです。 よって、ターミナルの設定というユーザ設定の中に閉じる内容であってもOS全体での設定を使うことも許容します。 本当はすべての設定をログインユーザのなかに閉じ込めておきたいのですが、 そこはハードルが高そうなので今回は諦めています。

どこまでを管理するか

どこまでをdotfilesで管理するかは非常に難しい問題です。 ある意味一番頭を悩ませる難しい問題かもしれません。 個人的には、ターミナルを操作する手が覚えているような操作系が再現されるところまで、 というのがおおよその目安かなと思っています。

一方で「これはちょっとやりすぎかな?」というようなものも一部に入っているので、 明確な基準はありません。 ただどこまでを管理対象とするかを考えたりするのも結構楽しくて、 いい塩梅の線引きを思いついた時は気持ち良くて幸せな気分になれます。

構築の前提

新しいmacに切り替えてこの環境を作り始まる際には、 情シスから指定された初期設定の処理以外何も余分なものをインストールしたりしないように かなり細心の注意を払いました。 よって、始状態はOSの初期状態にかなり近い状態になっているかと思います。

macには最初から結構色々なものがインストールされているので、 例えば以下のものは最初から入っている前提となります。

  • git
    • そもそもdotfilesをGitHubからcloneするところでも使う
  • perl
    • シェルスクリプトだけではちょっと辛い部分に少しPerlのワンライナーを使っている
  • curl
    • 最近はcurlでインストールスクリプトを取得しながら実行するものが多い

実際のファイル(の一部)

上記のような経緯で出来上がったものの一部をご紹介します。 実際の私のdotfilesはGitHubにありますので、もし気になる方はご参照ください。

ファイル構成

$ cd ~/dotfiles/mac
$ tree
.
├── install.sh
├── uninstall.sh
├── git
│   └── config_diff
└── zsh
    ├── zprofile
    ├── zshenv
    └── zshrc

ホームディレクトリにdotfilesディレクトリを作り、中にmacというディレクトリを作っています。 各設定ファイル内でLinuxやmacなどの環境ごとに分岐を作る方法もあると思いますが、 運用がかなり辛かったので私はこの階層でmac専用のディレクトリを切ってしまっています。 (将来全く同一のファイルを別の場所で管理することになるかもしれませんが、 直近は大体macしか使わないので今のところ気にしていません)

実際はもっと色々な設定ファイルを置いていますが、 基本的な設計思想を伝えるのがこの記事の意図なので割愛します。 Neovim関連の設定はまた今度の機会があれば書きたいと思っています。

install.sh

一通りの環境を構築するシェルスクリプトです。 brewなどもなく、ほとんどOSの初期状態に近い設定を初期状態として想定しています。 そしてその初期状態が次に紹介するuninstall.shで戻す先の状態となります。

#!/usr/bin/env bash
set -veuo pipefail

SCRIPT_DIR=$(perl -MCwd=realpath -le 'print realpath shift' "$0/..")
# スクリプト中で使われるPATHを予め通しておく
PATH="$PATH:/opt/homebrew/bin"
PATH="$PATH:$HOME/.local/share/mise/installs/python/latest/bin"
PATH="$PATH:$HOME/.local/share/mise/installs/node/latest/bin"

# ディレクトリのsymlinkを作る
function symlink_dir() {
    src=$1
    dst=$2
    [[ -L "$dst" ]] && rm -fr "$dst"
    ln -sf "$src" "$dst"
}


#
# Homebrew
#
if ! which brew; then
    bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi

#
# Git
#
mkdir -p $HOME/.config/
symlink_dir $SCRIPT_DIR/git $HOME/.config/git
brew install difftastic

#
# mise
#
if [[ ! -e "$HOME/.local/bin/mise" ]]; then
    curl https://mise.run | sh
fi

#
# Node
#
if ! which node; then
    $HOME/.local/bin/mise use --global node
fi

#
# Python
#
brew install xz
if ! which python; then
    $HOME/.local/bin/mise use --global python
fi

#
# Rust
#
sh <(curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs) -y

#
# zsh
#
mkdir -p $HOME/.config/zsh
ln -sf $SCRIPT_DIR/zsh/zprofile $HOME/.zprofile
ln -sf $SCRIPT_DIR/zsh/zshenv $HOME/.zshenv
ln -sf $SCRIPT_DIR/zsh/zshrc $HOME/.zshrc
ln -sf $SCRIPT_DIR/zsh/p10k.zsh $HOME/.config/zsh/p10k.zsh
brew install coreutils gnu-sed

gitやzshなどは設定をホームディレクトリ直下ではなくて~/.config/(アプリ名)にディレクトリレベルで分けるようにしています。 この考え方はXDG Base Directoryなどと呼ばれており、 見通しがよくなるので私は好んで採用しています。

NodeとPythonはmiseで管理します。 細かくバージョンを気にしない場合はmise経由でglobalにインストールしたものを使うようにします。

Rustも最初はmiseで管理しようと思ったのですが、 Neovimのcocがうまく動作させられなかったのと、 Rustは公式の管理ツールがしっかりしてると思った(多分)ので公式の方法で管理します。

zshはデフォルトだとXDG Base Directoryを見てくれないので、 zshrcなどは諦めてホームディレクトリに置いています。 zshもZDOTDIR環境変数を設定することでXDG Base Directoryを利用できますが、 この設定を/etc内のファイルに書くのを嫌ってホームディレクトリ直置きを採用しています。 一方、zshrcから明示的にパスを指定して読み込むファイル(例ではp10k.zsh)は ~/.config/zsh/に置いて読むようにしています。

uninstall.sh

次に環境を解体するスクリプトです。

#!/usr/bin/env bash
set -veuo pipefail

SCRIPT_DIR=$(perl -MCwd=realpath -le 'print realpath shift' "$0/..")

#
# zsh
#
/bin/rm -fr $HOME/.config/zsh
/bin/rm -f $HOME/.zshrc
/bin/rm -f $HOME/.zshenv
/bin/rm -f $HOME/.zprofile

#
# Rust
#
if which rustup; then
    rustup self uninstall -y
fi

#
# mise
#
if which mise; then
    mise implode -y
fi

#
# Git
#
rm -fr $HOME/.config/git

#
# Homebrew
#
if which brew; then
    echo 'Y' | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)"
fi
[[ -e /opt/homebrew ]] && sudo /bin/rm -fr /opt/homebrew

#
# General
#
/bin/rm -fr $HOME/.cache

installとは逆順で消していきます。 これは特に違和感ないかと思います。

miseやbrewはそれ自体をアンインストールすることで、そこで管理しているものをすべて一気にアンインストールします。 NodeやPythonは個別に消すのではなく管理ツールごと一気に吹き飛ばします。 これができるのでuninstall.sh自体は記述量がかなり少なくて済んでいます。

ツールで一気に削除できないものも、基本的にはディレクトリ単位で削除できています。 XDG Base Directoryに従うとこの辺の見通しがよくなるので良いですね!

brewまで消してしまうのは結構影響が大きいので、ここまでやるかは思想の強さ次第かもしれません。 これを実行すると当然brew install xxxが全部やり直しになりますが、 私はこの辺のインストールをJustInTimeで実行する感覚が好きです。 この感覚は個人差が大きいと思いますので、あくまでも私の趣味です。

余談ですが、最初はaws-vaultもbrewでインストールしたものを使っていました。 しかしそれだとbrewを消した時に登録してあったクレデンシャル情報も全部なくなってしまい、登録し直しが必要です。 これを毎回やるのはさすがに辛いので、aws-vaultはbrew経由ではなく実行ファイルを直接配置するようにしました。 このように単純にスクリプトで設定できない要素があるものはbrewを経由しない方が良さそうです。

まとめ

macのターミナル環境を、自分がある程度納得できるくらいには再構築可能な形にできました。 作業環境は必要最低限なもので綺麗にしておきたいと考えると、 すべてを破壊した上でもう一度作り直せると気持ちがとても楽です。 それがスクリプト一つの実行で簡単に行えるのであれば、クリーンな環境の維持がとても捗ります。

最後に、再構築可能な環境を維持するのに最も重要なことがあります。 それは日常的に環境の再構築を行うことです。 いくらプログラム的に再構築可能だと思う状態をキープしておいても、 普段全く実行していなければいざという時に怖くて解体はできません。 日常的に再構築をしていくことが環境をきれいに保つための最大のコツだと思います。 私も実際このブログを書きながら10回くらい作っては壊してを繰り返しており、 そのおかげで少しブラッシュアップもできたかなと思います。

以上、同じような思想をお持ちの誰かの参考になれば幸いです。