TypeScript で CLI アプリケーションを作って GitHub repo でホストする
最近お仕事関連で TypeScript 製の CLI アプリケーションを実装することがあり、 npm install
できるようになるまでに何かとハマりがちだったので、メモをここに遺しておきます。
前提条件
- npm には公開せず、private な GitHub リポジトリでホストしたい
- 社内で使う内製アプリケーションなどを想定している
- GitHub Package Registry も利用しない
- 使ってもよかったが、諸々を整備するのに面倒だった (それ以外の特別な理由はない)
- トランスパイルされた JavaScript コードは git リポジトリに含めたくない
- ゆえに、
npm install
した時に自動的にtsc
でトランスパイルして JS コードを生成する必要がある
- ゆえに、
- 実行に不要な依存パッケージは
devDependencies
に押し込みたいtypescript
も (トランスパイルには必要だけど) 実行には不要なのでdevDependencies
にする
package.json の構成
サンプルの git リポジトリ にあるように、以下のような記述になります。
{
"name": "typescript-cli-example",
"version": "1.0.0",
"description": "Minimal example of the CLI app written in TypeScript.",
"bin": {
// トランスパイルにより生成される JavaScript ファイルを直接指定しても構わない
// その場合は元の .ts ファイルにシバン (#!/usr/bin/env node) を記述する必要がある
"typescript-cli-example": "./bin/typescript-cli-example"
},
"scripts": {
// install/preinstall など **ではなく**、この prepare ライフサイクルで
// tsc を実行する必要がある
"prepare": "tsc -p .",
"test": "echo \"Error: no test specified\" && exit 1"
},
"license": "ISC",
"devDependencies": {
"@types/node": "^14.14.0",
"typescript": "^4.0.3"
}
}
インストール方法
この CLI のパッケージをインストールする際は、 npm install 'git+ssh://git@github.com/<organizationまたはユーザ名>/<リポジトリ名>#<ブランチ名もしくはタグ>'
のように指定します。以下はサンプルの CLI をインストールする際の指定になります。
npm install 'git+ssh://git@github.com/komiya-atsushi/typescript-cli-example#main'
ハマりポイント
トランスパイルに適したライフサイクル
npm install
でインストールできるパッケージを作るために npm を触るのは実に久しぶり (GitHub のログを見る限りでは 9 年ぶりだった) なので以前のことは全く覚えていないしそもそも詳しくなかったのですが、まあ install
あたりのライフサイクルで tsc
すればいいんじゃね? ぐらいのノリで当初は考えていました。
でも実際に こんな感じ で scripts
フィールド の install
ライフサイクルで tsc -p .
と記述してみたところ、以下のように sh: tsc: command not found
となってしまいトランスパイルできませんでした。つまりは devDependencies
の依存パッケージは install
ライフサイクルでは扱えない、ということですね。困った困った…
❯ npm i 'git+ssh://git@github.com/komiya-atsushi/typescript-cli-example#bad-example/use-install-lifecycle-script'
> typescript-cli-example@1.0.0 install /Users/atsushi/tmp/typescript-cli-install-dir/node_modules/typescript-cli-example
> tsc -p .
sh: tsc: command not found
npm WARN typescript-cli-install-dir@1.0.0 No description
npm WARN typescript-cli-install-dir@1.0.0 No repository field.
npm ERR! file sh
npm ERR! code ELIFECYCLE
npm ERR! errno ENOENT
npm ERR! syscall spawn
npm ERR! typescript-cli-example@1.0.0 install: `tsc -p .`
npm ERR! spawn ENOENT
npm ERR!
npm ERR! Failed at the typescript-cli-example@1.0.0 install script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
npm ERR! A complete log of this run can be found in:
npm ERR! /Users/atsushi/.npm/_logs/xxx-debug.log
この件について調べてみると「postinstall-build
を使うといいよ」的な記事が見つかったものの、当の postinstall-build
のページ では This package has been deprecated と書かれていて「代わりに prepare
ライフサイクルを使ってね」と綴られていました。
そういうわけで改めて scripts
フィールドのドキュメントを確認してみると、Life Cycle Scripts のセクション の prepare
のところには
NOTE: If a package being installed through git contains a prepare script, its dependencies and devDependencies will be installed, and the prepare script will be run, before the package is packaged and installed.
と書かれています。
つまり「git リポジトリからパッケージをインストールする場合において prepare
ライフサイクルのスクリプトが存在すると、dependencies
/devDependencies
両方の依存パッケージがインストールされた上で prepare
で指定されたスクリプトが実行され、その後にパッケージ本体がインストールされる」ということで、確かに今回の前提条件 (git リポジトリからインストールするケース) には合致します。
ゆえに、git リポジトリからインストールする場合は prepare
ライフサイクルで tsc
を実行してトランスパイルするのが正解 ということになります。
.gitignore でのトランスパイル結果ファイルの除外
TypeScript でアプリケーションを書いていると、tsc
によって生成されるトランスパイル結果の JavaScript ファイルを .gitignore
ファイルで明示的に git リポジトリから除外する設定にしたくなるかもしれません。しかし 実際にそのような設定をしてしまう と、たとえトランスパイルが成功したとしても生成された JavaScript ファイルは最終的なインストール結果には含まれない模様です。
その結果、package.json の bin
フィールドで直接トランスパイル結果の JavaScript ファイルを指定している場合には chmod
できずに (no such file or directory
になるやつ、下記エラーログ参照) CLI のインストールに失敗してしまったり、もしくは CLI のインストールはできても実際に CLI を叩くと JavaScript ファイルが見つからない的な実行時エラーが発生してしまったりします。
❯ npm i 'git+ssh://git@github.com/komiya-atsushi/typescript-cli-example#bad-example/specify-transpiled-entry-point-as-bin'
npm WARN typescript-cli-install-dir@1.0.0 No description
npm WARN typescript-cli-install-dir@1.0.0 No repository field.
npm ERR! path /Users/atsushi/tmp/typescript-cli-install-dir/node_modules/typescript-cli-example/src/cli.js
npm ERR! code ENOENT
npm ERR! errno -2
npm ERR! syscall chmod
npm ERR! enoent ENOENT: no such file or directory, chmod '/Users/atsushi/tmp/typescript-cli-install-dir/node_modules/typescript-cli-example/src/cli.js'
npm ERR! enoent This is related to npm not being able to find a file.
npm ERR! enoent
npm ERR! A complete log of this run can be found in:
npm ERR! /Users/atsushi/.npm/_logs/xxx-debug.log
この問題を回避する妙案を僕は把握しておらず、.gitignore
ファイルにはトランスパイル結果のファイルを指定しない ようにしています 😢
まとめ
リリースアーティファクトであるパッケージを作らずに、TypeScript のコードをホスティングしている git リポジトリから CLI のパッケージをトランスパイルしつつインストールさせるのは通常はあまり採用しない手段かとは思うのですが、ハマりどころはあるにしても社内ツールを手軽に共有する手段としては悪くないかと思います。