Windows向けにdotfilesセットアップスクリプトを作る

# Windows向けにdotfilesセットアップスクリプトを作る

というのがテーマです。

会社ではLinux, Windows, Macで、自宅ではLinux, Windowsで開発をしています。
元々はLinux向けとしてdotfilesとして管理していましたが、[scoop](https://github.com/lukesampson/scoop)の出現によりモチベが急上昇、Windows環境もdotfilesとして管理できるよう頑張ってみました(あとMacも)。
本稿ではLinux向けにbashで書いたセットアップスクリプトを元にWindows向けにPowerShellで同じようなものを作った際に得たナレッジを紹介しています。

### 見せて

- https://gitlab.com/kentac55/.dotfiles/raw/master/make.ps1

これを`iex(new-object net.webclient).downloadstring($url)`のように実行すると環境が自動でマイグレートされます(注: そのまま実行すると一部のパスがWindows Defender管理外になりセキュリティリスクが上昇します)。

Windows 10 + (Windows PowerShell 5.1 | PowerShell Core 6)での動作を確認をしています。

### 環境変数

例えばzshなら`.zshenv`のシンボリックリンクを`$HOME`に作って終了ですがWindowsにそのような機能はないのでコマンドラインから設定します(PowerShellのみの反映で良いなら後述の`profile.ps1`に書いても良いと思います)。

```PowerShell
$newPath = @(
  "$env:USERPROFILE\bin"
  "$env:USERPROFILE\.dotnet\tools"
  "$env:USERPROFILE\.local\bin"
  "$env:USERPROFILE\scoop\shims"
  "$env:USERPROFILE\scoop\apps\python\current"
  "$env:USERPROFILE\scoop\apps\python\current\Scripts"
  "$env:USERPROFILE\scoop\apps\python27\current\scripts"
  "$env:USERPROFILE\scoop\apps\nodejs-lts\current\bin"
  "$env:USERPROFILE\scoop\apps\nodejs-lts\current"
  "$env:USERPROFILE\scoop\apps\ruby\current\gems\bin"
  "$env:USERPROFILE\scoop\apps\ruby\current\bin"
  "$env:USERPROFILE\scoop\apps\git\current\usr\bin"
  "$env:USERPROFILE\scoop\apps\git\current\mingw64\bin"
  "$env:USERPROFILE\scoop\apps\git\current\mingw64\libexec\git-core"
  "$env:USERPROFILE\AppData\Local\Programs\Python\Launcher"
  "$env:USERPROFILE\AppData\Local\Microsoft\WindowsApps"
) -join ";"
# カレントプロセスへの反映(bashでいうexport))
$env:PATH = $newPath + ';' + $env:PATH
# 永続化
[System.Environment]::SetEnvironmentVariable("PATH", $newPath, "User")
```

他tips

- Powershell上でHOMEディレクトリ(`C:\Users\<UserName>`)を指定したい場合、`$env:UserProfile`が正式な？表記ですが`~`や`$HOME`でも認識してくれます。
- bashの`PATH`の区切り文字は`:`ですがPowerShellのそれは`;`です。
- bashの場合、環境変数はCaseSensitiveですがPowerShellの場合CaseInsensitiveです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73816/bdc61347-719f-924d-b3c9-7d33c9690284.png)

### RunControlファイル

bashやzshのRunControl fileは`$HOME/.bashrc`や`$HOME/.zshrc`ですが、Powershellの場合は環境に応じて次の場所に置きます。

- Windows PowerShell -> `$env:USERPROFILE\Documents\WindowsPowerShell\profile.ps1`
- PowerShell Core -> `$env:USERPROFILE\Documents\PowerShell\profile.ps1`

