Nodejsでcliを実装する - commander.js -
頻繁に発生するオペレーション作業を自動化するなど、ちょっとしたCLIをツールを自作したいケースはよくあると思います。Shellでサクッと終わらせてもよいのですが、Web APIを利用するようなケースなど、高級言語で開発した方が効率か良いですし、副産物としてマルチOSで動作させられるおまけがついてきます。どうせやるなら今っぽいCLIを作りたい。ということで、Vue.js
、React
を参考に、commander
モジュールを使ったNodejsでのCLI実装について紹介します。
# Problem
Web APIを利用するCLIを開発することになりました。その他の機能要件として以下があるとします。
- 将来の拡張性を見据え、サブコマンドで実行できる
- ショートオプション、ロングオプション両方をサポートする
- ユーザフレンドリーなCLIにしたい
- ヘルプ、エラー出力を充実
- サブコマンドにはSuggestion機能を設ける
Shellで開発するには要件がリッチなので、高級言語を利用したいと思うでしょう。本ブログではJavascriptをメイン言語としているので、npmモジュールを利用していきます。ググってみても複数の選択肢があるのでどれを使えばよいでしょうか?
NOTE
この記事のサンプルコードはGithub で確認できます。
# Solution
この手の意思決定をするときのおすすめは、普段使っているOSSプロダクトの実装を参考にするです。そこで得た知見は、参考にしたOSSプロダクトの理解が深まる。コードの書き方の勉強になる。といいこと尽くしだからです。
この記事では私が普段使いしている以下OSSプロダクトを参考にしました。本記事執筆時点でのそれぞれのプロダクトで使っているCLI実装用のライブラりは以下のようになっています。
OSSプロダクト | CLI実装ライブライ | GitHubスター数 |
---|---|---|
Vue Cli | commander | ★17,344 |
Vue Press | cac | ★504 |
Gridsome | commander | ★17,344 |
Create React App | commander | ★17.344 |
※ 2020/03/16日時点
Vue, Reactの両巨頭が採用していること、GitHubのスター数も十分なことからcommander一択と考えて問題なさそうです。
# commander 概要
Readmeが充実しているので、流し読みしてもらえればその便利さにワクワクしてくると思います 最小限の実装でもこれだけのことができます。
const { program } = require('commander');
// version
program.version('1.0.0')
// options
program
// 引数をとらない flgオプション
.option('-d, --debug', 'output extra debugging')
// 引数をとるオプション。default valueも指定可能(3rd args)
.option('-s, --pizza-size <size>', 'S, M, or L', 'S')
// 必須オプション。直観通り未指定の場合エラーに倒してくれる
.requiredOption('-p, --pizza-type <type>', 'flavour of pizza');
program.parse(process.argv);
console.log(program.opts())
上記プログラムを実行してみます。
# 必須オプションのバリデーション
$ node cli.js
error: required option '-p, --pizza-type <type>' not specified
$ node cli.js -s M -p Margherita
{
version: '1.0.0',
debug: undefined,
pizzaSize: 'M',
pizzaType: 'Margherita'
}
# ロングオプションのサポート
$ node cli.js --pizza-size=M --pizza-type=Margherita -d
{
version: '1.0.0',
debug: true,
pizzaSize: 'M',
pizzaType: 'Margherita'
}
# Helpもいい感じに出力してくれる
$ node cli.js --help
Usage: cli [options]
Options:
-V, --version output the version number
-d, --debug output extra debugging
-s, --pizza-size <size> S, M, or L (default: "S")
-p, --pizza-type <type> flavour of pizza
-h, --help display help for command
10行前後でオプションのパース処理が完了してしまいました。あとはオプションに応じた処理を実装するだけです。生産的な作業のみに集中できますね。これぞ開発者エクスペリエンスです。
次セクションではより具体的な例として、GitHubのAPIとやりとりをするcommanderの実装をベースに、よりユーザフレンドリーなCLIを実装方法をご紹介します。
# Discussion
サンプルとして、GitHubのWeb APIとやりとりをするCLIを実装しました。同サンプルをベースに、Readmeからだとわかりずらいと思われる以下の実装方法について説明をします。
以下完成したCLIの出力結果です。2つのサブコマンドを実装しています。
main-branch
: 引数のRepositoryの本流ブランチをdevelopに変更(Git Flow)issues
: ステータスがOpenのIssueを一覧表示
以下CLI自体のヘルプです。GHEにアクセスするクレデンシャルなどはグローバルオプションとして定義しています
$ node bin/ghe.js --help
Usage: ghe <command> [options]
Options:
-v, --version output the version number
-u, --username <username> username of GitHub Enterprise call Web API
-p, --password <password> password of GitHub Enterprise user
-o, --org <org> organization of Github
-a, --api-base <apibase> api baseurl (eg. https://api.github.com)
-h, --help display help for command
Commands:
main-branch <repository-name> set a main branch
issues [options] <repository-name> search issues filterd lables
maine-barnch
サブコマンドのhelpと実行結果です
# hlelp
$ node bin/ghe.js main-branch --help
Usage: ghe main-branch [options] <repository-name>
set a main branch
Options:
-h, --help display help for command
# execution
$ node bin/ghe.js -u creep_32 -p ****** -o my-org --api-base https://api.github.com main-branch my-repository
INFO my-repository set default branch is 'develop'
issues
サブコマンドのhelpと実行結果です。--labels
オプションで渡されたlabelが付与されているissueを一覧表示します(複数選択可)
# help
$ node bin/ghe.js issues --help
Usage: ghe issues [options] <repository-name>
search issues filterd lables
Options:
-l, --labels <labels> comma separeted labels (default: [])
-h, --help display help for command
# execution
$ node bin/ghe.js -u creep_32 -p ****** -o my-org --api-base https://api.github.com -l bug,enhancement my-repository
INFO my-repository issues filterd [bug, enhancement]
┌────────────────────────────────────────┬──────────────────────────────┬────────────────────────────────────────┐
│ title │ assignee │ labels │
├────────────────────────────────────────┼──────────────────────────────┼────────────────────────────────────────┤
│ write test code │ creep_32 │ bug, enhancement, help wanted │
│ bug fix fro lib/command.js │ creep_42 │ bug, enhancement │
└────────────────────────────────────────┴──────────────────────────────┴────────────────────────────────────────┘
NOTE
この記事のサンプルコードはGithub で確認できます。
# サブコマンドの実装とポイント
(sub)commandの章で説明があるように、command
メソッドで登録することができます。高階関数でWrapすることで、今後サブコマンドが増えてもエラーハンドリングを一元管理化することができます。
program
.command('main-branch <repository-name>')
.description('set a main branch')
.action(function (repositoryName, cmd) {
const option = mergeOpts(cmd)
const command = require('../lib/commands/mainBranch')
return wrapCommand(command)(repositoryName, option)
})
// higher function for error handling
function wrapCommand (fn) {
return (...args) => {
return fn(...args).catch(err => {
error(err.stack)
process.exitCode = 1
})
}
}
グローバルオプションは、command
に直接option
メソッド経由で登録することができます。
// set Global Option
program
.requiredOption('-u, --username <username>', 'username of GitHub Enterprise call Web API')
.requiredOption('-p, --password <password>', 'password of GitHub Enterprise user')
.requiredOption('-o, --org <org>', 'organization of Github')
.requiredOption('-a, --api-base <apibase>', 'api baseurl (eg. https://api.github.com)')
ここでポイントです。サブコマンド実行時は、action
メソッドが呼ばれますが、cmd
引数には実行コンテキストが設定されたcommander
のインスタンスが渡されます。このインスタンスのopts
メソッドをコールした場合、サブコマンド用のオプションのみが返ってきます。グローバルオプションを取得するには、cmd.parent.opts()
というように、parent
プロパティのopts
メソッドをコールします。
program
.command('main-branch <repository-name>')
.description('set a main branch')
.action(function (repositoryName, cmd) {
const option = mergeOpts(cmd)
const command = require('../lib/commands/mainBranch')
return wrapCommand(command)(repositoryName, option)
})
// merge global and subcommand's option
function mergeOpts(cmd) {
return { ...cmd.parent.opts(), ...cmd.opts()}
}
# エラー出力の改善
例えば前セクションの実行例で紹介したように、デフォルトだとオプションのバリデーションに失敗した場合、以下のようにエラーの内容だけが表示されます。この時helpの内容も表示された方がユーザフレンドリーです。
# 必須オプションのバリデーション
$ node cli.js
error: required option '-p, --pizza-type <type>' not specified
Override exit handlingに説明があるように、終了時の処理をオーバライドすることができます。
By default Commander calls process.exit when it detects errors, or after displaying the help or version. You can override this behaviour and optionally supply a callback. The default override throws a CommanderError.
以下のようにexitOverride
メソッドを実装します。help, versionアクションを実行されたときもこのメソッドが呼ばれるので、その時はデフォルト通りの処理が行われるように条件分岐します。これはexitOverride
のコールバックに渡されるCommanderError
インスタンスのcode
プロパティで判断が可能です。
program.exitOverride(function (err) {
if (err && ! ['commander.version', 'commander.helpDisplayed'].includes(err.code)) {
this.outputHelp()
log('')
warn(err.message)
}
});
自身でカスタムエラーハンドリングを実施した場合もこの仕組みを利用することができます。_exit(exitCode, code, message)メソッドを呼ぶことで、適切なCommanderError
を放出することができ、カスタムエラーハンドリングを想定通りに機能させることができます。
以下はissues
サブコマンドで受け付ける--labels
オプションが想定外の値だった場合バリデーションエラーにして、helpと、エラーメッセージを表示する方法です。
program
.command('issues <repository-name>')
.description('search issues filterd lables')
.option('-l, --labels <labels>', 'comma separeted labels', parseLabels, [])
.action(function (repositoryName, cmd) {
const option = mergeOpts(cmd)
const allowedLabels = ['bug', 'documentation', 'duplicate', 'enhancement', 'good first issue', 'help wanted', 'invalid', 'question', 'wontfix']
option.labels.forEach(each => {
if (!allowedLabels.includes(each)) {
const msg = `"${each}" is not allowed value for -l, --labels option`
this._exit(1, 'self.optionNotAllowedValue', msg)
}
})
const command = require('../lib/commands/issues')
return wrapCommand(command)(repositoryName, option)
})
想定外のlabelが指定された場合、以下のようなエラー出力になります。
$ node bin/ghe.js -u creep_32 -p ****** -o my-org --api-base https://api.github.com -l bug,unexpected my-repository
Usage: ghe issues [options] <repository-name>
search issues filterd lables
Options:
-l, --labels <labels> comma separeted labels (default: [])
-h, --help display help for command
WARN "unexpected" is not allowed value for -l, --labels option
# サブコマンドにSuggestion機能を設ける
おまけに近い章になりますが、Gridsomeの実装を参考に、サブコマンドのSuggestion機能を実装してみました。
Specify the argument syntaxを使うと、case文のdefault句のように、明示的に設定されていないサブコマンドが指定された場合のactionを実装することができます。
suggestionは、二つの文字列の違いをレベルで返却してくれるlevenモジュールで実装しています。
// show a warning if the command does not exist
program.arguments('<command>').action(async function(command) {
const availableCommands = program.commands.map(cmd => cmd._name)
const suggestion = availableCommands.find(cmd => {
const steps = leven(cmd, command)
return steps < 3
})
console.log(suggestion)
if (suggestion) {
this._exit(1, 'self.thereIsSuggestion', `Did you mean ${suggestion}?`)
} else {
this.unknownCommand()
}
})
不要な機能だと思うかもしれませんが、このような遊び心を加えておくことで、同CLIを使うユーザの中にいる感度の高い開発者が実装内容に興味を示す可能性があります。興味を持ってもらえれば、コントリビュートしてくれる可能性が高くなり、それは良い開発サイクルを生むきっかけになります。ちょっとしたこだわりも大切にしたいものです。
# Conclusion
本記事ではJavascriptでCLIを実装してみました。オペレーションの手順をドキュメントに記載するのはもう終わりにして、CLIを開発してチームに展開しましょう!それが文化になれば、楽しい開発サイクルにつながるはずです。
Thank you for commander.js !