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 のパッケージをトランスパイルしつつインストールさせるのは通常はあまり採用しない手段かとは思うのですが、ハマりどころはあるにしても社内ツールを手軽に共有する手段としては悪くないかと思います。