この記事はTwilio Developer AdvocateのDominik Kundelがこちらで執筆した記事の日本語版です。
Node.jsを使用して構築されたコマンドラインインターフェース(CLI)は、広大なNode.jsのエコシステムを活用し、反復的なタスクを自動化することができます。また、npm
やyarn
などのパッケージマネージャーを通じて簡単に配布でき、複数のプラットフォームで利用できます。この記事では、CLIの作成にNode.jsを使用するメリットとその方法、いくつかの便利なパッケージ、そして新しいCLIを配布する方法を解説します。
CLIの作成にNode.jsを使用する理由
Node.jsが人気を博した理由の1つは、npmレジストリ
に90万以上のパッケージを有するパッケージエコシステムであるという点です。Node.jsを使用してCLIを作成することで大量のCLI向けパッケージを含むエコシステムを利用することができます。たとえば、CLIで次のようなパッケージを利用できます。
- 複雑な入力プロンプト用の
inquirer
、enquirer
、prompts
- 便利な電子メール入力プロンプト用の
email-prompt
- カラー出力用の
chalk
またはkleur
- 美しいスピナーを作成する
ora
- 出力の周囲にボックスを描画するための
boxen
tmux
に似たUI作成用のstmux
- 進捗状況リスト用の
listr
- ReactによるCLI構築用の
ink
- 基本的な引数解析のための
meow
またはarg
- 複雑な引数の解析とサブコマンドサポートのための
commander
およびyargs
- Herokuによる拡張可能なCLI構築用フレームワーク
oclif
(代替としてgluegun
)
他にもnpm
に公開されているCLIをyarn
とnpm
の両方から利用する便利な方法がたくさんあります。たとえば、create-flex-plugin
はTwilio Flex用のプラグインのブートストラップに使用できるCLIですが、これをグローバルコマンドとしてインストールできます。
# npmを使用: npm install -g create-flex-plugin # yarnを使用: yarn global add create-flex-plugin # その後使用できるようになります: create-flex-plugin
またはプロジェクト固有の依存関係としてインストールできます。
# npmを使用: npm install create-flex-plugin --save-dev # yarnを使用: yarn add create-flex-plugin --dev # その後のコマンドの場所 ./node_modules/.bin/create-flex-plugin # またはnpmを使用したnpx経由: npx create-flex-plugin # およびyarn経由: yarn create-flex-plugin
また、npx
を用いてCLIをインストールせずに実行できます。npx create-flex-plugin
を実行すると、ローカルまたはグローバルにインストールされているバージョンが見つからない場合、キャッシュにダウンロードされます。
最後に、npm
バージョン6.1以降では、npm init
、yarn
は、create-*
という名前のCLIを使用してプロジェクトをブートストラップする方法をサポートしています。たとえばcreate-flex-plugin
の中で実際に使用されている呼び出し方法は次のようになります。
# Node.jsを使用 npm init flex-plugin # Yarnを使用: yarn create flex-plugin
最初のCLIをセットアップする
ここからのセットアップ方法はビデオチュートリアルが用意されています。良ければ、YouTubeのチュートリアルもご覧ください。
> YouTubeビデオを埋め込み
ここまででCLIの作成にNode.jsを使用するメリットを説明しました。ここからはCLIの作成を始めましょう。このチュートリアルではnpm
を使用しますが、yarn
もほぼすべてのコマンドを網羅しています。システムにNode.jsとnpm
がインストールされていることを確認してください。
このチュートリアルでは、npm init @your-username/project
コマンドで実行するCLIを作成し、その内部では新規プロジェクトをブートストラップします。
次を実行し、新しいNode.jsプロジェクトを開始します。
mkdir create-project && cd create-project npm init --yes
その後、プロジェクトのルートにsrc/
というディレクトリを作成し、その中にcli.js
というファイル作成します。作成したファイルには次のコードを配置します。
export function cli(args) { console.log(args); }
これは、後にロジックを解析し、実際のビジネスロジックをトリガーするパーツになります。次に、CLIのエントリーポイントを作成します。プロジェクトのルートに新しいディレクトリbin/
を作成し、その中にcreate-project
という新しいファイルを作成します。その中に次のコードを配置します。
#!/usr/bin/env node require = require('esm')(module /*, オプション*/); require('../src/cli').cli(process.argv);
この小さなスニペットにはいくつかの役割があります。まず、他のファイルでimport
を使用できるようにするために、esm
というモジュールが必要であると定義しています。これはCLIの構築と直接関係ありませんが、このチュートリアルではESモジュールを使用しており、esm
パッケージを使用するとサポートされていないバージョンのNode.jsにトランスパイルを行う必要がなくなります。その後cli.js
ファイルをrequireし、cli
関数を呼び出します。この呼び出しにはprocess.argv
でアクセスできるコマンドラインからこのスクリプトに渡されたすべての引数の配列をそのまま関数に渡しています。
スクリプトをテストする前に次のコマンドを実行し、依存関係にあるesm
をインストールします。
CLIスクリプトを公開していることをパッケージマネージャーに通知する必要もあります。そのため、package.json
に適切なエントリを追加します。また、忘れずにdescription
、name
、keyword
、main
プロパティを適宜更新します。
{ "name": "@your_npm_username/create-project", "version": "1.0.0", "description": "A CLI to bootstrap my new projects", "main": "src/index.js", "bin": { "@your_npm_username/create-project": "bin/create-project", "create-project": "bin/create-project" }, "publishConfig": { "access": "public" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ "cli", "create-project" ], "author": "YOUR_AUTHOR", "license": "MIT", "dependencies": { "esm": "^3.2.25" } }
ここでbin
キーを見ると、2つのキーと値のペアを持つオブジェクトを渡しています。これらのオブジェクトはパッケージマネージャーがインストールするCLIコマンドを定義しています。この例では、同じスクリプトを2つのコマンドに登録します。1つはユーザー名を使用して独自のnpm
スコープを使用し、もう1つは便宜上の汎用create-project
コマンドとします。
これでスクリプトをテストできます。テストにはnpm link
コマンドを使用するのが一番簡単な方法です。次のようにプロジェクト内のターミナルで実行します。
これにより、現在のプロジェクトにリンクするシンボリックリンクがグローバルにインストールされるため、コード更新の際にこの作業を再度実行する必要はありません。npm linkを実行すると、CLIコマンドが利用できるようになります。次のコマンドを実行してください。
次のような出力が表示されていれば正しく設定されています。
[ '/usr/local/Cellar/node/11.6.0/bin/node', '/Users/dkundel/dev/create-project/bin/create-project' ]
どちらのパスも、プロジェクトの場所やNode.jsがインストールされている場所により異なることに注意してください。この配列は、引数を追加するたびに長くなります。試しに次のコマンドを実行します。
[ '/usr/local/Cellar/node/11.6.0/bin/node', '/Users/dkundel/dev/create-project/bin/create-project', '--yes' ]
引数を解析し、入力を処理する
これで、スクリプトに渡される引数を解析し、利用を開始するための準備が完了しました。このCLIでは1つの引数と下記に記しているいくつかのオプションをサポートするように実装していきます。
[template]
: 異なるテンプレートを直接サポートします。これが渡されない場合、ユーザーにテンプレートの選択を促します --git
: git init
を実行し、新しいgitプロジェクトのインスタンスを作成します --install
: プロジェクトのすべての依存関係を自動的にインストールします --yes
: すべてのプロンプトをスキップし、デフォルトのオプションを使用します
このプロジェクトでは、不足している値の入力を促すためにinquirer
を使用し、また、CLIの引数を解析するためにarg
ライブラリを使用します。そのため次のコマンドを実行し不足している依存関係をインストールします。
まず、options
オブジェクトに引数を解析するロジックを記述しましょう。次のコードをcli.js
に追加します。
import arg from 'arg'; function parseArgumentsIntoOptions(rawArgs) { const args = arg( { '--git': Boolean, '--yes': Boolean, '--install': Boolean, '-g': '--git', '-y': '--yes', '-i': '--install', }, { argv: rawArgs.slice(2), } ); return { skipPrompts: args['--yes'] || false, git: args['--git'] || false, template: args._[0], runInstall: args['--install'] || false, }; } export function cli(args) { let options = parseArgumentsIntoOptions(args); console.log(options); }
この状態でcreate-project --yes
を実行すると、skipPrompt
がtrue
となります。あるいは、create-project cli
と、引数を渡して実行するとtemplate
に値がセットされます。
これでCLIの引数を解析できるようになりました。次に不足している情報の入力を促す機能と、--yes
フラグが渡された場合にデフォルトの引数を用いる機能を追加する必要があります。次のコードをcli.jsファイルに追加します。
import arg from 'arg'; import inquirer from 'inquirer'; function parseArgumentsIntoOptions(rawArgs) { // ... } async function promptForMissingOptions(options) { const defaultTemplate = 'JavaScript'; if (options.skipPrompts) { return { ...options, template: options.template || defaultTemplate, }; } const questions = []; if (!options.template) { questions.push({ type: 'list', name: 'template', message: 'どのプロジェクトテンプレートを使用するか選択してください', choices: ['JavaScript', 'TypeScript'], default: defaultTemplate, }); } if (!options.git) { questions.push({ type: 'confirm', name: 'git', message: 'gitリポジトリを初期化しますか?', default: false, }); } const answers = await inquirer.prompt(questions); return { ...options, template: options.template || answers.template, git: options.git || answers.git, }; } export async function cli(args) { let options = parseArgumentsIntoOptions(args); options = await promptForMissingOptions(options); console.log(options); }
ファイルを保存し、create-project
を実行すると、テンプレート選択のプロンプトが表示されます。
その後git
を初期化するか否かの質問がプロンプトされます。両方を選択すると、次のような出力が表示されます。
{ skipPrompts: false, git: false, template: 'JavaScript', runInstall: false }
同じコマンドに-y
を指定して実行すると、プロンプトがスキップされます。その代わりに決定されたオプションの出力がすぐに表示されます。
ロジックを記述する
プロンプトとコマンドライン引数で各オプションを決定できるようになるため、次にプロジェクトを作成する実際のロジックを記述しましょう。このCLIは、npm init
と同様に既存のディレクトリに書き込み、プロジェクトのtemplates
ディレクトリからすべてのファイルをコピーします。他のプロジェクトで同じロジックを再利用したい場合に備え、オプションによりターゲットディレクトリを変更できるようにします。
実際のロジックを記述する前に、プロジェクトのルートにtemplates
ディレクトリを作成し、その中にtypescript
とjavascript
という名前の2つのディレクトリを配置します。これらは、ユーザーに選択候補を表示した2つの値の小文字版です。この記事ではこれらの名前を使用しますが、他の名前を使用することもできます。このディレクトリには、プロジェクトのベースとなるpackage.json
や、プロジェクトにコピーしたい任意のファイルを配置します。後で我々が実装するコードはこれらのファイルを新しいプロジェクトにコピーします。どんなファイルを配置するかについてインスピレーションを得たい場合は、github.com/dkundel/create-projectを参考にしてください。
ファイルの再帰的コピーを行うために、ncp
というライブラリを使用します。このライブラリはクロスプラットフォームの再帰的コピーをサポートしており、既存のファイルを強制的に上書きするフラグも用意されています。さらに、カラー出力用にchalk
をインストールします。これらの依存関係をインストールするには、次のコマンドを実行します。
ここでは、すべてのコアロジックをプロジェクトのsrc/
ディレクトリ内にあるmain.js
ファイルに配置します。ファイルを作成し、次のコードを追加します。
import chalk from 'chalk'; import fs from 'fs'; import ncp from 'ncp'; import path from 'path'; import { promisify } from 'util'; const access = promisify(fs.access); const copy = promisify(ncp); async function copyTemplateFiles(options) { return copy(options.templateDirectory, options.targetDirectory, { clobber: false, }); } export async function createProject(options) { options = { ...options, targetDirectory: options.targetDirectory || process.cwd(), }; const currentFileUrl = import.meta.url; const templateDir = path.resolve( new URL(currentFileUrl).pathname, '../../templates', options.template.toLowerCase() ); options.templateDirectory = templateDir; try { await access(templateDir, fs.constants.R_OK); } catch (err) { console.error('%s Invalid template name', chalk.red.bold('ERROR')); process.exit(1); } console.log('Copy project files'); await copyTemplateFiles(options); console.log('%s Project ready', chalk.green.bold('DONE')); return true; }
このコードは、createProject
という新しい関数をエクスポートします。これはfs.access
を使用し、read
アクセス(fs.constants.R_OK
)をチェックすることにより指定されたテンプレートが本当に利用可能なテンプレートであるか否かをチェックした後に、ncp
を使用してターゲットディレクトリにファイルをコピーします。ファイルのコピーに成功した際に完了 プロジェクトの準備ができました
というログをカラーで表示します。
その後cli.js
を更新し、新しいcreateProject
関数を呼び出します。
import arg from 'arg'; import inquirer from 'inquirer'; import { createProject } from './main'; function parseArgumentsIntoOptions(rawArgs) { // ... } async function promptForMissingOptions(options) { // ... } export async function cli(args) { let options = parseArgumentsIntoOptions(args); options = await promptForMissingOptions(options); await createProject(options); }
ここまでの進捗状況を確認するため、システム上の~/test-dir
などに新しいディレクトリを作成し、その中でテンプレートのいずれかを使用したコマンドを実行します。例:
create-project typescript --git
プロジェクトが作成され、ファイルがディレクトリにコピーされます。
さて、ここからCLIに実行させたい手順があと2つあります。オプションによるgit
の初期化と依存関係のインストールです。ここでは、さらに3つの依存関係を使用します。
execa
はgitのような外部コマンドの実行を容易にします pkg-install
はユーザーが何を使用しているかに応じてyarn install
またはnpm install
をトリガーします listr
はタスクのリストを指定でき、ユーザーに進捗状況の概要を提供します
次を実行し、依存関係をインストールします。
npm install execa pkg-install listr
その後、次のコードが含まれるようにmain.js
を更新します。
import chalk from 'chalk'; import fs from 'fs'; import ncp from 'ncp'; import path from 'path'; import { promisify } from 'util'; import execa from 'execa'; import Listr from 'listr'; import { projectInstall } from 'pkg-install'; const access = promisify(fs.access); const copy = promisify(ncp); async function copyTemplateFiles(options) { return copy(options.templateDirectory, options.targetDirectory, { clobber: false, }); } async function initGit(options) { const result = await execa('git', ['init'], { cwd: options.targetDirectory, }); if (result.failed) { return Promise.reject(new Error('Failed to initialize git')); } return; } export async function createProject(options) { options = { ...options, targetDirectory: options.targetDirectory || process.cwd(), }; const templateDir = path.resolve( new URL(import.meta.url).pathname, '../../templates', options.template.toLowerCase() ); options.templateDirectory = templateDir; try { await access(templateDir, fs.constants.R_OK); } catch (err) { console.error('%s テンプレート名が無効です', chalk.red.bold('エラー')); process.exit(1); } const tasks = new Listr([ { title: 'プロジェクトファイルのコピー', task: () => copyTemplateFiles(options), }, { title: 'gitの初期化', task: () => initGit(options), enabled: () => options.git, }, { title: '依存関係のインストール', task: () => projectInstall({ cwd: options.targetDirectory, }), skip: () => !options.runInstall ? '依存関係を自動的にインストールする場合は --install オプションを渡してください' : undefined, }, ]); await tasks.run(); console.log('%s プロジェクトの準備ができました', chalk.green.bold('完了')); return true; }
このとき、--git
が渡されるか、ユーザーがプロンプトでgit
を選択するとgit init
を実行し、ユーザーが--install
を渡すとnpm install
またはyarn
を実行します。そうでない場合はタスクをスキップし、自動インストールを希望する場合は--install
を渡すように通知するメッセージが表示されます。
さきほどのテストフォルダを削除してから、新しいフォルダを作成してみましょう。そして、次のコマンドを実行します。
create-project typescript --git --install
フォルダ内にgit
が初期化されたことを示す.git
フォルダと、インストールされたpackage.json
で指定された依存関係がインストールされているnode_modules
フォルダの両方があることが分かります。
おめでとうございます。これで、最初のCLIの準備ができました。
自分のコードを実際のモジュールとして利用可能とし、そのロジックを他者が自分のコードに再利用できるようにしたい場合、src/
ディレクトリにmain.js
の内容を公開するindex.js
ファイルを追加する必要があります。
require = require('esm')(module); require('../src/cli').cli(process.argv);
次のステップ
CLIコードの準備ができましたが、この先は目的ごとに選択肢が異なります。自分専用とし、他者と共有しないのであれば、npm link
を使用します。実際にnpm init project
を実行してみると、コードがトリガーされます。
テンプレートを他者と共有したい場合は、コードをGitHubにプッシュしてそこから利用します。あるいはnpm publish
コマンドを使用し、スコープしたパッケージとしてnpm
レジストリにプッシュします。ただしその前に、package.json
にfiles
キーを追加し、どのファイルを公開するか指定する必要があります。
}, "files": [ "bin/", "src/", "templates/" ] }
公開されるファイルを確認したい場合、npm pack --dry-run
を実行し、出力を確認します。その後、npm publish
を使用してCLIを公開します。このチュートリアルで作成したプロジェクトは@dkundel/create-project
で見つけることができます。またはnpm init @dkundel/project
を実行してください。
また、さまざまな機能を追加することもできます。実際のパッケージでは、LICENSE
、CODE_OF_CONDUCT.md
および.gitignore
ファイルを作成する依存関係を追加しました。ソースコードはGitHubで公開されています。また、前述のイブラリを使用し、追加機能をチェックすることもできます。掲載してほしいライブラリがある場合や、ご自身のCLIを私に見せたい場合は、お気軽にメッセージをいただければ幸いです。