CLIツールを作るためにoclifを試してみたら簡単すぎて吃驚した
CLI用のコマンドを作ってみようと思いたったのですが、CLI開発フレームワークoclifを試してみたらとても簡単で環境構築もすんなりできたのでびっくりしたという話。
概要
- ある用途で思いたってCLIのコマンド作ろうと思った
- oclifというCLI用のフレームワークがあったので試してみた
- 簡単すぎてびっくりした
- 追加でPrettierとJestも対応してみました
動機
誰しもCLIのコマンドをつくりたくなることがたまにある。僕はある。
今回はGUIを作るまでもなく、コマンドでシュッと実行したい作業があったので勉強と遊びを兼ねてコマンドを作ることにしました。CLIの開発ツールはいろいろありますが、今回はやりたいことを実現するのにすでに知見としてあるものを流用したい背景aからNode.jsでやることにしまた。
Node.jsにもCLI用を作るためのライブラリがさらにいくつかありますが、今回はoclifというものを使うことにしました。
Open Command Line Interface Frameworkの略らしく、oclifはHerokuでも使われています。
期待すること
簡単にサクッと作りたい、かつ、TypeScriptに対応していて、テストまで書きやすいと尚いい、という感じですね。
実際やったこと
導入
まずはoclifの導入ですね。oclifにはscaffolding用のコマンドが用意されていますのでそれを叩きます。
と、その前にシングルコマンドかマルチコマンドかを指定する必要があります。
シングルコマンドはよくあるタイプのコマンドで
$ foo --bar baz
のようにfoo
コマンドだけの用途です。もちろんオプションを引数で指定できます。一方マルチコマンドというのは
$ foo bar --hoge
$ foo baz --fuga
みたいな、foo
コマンドの中でさらにサブコマンドが存在するパターンです。
とりあえずは sample
というシングルコマンドを作ってみようと思います。
$ npx oclif single sample
_-----_ ╭──────────────────────────╮
| | │ Time to build a │
|--(o)--| │ single-command CLI with │
`---------´ │ oclif! Version: 1.17.0 │
( _´U`_ ) ╰──────────────────────────╯
/___A___\ /
| ~ |
__'.___.'__
´ ` |° ´ Y `
と叩くと対話的に各設定が進んでいきます。
# npmのpackageの名称を設定
? npm package name sample
# コマンドの名称
? command bin name the CLI will export sample
# コマンド説明
? description a sample command by oclif
# 作者
? author Aqui TSUCHIDA @AquiTCD
# バージョン
? version 0.0.0
# ライセンス
? license MIT
# GitHubのオーナー
? Who is the GitHub owner of repository (https://github.com/OWNER/repo) AquiTCD
# GitHubのリポジトリ
? What is the GitHub name of repository (https://github.com/owner/REPO) sample
# package manager(yarn or npm)
? Select a package manager yarn
# TypeScriptを使うか
? TypeScript Yes
# ESlintを使うか
? Use eslint (linter for JavaScript and Typescript) Yes
# テストフレームワークとしてmochaを使うか
? Use mocha (testing framework) Yes
# CIを設定するか(e.g. circleCI)
? Add CI service config
と、こういう設定がでます。ここまで答えると設定した内容で新しいディレクトリができて、その中で指定したpackage managerで必要なライブラリのインストールが開始されます。
さらにTypeScriptを使うかどうかはY
で答えれば関連ライブラリも入れてくれますし、テストも組みこんでくれます(ただしmocha限定なのはちょっと残念)。
個人的にはこれが素晴しく便利で、自分でせっせとpackage.json
を書くことはしなくてもいいのはもちろんTSの導入までやってくれるのはありがたいです。TSの環境構築はわりとつまりやすかったりどのような方法にするか迷うポイントだったりするので大変助かります。
つまりこれでもう導入が完了しちゃいます。素晴しく楽チン。
動かしてみる
今回はシングルコマンドです。前述のスキャッフォールドでいくつかのオプションもすでに実装済みです。
$ cd sample # sampleとして作ったので移動
$ bin/run
# => hello world from ./src/index.ts
$ bin/run --name Aqui
# => hello Aqui from ./src/index.ts
$ bin/run --help
# describe the command here
#
# USAGE
# $ sample [FILE]
#
# OPTIONS
# -f, --force
# -h, --help show CLI help
# -n, --name=name name to print
# -v, --version show CLI version
という感じです。すごいですね。あとはじっくり/src/index.ts
の中をせっせと実装すれば完成しちゃいます。楽ですね!
環境をもうちょっと整える
Prettierを導入
Node.js系のフォーマッタであるPrettierを入れます。ESlintと組み合わせるとき、このあたりはコロコロと短期間でベストプラクティスが変わるので要注意ですが、現時点での方法を考えてみましょう。
現時点でもっとも有力な話としては、ESLintとPrettierは別個でかける(pretter-eslintやeslint-plugin-prettierを使わない)という考え方のようです。
こちらを参考にさせていただきました。
まず、prettierとeslint-config-prettierを入れます。
$ yarn add -D prettier eslint-config-prettier
eslint-config-prettierはPrettierとESlint両方の対応があるものはESlint側の設定を無効化するものです。これは入れておきます。
次にESlintの設定を書きます。スキャッフォールド時に作られてるはずなので.eslintrc
を変更するのですが、個人的にはコメントアウトが効くからjs形式がお勧めです。
つまり .eslintrc.json
を.eslintrc.js
にリネームして
module.exports = {
extends: ['oclif', 'oclif-typescript', 'prettier'],
rules: {
// ...ご自由に
}
}
とします。
Prettierもデフォルト動作以外にルールを指定したい場合は.pretterrc
を作ります。これも同じく個人的にはjsで作るのがいいと思っています。
次にpackage.jsonを少し修正しておきます。
// ... 略
{
"scripts": {
// ...略
"lint:eslint": "eslint . --ext .ts --config .eslintrc.js",
"lint:prettier": "prettier . \"!test*\" --check",
"fix": "yarn fix:eslint && yarn fix:prettier",
"fix:eslint": "yarn lint:eslint --fix",
"fix:prettier": "yarn lint:prettier --write"
}
}
と、こんな変更箇所はこんな感じですかね。
ESLintとPrettierを切り離しているのでfix
ところで&&
を使って連結しています。
mochaではなくJestを使う
oclifではMochaが使われています。Mocha一択です。ですが、いろんな理由からMochaを使わず変わりにJestを使いたいこともあると思います。
ここでやや面倒なのがMochaを使う場合 、@oclif/test
というテスト用ユーティリティを使いますが、これはMochaで使うことが前提なのでJestでは使えません。そのあたりも含めてJestをどう導入していくかをやっていきます。
Jestの導入
まずJestを入れます。TSでやるとして、ここではBabel経由でやる方法ではなくts-jest
を使う方法でやっていきます。
$ yarn add -D jest ts-jest @types/jest
次にJestの設定を書きます。jest.config.js
をプロジェクトルートに作って
module.exports = {
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'js', 'json'],
testPathIgnorePatterns: [
'/node_modules/',
'<rootDir>/__tests__/helpers/'
],
transform: { '^.+\\.(ts|tsx)$': 'ts-jest' },
coverageReporters: ['lcov', 'text-summary'],
collectCoverageFrom: ['src/**/*.ts'],
coveragePathIgnorePatterns: ['/templates/'],
coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},
}
こんなふうに書きました。これは一例なので好みによって変わってきます。 oclifのJest導入のmergeされずにcloseされたPRがあるのですが概おおむね違ってないのでそれを参考にしています。注意点として
transform: { '^.+\\.(ts|tsx)$': 'ts-jest' },
ここはts-jest
を使うのに必要です。
それと、
testPathIgnorePatterns: [
'/node_modules/',
'<rootDir>/__tests__/support/'
],
が注意ポイントです。jestのデフォルトではテストファイルは__tests__
というディレクトリの置くことになっています。今回@oclif/test
を使えない変わりにJestでmockしたいところをhelperとして書くことにしました。
また、package.jsonにも
{
// ...略
"scripts": {
// ...略
"test": "jest",
}
}
を追加しておくといいでしょう。
Jestでoclifを書くためのhelperを作る
この記事が詳しいです。
https://martianwabbit.com/2018/05/25/testing-oclif-with-jest.html
簡単に言えばoclifの最終的な結果である標準出力をJestのmockを使って取得できるようにしています。
これと同じ方法で上手く切り出してる例が https://github.com/LiskHQ/lisk-core/issues/322 にありました。
これらを参考にして __test__/support/testCommand.ts
にhelperを作ってみます。
import { Command } from '@oclif/command'
export const testCommand = async (
klass: typeof Command,
argv: string[] = []
): Promise<{ stdout: string[] }> => {
const result: string[] = []
const originalWrite = process.stdout.write
const writeMock = jest
.spyOn(process.stdout, 'write')
.mockImplementation((val, encoding, cb): boolean => {
result.push(val as string)
if (process.env.TEST_OUTPUT) {
originalWrite(val, encoding, cb)
} else if (cb) {
cb()
}
return true
})
await klass.run(argv)
writeMock.mockRestore()
return {
stdout: result,
}
}
そうしたら、テストのほうで読みこんで使うようにします。__tests__/index.test.ts
は具体的にこんな感じになるでしょう。
import { testCommand } from './support/test-command'
import Sample = require('../src/index')
describe('elgtm', () => {
it('returns hello world', async () => {
const { stdout } = await testCommand(Sample, [])
expect(stdout[0]).toContain('hello world')
})
})
これでyarn test
なりjest
なりを叩いてみてテストがパスすればOKですね!
やってみた結果
prettierの導入自体はoptionalなのですが、まあこれらのベストプラクティスがコロコロ変わるNode.js系はツライなあ、という感じです。
テストフレームワークをJestに対応させないのはおそらく関連するhelperもJest用に作らなければいけないことで進んでいないようですが、これはPRやIssueもあるのでできれば積極的に対応して欲しいと感じました。
感想
PrettierとJestの導入にちょっと面倒さを感じましたが、逆を言えば、これらのことはやらずスキャフォールディングで作られるそのままの環境でOKであれば爆速でCLIの開発に入れますね。それは本当に凄いことです。
まだちゃんとしたコマンドを作るところまでいってないですが、マルチコマンドもシングルコマンドも対応できますし、パッとみてどうなってるかもわかるので本当に楽に開発しはじめられる! という印象でした。