GitHubのリポジトリーに簡易インストーラーを設置する

GitHubのリポジトリーにインストーラーを置きたくて、調べたけどカンタンに~~パクれる~~参考にできる良い方法が見つからなかったので、スクラッチで作ってみました。
せっかくなので公開しておきます。

追記(2017-04-30): 大幅に修正しました。

<br />


## はじめに

パッケージ管理に入れるほどではないアプリ――野良アプリ？――を作った場合、カンタンにインストールできるようにしておけば、すぐに試してもらうことができるようになります。

要は、`README.md`にこう書きたいんです。

```
インストールするには、以下のコマンドを実行してください。

$ curl -fsSL https://raw.githubusercontent.com/... | sh
```

<br />


## 前提条件

ビルド済みバイナリーを配布している前提です。

今回紹介する例は、Javaで書いたJARファイルで実行するタイプのアプリをZIPで配布していますが、他のタイプでもビルド済みバイナリーであれば応用はできると思います。

配布物件のファイル名は、バージョンが`1.0.0-beta1-SNAPSHOT`の場合、`myapp1-1.0.0-beta1-SNAPSHOT-bin.zip`
の中に`myapp1-1.0.0-beta1-SNAPSHOT.jar`を格納するようにします。ZIPファイルには、他の配布物件も含まれています。

<br />


## 実行環境

### OS・処理系など

動作を確認した環境は、以下のとおりです。

（すべて64bit）

- Ubuntu 14.04
- CentOS 7 1611
- Fedora 25
- Darwin 16.5.0 (macOS Sierra 10.12.4)
- CYGWIN_NT-10.0 on Windows 10

また、動作確認はしていませんが、Debian 8.6.0を情報採取に使用しました。

<br />


### 使用しているモジュール・ツール

`cURL`, `unzip`と、標準コマンド以外は使わないで実行できるようにしました。

<br />


## 実装

先に完成版を載せておきます。
`argius`の`myapp1`リポジトリー（実際には存在しない）にある、`1.0.0-beta1-SNAPSHOT`をインストールするスクリプトです。

- `install.sh`

```sh
#!/bin/sh

# Installer

set -eu

prodname=myapp1
ver=1.0.0-beta1
owner=argius
execname=myapp1
execdir=/usr/local/bin

dir=~/.$prodname
baseurl=https://github.com/$owner/$prodname
bindir=$dir/bin
libdir=$dir/lib
zipfile=${prodname}-${ver}-bin.zip
zipurl=$baseurl/releases/download/v$ver/$zipfile
jarfile=${prodname}-${ver}.jar
jarpath=$libdir/$jarfile
binfile=$bindir/$execname
execfile=$execdir/$execname

errexit() {
  printf "\033[31m[ERROR]\033[0m" ; echo " $1"
  echo "Installation incomplete."
  exit 1
}

onexit() {
  if [ -n "$tmpdir" ]; then
    cd $tmpdir/..
    test -f $tmpdir/$jarfile && rm $tmpdir/$jarfile
    test -f $tmpdir/$zipfile && rm $tmpdir/$zipfile
    rmdir $tmpdir
  fi
}

tmpdir=`mktemp -d /tmp/${prodname}-XXXXXX`
trap onexit EXIT
trap "trap - EXIT; onexit; exit -1" 1 2 15 # SIGHUP SIGINT SIGTERM

echo "This is the installer of \"$prodname\"."
echo ""

# OS specific settings
case "`uname -a`" in
  Linux* )
    echo "adjusting for Linux"
    if [ -d ~/.local/bin ] && [ -w ~/.local/bin ]; then
      execdir=~/.local/bin
    elif [ -d ~/bin ] && [ -w ~/bin ]; then
      execdir=~/bin
    elif [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then
      execdir=/usr/local/bin
    else
      errexit "cannot detect writable exec dir, requires ~/.local/bin or ~/bin"
    fi
    execfile=$execdir/$execname
    echo ""
    ;;
  *BSD* )
    echo "adjusting for *BSD"
    if [ -d ~/bin ] && [ -w ~/bin ]; then
      execdir=~/bin
    elif [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then
      execdir=/usr/local/bin
    else
      errexit "cannot detect writable exec dir, requires ~/bin"
    fi
    execfile=$execdir/$execname
    echo ""
    ;;
  CYGWIN* )
    echo "adjusting for Cygwin"
    jarpath=`cygpath -m $jarpath`
    echo ""
    ;;
esac

echo "installing version $ver into $execdir and $dir,"
echo "and uses $tmpdir as a working directory."
cd $tmpdir || errexit "failed to change directory"
echo "downloading: $zipurl"
curl -fsSLO $zipurl || errexit "failed to download zip"
unzip -o $zipfile $jarfile || errexit "failed to unzip"

mkdir -p $libdir && cp -fp $jarfile $libdir/
test -f $libdir/$jarfile || errexit "failed to copy jar file"
mkdir -p $bindir && ( echo "#!/bin/sh" ; echo "java -jar $jarpath \$@" ) > $binfile
test -f $binfile || errexit "failed to create $binfile"
chmod +x $binfile || errexit "failed to change a permission"
ln -sf $binfile $execfile || errexit "failed to create a symlink of $binfile"

echo "\"$prodname\" has been installed to $execdir and $dir/ ."
echo "checking installation => `$execname --version`"
echo ""
echo "Installation completed."
```