また、shellのプロンプトを変えたい場合、bashやzshは`$PS1`を変更しますが、PowerShellの場合`profile.ps1`内の`Prompt`関数が評価・実行されます([公式doc](https://docs.microsoft.com/en-us/PowerShell/module/microsoft.PowerShell.core/about/about_prompts))。


### 特定のコマンドが使えるか調べる

bashの場合

```sh
if type $cmd > /dev/null 2>&1; then
    # $cmdがある
fi
if !(type $cmd > /dev/null 2>&1); then
    # $cmdがない
fi
```

PowerShellの場合

```PowerShell
if (Get-Command * | Where-Object { $_.Name -match $cmd }) {
    # $cmdがある
}
if (!(Get-Command * | Where-object { $_.Name -match $cmd })) {
    # $cmdがない
}
```

PowerShellで注意しなければいけないのは`Get-Command`の中身を`Where-object`する場合、`-eq`を使ってしまうと完全一致となってしまいマッチしません(例えばコマンド上から`go`を使う場合PowerShellは内部で勝手に`go.exe`に変換してくれている)。実行可能な拡張子は`.exe`のみならず`.bat`, `.ps1`等あるので`-match`句を使い部分一致をします(実際には正規表現が使えます)。

また、try...catch...finallyを使う方法もあります。

```PowerShell
try {
    Get-Command -Name $cmd -ErrorAction Stop
} catch [System.Management.Automation.CommandNotFoundException] {
    # 例えばインストールスクリプト実行
    # catchする型は省略しても良いです
} finally {
    $cmd
}
```

なお`-ErrorAction Stop`の代わりに`$ErrorActionPreference = "Stop"`としても良いです。

こんな感じ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73816/2d3dacc0-1bbd-84a6-7a2b-80420ae1c1a7.png)

### 特定のパスが存在するか調べる

bashの場合

```sh
if [ -e $target ]; then
    # 何かある
fi
if [ -f $target ]; then
    # ファイルがある
fi
if [ -d $target ]; then
    # ディレクトリがある
fi
```

PowerShellの場合([Test-Path](https://docs.microsoft.com/en-us/PowerShell/module/microsoft.PowerShell.management/test-path))

```PowerShell
if (Test-Path $target) {
    # 何かある
}
if (Test-Path $target -PathType Leaf) {
    # ファイルがある
}
if (Test-Path $target -PathType Container) {
    # フォルダがある
}
```

### 配列の展開

パッケージをインストール際はよく配列にして1行に1パッケージインストール等をすることが多いと思います。

bashの場合

```sh
pkgs=(
    "pip"
    "pipx"
)
pip install --upgrade $(IFS=' '; echo ${pkgs[*]})
```

PowerShellの場合

```PowerShell
$pkgs=@(
    "pip"
    "pipx"
)
pip install --upgrade $pkgs
```

注意するべきなのはbashとPowerShellで展開の仕方が異なることです。bashの場合、コマンドに配列を渡す際は`join`っぽいことをする必要がありますが、PowerShellの場合joinをすると逆に1つの文字列として解釈しまいます(`pip install "pip pipx"`となってしまうイメージ)。

↓よくない例

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73816/a13b7a3f-f878-72f8-af5f-d26e1e447e5e.png)

### パッケージのインストール

パッケージマネージャはシンボリックリンクを駆使してパッケージのバージョン管理をしてくれるscoopがお勧めです。
scoopの基本的な使い方はQiita上にもたくさんありますので各自ググってみてください。

さて、インストール作業の全てを自動化する場合、環境によってはインストール時に自動で依存パッケージ(パッケージの解凍ツール等)をダウンロードする機能が正常に動かなかったりします(特にWindows Sandboxなどの不安定な環境の場合)。

それを防ぐために先に依存パッケージを明示的にインストールします

- aria2
    - proxy対策です(後述)
- 7zip, lessmsi, dark
    - 基本的にscoopはzip, tar.gz, msiやexeを良い感じに解凍してくれます。PowerShellの機能で解凍できるファイルはzipのみなので、必要に応じて7zip, lessmsi, darkをインストールしてくれます・・・、が時々動かない :thinking: 
- git
    - `scoop bucket add`時に必要です

#### docker

WSL2を使ったdockerは現状docker desktop for windowsでないと使えません。そういった事情がある場合scoop経由ではなく手動で入れたほうが良いです。
ちなみにdocker desktop for windowsにはdocker-compose.exeがついてくるのでpipxで入れる必要もありません。


#### scoopとproxy

proxyに関しては[公式のドキュメント](https://github.com/lukesampson/scoop/wiki/Using-Scoop-behind-a-proxy)にも記載されています。

ただ、少し気を付けなければならないのは、Windows特有の仕様により場所によって使われるproxy情報が異なることです。

- scoopのインストールをする場合

    ```PowerShell
    [net.webrequest]::defaultwebproxy = new-object net.webproxy "http://proxy.example.local:3128"
    [net.webrequest]::defaultwebproxy.credentials = new-object net.networkcredential 'kentac55@example.local', 'P4SSW0RD'
    iex (new-object net.webclient).downloadstring('https://get.scoop.sh')
    ```

- scoopでパッケージをインストールする場合

    ```PowerShell
    scoop config proxy "kentac55\@example.local:P4SSW0RD@proxy.example.local:3128"
    scoop install pkg
    ```

- scoop updateをする場合
    - `netsh winhttp show proxy`の中身が使われます

特に`scoop install`と`scoop update`で使われるproxy情報が違う点が曲者です。混乱を避けるためにも普段使い慣れている`http_proxy`環境変数を読む`aria2c`の導入をおすすめします。

```PowerShell
[net.webrequest]::defaultwebproxy = new-object net.webproxy "http://proxy.example.local:3128"
[net.webrequest]::defaultwebproxy.credentials = new-object net.networkcredential 'kentac55@example.local', 'P4SSW0RD'
iex (new-object net.webclient).downloadstring('https://get.scoop.sh')
scoop config proxy "kentac55\@example.local:P4SSW0RD@proxy.example.local:3128"
scoop install aria2
scoop config rm proxy
# ここから$env:http_proxyが読まれる
scoop install git ...
```

proxyが絡むとセットアップスクリプトでの全自動化は難しくなってくると思います。私もproxyのある環境では導入のタイミングだけ一部手動で妥協しています :innocent: 

#### pythonの切り替えについて

Windowsではbashで書かれたpyenvは使えません。しかしscoopで似たようなことをできます。

```PowerShell
scoop install python
python --version
Python 3.8.0
scoop install python@3.7.0
python --version
Python 3.7.0
scoop reset python
python --version
Python 3.8.0
scoop reset python@3.7.0
python --version
Python 3.7.0
```

ところで`python`コマンドで2系と3系どちらが起動すべきかについてはUnix環境向けには[PEP394](https://www.python.org/dev/peps/pep-0394/)が、Windows環境向けには[PEP397](https://www.python.org/dev/peps/pep-0397/)がそれぞれ提供されています。PEP397には`py.exe`でハンドリングできるよと書いてありますが、scoop経由で入れると`py.exe -3`をしてもpython3を見つけてくれません。
また、LinuxとWindowsでいちいちコマンドを変えるのも面倒です。幸いにも2019/06にPEP394が改訂され、`python`コマンドでpython3起動させても良いよとなりました。併せてWindows側もpythonコマンドでpython3を立ち上げるようにするのが良いと思います(`scoop reset python`)。
なおpython2系を入れる度に`python`へのシンボリックリンクがpython2へ向いてしまうので都度`scoop reset python`することをお勧めします。

#### nightlyビルドについて

visual studio code insider版のようなnightlyビルドは`scoop update`をしても自動でアップデートされません。引数に`--force`をつけることで解決できます。

### パス中の空文字の扱い

VisualStudioCodeのOSS版やInsiders版を使うとconfigのパス中に空白文字が現れます。
Linuxの場合はエスケープに`\`を使いますがPowerShellの場合は `(バッククォート) を使います。

```sh
mkdir -p $HOME/.config/Code\ -\ Insiders/User
```

```PowerShell
New-Item $env:APPDATA\Code` -` Insiders\User -Force -ItemType Directory
```

### シンボリックリンク

Linux + bashの場合

```bash
ln -sf $HOME/.dotfiles/.vimrc $HOME/.vimrc
```

Windows + PowerShellの場合

```powershell
New-Item -Force -ItemType SymbolicLink -Value $env:USERPROFILE\.dotfiles\.vimrc -Path $env:USERPROFILE\.vimrc
```

Linuxの場合は通常ユーザー権限で作れますがWindowsの場合は管理者権限が必要です。
UACを切っていればそんなこと気にしなくても良いのですがUACのプロンプトはホッとさせる一面があるためなかなか切れません。
とはいえUACのプロンプトが出てきてセットアップが中断するのもイケていません。
そこで管理者権限が必要なコマンドを別のファイルに集約し、管理者権限用の別プロセスを立ち上げてそこで処理を行わせるようにします。

```PowerShell
Start-Process PowerShell.exe ("-NoProfile -Command cd " + $env:USERPROFILE + "\.dotfiles; .\runas.ps1") -Verb runas
```

こうすることでUAC画面が出てくる一方でメインのセットアップスクリプトは動き続けることが可能です。

余談ですがOneDrive上ではシンボリックリンクファイルは通常ファイルに変換されます。

## 終わりに

以上Windows環境をdotfile風に管理する方法を紹介してみました。

Linux向けのセットアップスクリプトを作るときはvagrantやdockerを使って挙動を確認していましたが、Windows向けに作った際はWindows SandboxとHyper-V上のWindows 10 dev environmentを活用しました。
特にWindows Sandboxはwsbファイルを作ってダブルクリックすれば一発で挙動を確認できるのである意味最強なのですが、微妙に挙動が怪しい部分があります。そのため、最終的な確認はWindows 10 dev environmentを使いました。