dotfiles のインストーラーをつくる

![スクリーンショット](https://qiita-image-store.s3.amazonaws.com/0/102243/6fab1cb5-2fe9-66c2-fe3d-84226db6621b.png)


# なぜ dotfiles のインストーラーをつくるのか

* OSクリーンインストール後のインストールが面倒くさい
    * パッケージのインストール
    * **シンボリックリンクの作成**
* インストールのオペレーションミスの防止
* **異なるマシンで追加/変更した環境をインストーラー経由で反映させる**
* **ここまでオートメーションしたぞという自己満足を得る**

基本的に最後が主原因です。

# TL;DR 出来上がったもの

https://github.com/sei40kr/dotfiles/tree/master/install-scripts

# インストールスクリプトを何で書くか

* **シェルスクリプト** - 恐らく dotfiles を管理している勢にとって最も慣れ親しんだ言語。
* **Ansible** - システムサービス/タイムゾーンの設定などシステムワイドな設定に強いが、 Ansible は手動でインストールする必要がでてくる。(僕の場合 pyenv も) また、コマンドを書こうとするとシェルスクリプトベタ書きより面倒くさい。
* **その他**

# シェルスクリプトで書いてみる

シンボリックリンクを作成するだけならこれだけで十分。

```sh:install.sh
ln -s ~/.dotfiles/bash_profile ~/.bash_profile
ln -s ~/.dotfiles/bashrc ~/.bashrc
```

# シェルスクリプトでベタ書きした結果

**遅い!**

何が遅いのかというと

* **パッケージのアップデートの確認,ビルド&アップデート**
* **ユーザーの確認待ち (例: Would you like to install? [y/n])**
* **コマンド実行のオーバーヘッド**
    * パッケージマネージャーによってはインストール前に毎回最新のパッケージ情報をアップデートするものもある。これでは実行したコマンドの数だけアップデートが走る。

# じゃあどうするか

* なるべくコマンドをまとめる
* なるべくインタラクティブな応答を不要に
* オプションで既にインストールされているパッケージのアップデートを無効にできる

というわけで思いついた構成がこちら

## Facade

外部コマンドを操作するモジュール。コマンド単位で作成する。
実行するコマンドの引数を記憶しておき、最後に可能な限りまとめたコマンドを実行する。

```sh
# これが
pacman -S networkmanager
pacman -S dialog
# こうなる (アップデート無効時)
pacman -S --needed --noconfirm networkmanager dialog
```

参考までに、今回作成した `pacman` のFacade. (結局 Perl でつくりました)

```perl
my @pacman_sync_intermediate = ();

sub pacman_sync {
    my $pkg = $_[0];

    push( @pacman_sync_intermediate, $pkg );
}

my sub pacman_sync_reducer {
    return if ( scalar(@pacman_sync_intermediate) eq 0 );

    log_wait('Installing Pacman packages ...');

    Command::run( qw(sudo pacman -S --needed --noconfirm --noprogressbar),
        @pacman_sync_intermediate );
}

# 第1引数は優先度を示す数値であり、小さいほど実行が先となる
register_reducer( 10, \&pacman_sync_reducer );
```

僕が定めたベストな Facade の優先度はこちら (小さいほど先に実行する)

* 10 - ビルトインのパッケージマネージャーを操作 (例: `pacman`)
* 20 - ファイルのダウンロード/Git レポジトリをクローン (例: `git`, `curl`)
* 30 - スクリプトのダウンロード&実行 (例: `curl sh`, `curl ruby`)
* 31 - ソースコードからビルド&インストール (例: `make`, `makepkg`)
* 40 - ビルトインでないパッケージマネージャーを操作 (例: `trizen`, `brew`)
* 50 - コンパイラー,言語処理系のバージョンマネージャーを操作 (例: `rustup`, `goenv`, `pyenv`, `rbenv`, `nvm`)
* 60 - Stack を操作/rustup でコンポーネントを追加
* 61 - バージョンマネージャーに管理されているパッケージマネージャーを操作 (例: `cargo`, `go`, `pip`, `gem`, `yarn`)
* 70 - ファイル/ディレクトリを操作 (例: `cp`, `ln`, `chmod`)
* 80 - レジストリを操作 (例: `defaults`)
* 90 - サービス/デーモンを登録 (例: `systemctl`, `launchctl`)

## Installer

上記で定義した Facade を呼び出す。呼び出した段階ではコマンドは実行されず、すべての Installer が評価された後に実行される。

```perl
if (&is_arch) {
    pacman_sync('networkmanager');
    pacman_sync('dialog');
}
```

## 実行ファイル

にわか Perler であることをバラしていく。
https://github.com/sei40kr/dotfiles/blob/master/install

大まかな流れとして

* 引数のパース
* Facade の読み込み
* Installer の読み込み
* コマンドの生成&実行

## 小ネタ

Meguro.vim で話したら若干ウケたので。
`brew install` は複数パッケージのインストールに対応していませんが、 `brew bundle` は `Brewfile` に複数パッケージを記述することで1コマンドで複数パッケージをインストールできます。
高速化のために、動的に `Brewfile` を作成して標準入力から読ませる、ということをやりました。

`Brewfile` の作成

```perl
my sub generate_brewfile {
    my $s = '';

    foreach my $item (@brew_tap_intermediate) {
        # 自分用なのでインジェクション対策とかはしてない
        $s .= sprintf( "tap \"%s\"", $item->{user_and_repo} );
        $s .= sprintf( ", \"%s\"",   $item->{url} )
          if ( defined( $item->{url} ) );
        $s .= "\n";
    }

    foreach my $item (@brew_install_intermediate) {
        $s .= sprintf( "brew \"%s\", args: [%s]\n",
            $item->{formula},
            join( ', ', map { "'$_'" } @{ $item->{install_opts} } ) );
    }

    $s .= "cask_args appdir: \"~/Applications\"\n"
      if ( scalar(@brew_cask_install_intermediate) ne 0 );
    $s .= "cask \"${_}\"\n" foreach @brew_cask_install_intermediate;

    return $s;
}
```

標準入力から渡す

```perl
sub brew_reducer {
    return
      if (  scalar(@brew_tap_intermediate) eq 0
        and scalar(@brew_install_intermediate) eq 0
        and scalar(@brew_cask_install_intermediate) eq 0 );

    my @command = qw( brew bundle --file=- );
    unless (&do_update) {
        push( @command, "--no-upgrade" );
    }

    log_wait('Installing Homebrew repos, formulas, casks ...');

    my $brewfile = &generate_brewfile;
    Command::run_with_stdin($brewfile, @command)
}
```

実行イメージとしてはこんな感じ

```shell-session
$ brew bundle --file=- --no-upgrade <<EOM
brew "fish"
brew "fzf"
...
EOM
```

## その他

* 引数でインストールするものを指定できる。指定がなければすべてインストール。
* `--dry-run` - コマンドを表示するだけで実行はしない。Facade を実装するときに重宝した。
* `--update` - アップデートが利用可能であればアップデート。パッケージマネージャーによっては「アップデートする」オプションだったり「アップデートを無視」オプションだったりするが、Facade 内でその差を吸収する。

# 結果

異なるマシン間での環境の同期って大変だったんですけど、今回インストーラーを作ったことによりメンテする手間が増えもっと大変になりました 〜完〜