<br />

以下はCentOSでの実行例です。

- 実行イメージ

```sh
$ sh install.sh
This is the installer of "myapp1".

adjusting for Linux
[ERROR] cannot detect writable exec dir, requires ~/.local/bin or ~/bin
Installation incomplete.
$ mkdir ~/.local/bin
$ sh install.sh
This is the installer of "myapp1".

adjusting for Linux

installing version 1.0.0-beta1 into /home/argius/.local/bin and /home/argius/.myapp1,
and uses /tmp/myapp1-0XGFEP as a working directory.
downloading: https://github.com/argius/myapp1/releases/download/v1.0.0-beta1/myapp1-1.0.0-beta1-bin.zip
Archive:  myapp1-1.0.0-beta1-bin.zip
  inflating: myapp1-1.0.0-beta1.jar  
"myapp1" has been installed to /home/argius/.local/bin and /home/argius/.myapp1/ .
checking installation => Myapp1 version 1.0.0-beta1

Installation completed.
$ myapp1
Hello argius, this is myapp1.
$ 
```

<br />


## 解説

このシェルスクリプトで使用している基本的なテクニックと、このスクリプトを書く上で考えたことについて補足します。

<br />


### OSごとの対応

どのディレクトリーにインストールすべきかを判断するため、いくつかのOSで情報採取しました。

列の意味は、以下の通りです。

- `uname`でディストリビューションなどの判定が可能か
- `/usr/local/bin`への書き込みが可能か
- `~/.local`ディレクトリーなどが初期状態で存在するか
- `~/.local/bin`などに初期状態でパスが通っているか

|          | `uname`個別 | `/usr/local/bin`書き込み | `~/.local`有 | `~/.local/bin`有 | `~/bin`有 | `~/.local/bin`パス | `~/bin`パス |
|----------|:-: |:-: |:-: |:-: |:-: |:-: |:-: |
| Ubuntu   | ○         | ×                      | ×          | ×              | ×       | ×                | ○         |
| CentOS   | ×         | ×                      | ○          | ×              | ×       | ○                | ○         |
| Fedora   | ×         | ×                      | ○          | ×              | ×       | ○                | ○         |
| Debian   | ○         | ×                      | ×          | ×              | ×       | ×                | ×         |
| Free BSD | ○         | ×                      | ×          | ×              | ×       | ×                | ○         |
| Mac      | ○         | ○                      | -          | -              | -       | -                | -         |
| Cygwin   | ○         | ○                      | -          | -              | -       | -                | -         |

<br />

Linuxは個別判定が難しいと判断し、多数決で`~/.local/bin`と`~/bin`をインストール先候補にしています。（これについては、コメント欄も参照して下さい。）
BSD系はサンプルが少なく、私も文化を良く知らないので、FreeBSDを代表として、`~/bin`をインストール先候補にしています。
MacやCygwinは`/usr/local/bin`に書き込み可と判断し、そこにインストールすることにしました。
上記以外のOSについては、`/usr/local/bin`にインストールするものとします。

Cygwinについては、CygwinからWindows版のJavaを使用している前提です。CygwinのパスをそのままJavaに渡すと上手く行きませんので、`cygpath`で変換したパスを渡すようにしています。

<br />


### 一時ディレクトリーの処理

Thanks to @tenmyo さん

下記の記事を参考にしました。

