Node.jsを使用したCLIの構築方法

March 19, 2019
執筆者
レビュー担当者

Node.js - CLI

この記事はTwilio Developer AdvocateのDominik Kundelがこちらで執筆した記事の日本語版です。

Node.jsを使用して構築されたコマンドラインインターフェース(CLI)は、広大なNode.jsのエコシステムを活用し、反復的なタスクを自動化することができます。また、npmyarnなどのパッケージマネージャーを通じて簡単に配布でき、複数のプラットフォームで利用できます。この記事では、CLIの作成にNode.jsを使用するメリットとその方法、いくつかの便利なパッケージ、そして新しいCLIを配布する方法を解説します。

CLIの作成にNode.jsを使用する理由

Node.jsが人気を博した理由の1つは、npmレジストリに90万以上のパッケージを有するパッケージエコシステムであるという点です。Node.jsを使用してCLIを作成することで大量のCLI向けパッケージを含むエコシステムを利用することができます。たとえば、CLIで次のようなパッケージを利用できます。

  • 複雑な入力プロンプト用のinquirerenquirerprompts
  • 便利な電子メール入力プロンプト用のemail-prompt
  • カラー出力用のchalkまたはkleur
  • 美しいスピナーを作成するora
  • 出力の周囲にボックスを描画するためのboxen
  • tmuxに似たUI作成用のstmux
  • 進捗状況リスト用のlistr
  • ReactによるCLI構築用のink
  • 基本的な引数解析のためのmeowまたはarg
  • 複雑な引数の解析とサブコマンドサポートのためのcommanderおよびyargs
  • Herokuによる拡張可能なCLI構築用フレームワークoclif(代替としてgluegun

他にもnpmに公開されているCLIをyarnnpmの両方から利用する便利な方法がたくさんあります。たとえば、create-flex-pluginTwilio 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 inityarnは、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.jsnpmがインストールされていることを確認してください。

このチュートリアルでは、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をインストールします。

npm install esm

CLIスクリプトを公開していることをパッケージマネージャーに通知する必要もあります。そのため、package.jsonに適切なエントリを追加します。また、忘れずにdescriptionnamekeywordmainプロパティを適宜更新します。

{ 
  "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

これにより、現在のプロジェクトにリンクするシンボリックリンクがグローバルにインストールされるため、コード更新の際にこの作業を再度実行する必要はありません。npm linkを実行すると、CLIコマンドが利用できるようになります。次のコマンドを実行してください。

create-project

次のような出力が表示されていれば正しく設定されています。

'/usr/local/Cellar/node/11.6.0/bin/node'

どちらのパスも、プロジェクトの場所やNode.jsがインストールされている場所により異なることに注意してください。この配列は、引数を追加するたびに長くなります。試しに次のコマンドを実行します。

create-project --yes

出力には新しい引数が反映されます。

'/usr/local/Cellar/node/11.6.0/bin/node'

引数を解析し、入力を処理する

これで、スクリプトに渡される引数を解析し、利用を開始するための準備が完了しました。このCLIでは1つの引数と下記に記しているいくつかのオプションをサポートするように実装していきます。

  • [template]: 異なるテンプレートを直接サポートします。これが渡されない場合、ユーザーにテンプレートの選択を促します
  • --git: git initを実行し、新しいgitプロジェクトのインスタンスを作成します
  • --install: プロジェクトのすべての依存関係を自動的にインストールします
  • --yes: すべてのプロンプトをスキップし、デフォルトのオプションを使用します

このプロジェクトでは、不足している値の入力を促すためにinquirerを使用し、また、CLIの引数を解析するためにargライブラリを使用します。そのため次のコマンドを実行し不足している依存関係をインストールします。

npm install inquirer 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を実行すると、skipPrompttrueとなります。あるいは、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を実行すると、テンプレート選択のプロンプトが表示されます。

Nodejs - CLI - slections

その後gitを初期化するか否かの質問がプロンプトされます。両方を選択すると、次のような出力が表示されます。

{ skipPrompts: false, 
  git: false, 
  template: 'JavaScript', 
  runInstall: false }

同じコマンドに-yを指定して実行すると、プロンプトがスキップされます。その代わりに決定されたオプションの出力がすぐに表示されます。

nodejs - cli - output

ロジックを記述する

プロンプトとコマンドライン引数で各オプションを決定できるようになるため、次にプロジェクトを作成する実際のロジックを記述しましょう。このCLIは、npm initと同様に既存のディレクトリに書き込み、プロジェクトのtemplatesディレクトリからすべてのファイルをコピーします。他のプロジェクトで同じロジックを再利用したい場合に備え、オプションによりターゲットディレクトリを変更できるようにします。

実際のロジックを記述する前に、プロジェクトのルートにtemplatesディレクトリを作成し、その中にtypescriptjavascriptという名前の2つのディレクトリを配置します。これらは、ユーザーに選択候補を表示した2つの値の小文字版です。この記事ではこれらの名前を使用しますが、他の名前を使用することもできます。このディレクトリには、プロジェクトのベースとなるpackage.jsonや、プロジェクトにコピーしたい任意のファイルを配置します。後で我々が実装するコードはこれらのファイルを新しいプロジェクトにコピーします。どんなファイルを配置するかについてインスピレーションを得たい場合は、github.com/dkundel/create-projectを参考にしてください。

ファイルの再帰的コピーを行うために、ncpというライブラリを使用します。このライブラリはクロスプラットフォームの再帰的コピーをサポートしており、既存のファイルを強制的に上書きするフラグも用意されています。さらに、カラー出力用にchalkをインストールします。これらの依存関係をインストールするには、次のコマンドを実行します。

npm install 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

プロジェクトが作成され、ファイルがディレクトリにコピーされます。

nodejs - cli - copy templates

さて、ここから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フォルダの両方があることが分かります。

nodejs - cli - install dependencies

おめでとうございます。これで、最初のCLIの準備ができました。

nodejs - cli - you made it

自分のコードを実際のモジュールとして利用可能とし、そのロジックを他者が自分のコードに再利用できるようにしたい場合、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.jsonfilesキーを追加し、どのファイルを公開するか指定する必要があります。

}, 
"files": [ 
    "bin/", 
    "src/", 
    "templates/" 
    ] 
}

公開されるファイルを確認したい場合、npm pack --dry-runを実行し、出力を確認します。その後、npm publishを使用してCLIを公開します。このチュートリアルで作成したプロジェクトは@dkundel/create-projectで見つけることができます。またはnpm init @dkundel/projectを実行してください。

また、さまざまな機能を追加することもできます。実際のパッケージでは、LICENSECODE_OF_CONDUCT.mdおよび.gitignoreファイルを作成する依存関係を追加しました。ソースコードはGitHubで公開されています。また、前述のイブラリを使用し、追加機能をチェックすることもできます。掲載してほしいライブラリがある場合や、ご自身のCLIを私に見せたい場合は、お気軽にメッセージをいただければ幸いです。