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の開発に入れますね。それは本当に凄いことです。

まだちゃんとしたコマンドを作るところまでいってないですが、マルチコマンドもシングルコマンドも対応できますし、パッとみてどうなってるかもわかるので本当に楽に開発しはじめられる! という印象でした。

技術英語を本当に正しい発音をつらぬくか考えて、長いものに巻かれようと決めた話鬼滅の刃が嫌いだった話。或いは知ってる前提で進まれると寂しさを感じるという話。