- [シェルスクリプトで一時ファイルを安全に扱う3つのコツ - Qiita](http://qiita.com/takuyachallenge/items/f7203e0b43924451fdaf)

<br />

一時ディレクトリーの作成は、`mktemp -d`を使用しています。
エラーや強制終了などで中断した場合でも削除できるように、`trap`を使用します。

ファイルとディレクトリーの削除は、意地でもワイルドカードを使わないようにしています。
ただ、この例ではファイルを2つしか展開しないのでこれで済んでいますが、展開されるファイルの数が増えるとワイルドカードを使わないのは難しいでしょうね。

シグナル番号を定数を使わずに数値リテラルで指定しているのは、一部の環境の`/bin/sh`では定数を使用できないためです。

<br />


### `set -eu`する

`-e`は、エラーが発生したら処理を中断するオプションです。
`-u`は、宣言されていない変数を使おうとした場合にエラーにしてくれるオプションです。

今回のスクリプトではエラー処理を細かく入れているのであまり意味はないですが、環境によっては予期しないエラーが起きる可能性はありますので、付けておくと少し安心です。

- [シェルスクリプトを書くときはset -euしておく - Qiita](http://qiita.com/youcune/items/fcfb4ad3d7c1edf9dc96)

<br />


### エラー処理

`errexit`関数を定義しておき、コマンドでエラーを返されたら終了できるようにしておきます。

典型的な使い方は以下のようなものです。

```sh
test -f /path/to/target || errexit "ファイルが無い"
```

`||`はOR条件を表します。`test`が成功していれば`||`以降は無視されます。`test`が失敗した場合は、`||`以降が実行され、メッセージを出力した上でスクリプトを終了させています。

成功かどうかの判定は、コマンド実行直後の特殊変数`$?`の値がゼロなら成功、それ以外なら失敗と見做されます。


<br />


### `echo`の引用符

`echo`は引用符が無くても動作しますが、スクリプトの見通しが良くなるので基本的につけるようにしています。

<br />


### 進捗メッセージ

無言だと、何をしているのかとか、上手く行っているのかとか、ユーザーさんを不安にさせてしまいますので、適度な量のメッセージを出力します。

先頭と末尾に空行を設けているのは、開始と終了を分かりやすくしているつもりです。

<br />


### `~/.myapp1`を使う

`~/.myapp1`には他にも必要なファイルを保管したりするのに使うので、`/usr/local/bin`に置くスクリプトファイル以外はここにまとめます。

<br />


### 実行スクリプトをその場で作成

実行スクリプトをリポジトリーで管理するとバージョンアップの際などに更新する手間が増えてしまうので、インストーラーに作らせています。

<br />


### 動作することを確認する

インストールが上手く行ったかどうかを、実際に実行して確認します。
`myapp1`は`--version`オプションでバージョンを出力するようになっていますので、そのコマンドを実行します。

<br />


### 英語のメッセージについて

それっぽいものを使っていますが、あまり自信がありませんので、ご利用の際にはその点にご留意願います。

<br />


## インストーラーの設置

インストーラーをリポジトリーのmasterブランチに登録しておきます。
先述の例に従うと、URLは`https://raw.githubusercontent.com/argius/myapp1/master/install.sh`になります。`https://raw.githubusercontent.com/`経由でアクセスすると、HTMLで修飾されていない生のファイルを参照することができます。これは、ファイルのページにある"Raw"ボタンを選択したときのURLと同じです。

このままでも良いですが、ちょっと長いので、短縮URLでインストーラーのURLを短縮します。
ここでは**Google URL Shortener**を使いました。

- [Google URL Shortener](https://goo.gl/)

実際には作っていないので、以降はダミーのURL`https://goo.gl/0v8bcj`（`google.com`の短縮）を使います。

<font color="Red">**※本物ではないので試さないでください。**</font>

```sh
$ curl -fsSL https://goo.gl/0v8bcj | sh
This is the installer of "myapp1".
(snip)

Installation completed.
$
```

<br />

できました！

<br />


## おわりに

作ってはみたものの、これで良いんだろうかと思わないでもないです。
もっと汎用的な方法があったら教えてください。


<br />


## 参考資料

- [curl コマンド 使い方メモ - Qiita](http://qiita.com/yasuhiroki/items/a569d3371a66e365316f)
- [シェルスクリプトを書くときはset -euしておく - Qiita](http://qiita.com/youcune/items/fcfb4ad3d7c1edf9dc96)