Nodejsでcliを実装する - commander.js -

記事の内容に誤り、不快な内容が含まれている場合、コメント欄、または問い合わせからご一報ください。可能な限り急いで修正します。
Javascript 記事のフォーマット

頻繁に発生するオペレーション作業を自動化するなど、ちょっとしたCLIをツールを自作したいケースはよくあると思います。Shellでサクッと終わらせてもよいのですが、Web APIを利用するようなケースなど、高級言語で開発した方が効率か良いですし、副産物としてマルチOSで動作させられるおまけがついてきます。どうせやるなら今っぽいCLIを作りたい。ということで、Vue.jsReactを参考に、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 !

Last Updated: 2022/11/24 2:34:39