🌊

Node.jsで対話型コマンドラインツールを作る

2023/12/04に公開

これはCureApp Advent Calendar 2023 4日目の記事となります。

はじめに

ターミナルやシェルなどのコマンドラインツールで、各種コマンドを実行することができます。
その中でも、実行後にターミナルから入力を求められたり、選択を促されたりすることがあります。
削除やデプロイのコマンドを実行した際に、y/nの入力を求められるようなものが、最も有名な例だと思います。

# (例 deleteというコマンドは実際には存在しません
delete hoge
> 本当にhogeを削除しますか?(y/n)
y
> hogeを削除しました

フロントエンドだと、create-next-appcreate-vueなどが有名ですね。
実行すると言語やモジュールでこれを使いますか、というのをインタラクティブにやり取りをします。

npx create-next-app@latest

What is your project named?  my-app
Would you like to use TypeScript?  No / Yes
Would you like to use ESLint?  No / Yes
Would you like to use Tailwind CSS?  No / Yes
Would you like to use `src/` directory?  No / Yes
Would you like to use App Router? (recommended)  No / Yes
Would you like to customize the default import alias (@/*)?  No / Yes

また、Webでいうところのチェックボックスやラジオボタンのような表現をすることもできます。
yarnにはupgrade-interactiveというコマンドが提供されており、チェックをつけたモジュールだけをアップグレードすることができます。

upgrade-interactiveの実行結果

これをNode.jsで実装してみようっていう内容です。

readline

Node.jsにはreadlineというモジュールが提供されており、このモジュールを使うことでターミナルとインタラクティブなやり取りができるようになります。
questionというメソッドがあり、これに古き良きコールバック形式で処理を実装していきます。
answerの中に、ターミナルで入力された内容が入っています。

// question.ts
import { stdin as input, stdout as output } from "node:process";
import * as readline from "node:readline";

const rl = readline.createInterface({ input, output });

rl.question("処理を実行しますか? (y/n): ", (answer) => {
  if (answer.toLowerCase() === "y") {
    console.log("処理を実行します");
    rl.close();
  } else {
    console.log("処理をキャンセルしました");
    rl.close();
  }
});

実行すると、このようなログが出力されます。
実行すると、「処理を実行しますか? (y/n): 」の表示がされた所で、処理が中断されます。
入力後、また処理が再開されるような挙動になります。

npx ts-node question.js
> 処理を実行しますか? (y/n): y
> 処理を実行します

このモジュールはプロミスな関数ではないため、async/awaitを使うことはできません。

使えないというのは語弊がありますが、プロミスな関数の中で上記のようなコードをそのまま書くと、処理が中断されることなく一気に実行されることになります。
例えばasync/awaitを使ってデータベースやファイルシステムにアクセスするような実装をする際には、このreadlineをasync/awaitでも扱えるようにプロミスな関数にしておかないといけません。

Node.js v17からpromissなreadlineが提供されているので、必要であればこちらを使いましょう。

import { stdin as input, stdout as output } from "node:process";
import * as readline from "node:readline/promises";

const rl = readline.createInterface({ input, output });

async function main() {
  const result = await rl.question("処理を実行しますか? (y/n): ");
  if (result === "y") {
    console.log("処理を実行します。");
  } else {
    console.log("処理をキャンセルしました。");
  }
}

main();
Node.js v16より前でのやり方

え、17よりも前もNode.jsしか使えないだって・・・?
しょうがないなぁのび太くんは

import { stdin as input, stdout as output, exit } from "node:process";
import * as readline from "node:readline";

async function readlinePromises(
  ask: string = "処理を実行しますか? (y/n)"
): Promise<boolean> {
  const rl = readline.createInterface({ input, output });

  return new Promise<boolean>((resolve) => {
    rl.question(`${ask}`, (answer) => {
      if (answer.toLowerCase() === "y") {
        console.log("処理を実行します");
        resolve(true);
      } else {
        console.log("処理をキャンセルしました");
        resolve(false);
      }
    });
  }).finally(() => {
    rl.close();
  });
}

async function main() {
  const result = await readlinePromises();
  if (!result) exit(1);
  exit(0);
}

main();

readlineでチェックボックス

y/n のような簡単な対話であれば書けるようになったと思いますが、チェックボックスはどうでしょうか。
こちらも同様にreadlineを使って実装することが可能です。
無限ループを使って、同じ質問を繰り替えして答えてもらうことで、複数選択可能な対話をすることができます。

import { stdin as input, stdout as output, exit } from "node:process";
import * as readline from "node:readline/promises";

const question = "プロジェクトにどのモジュールを追加しますか?";
const options = ["TypeScript", "ESLint", "Tailwind CSS"];

async function askCheckbox(
  question: string,
  options: string[]
): Promise<string[]> {
  const rl = readline.createInterface({ input, output });

  const responses: string[] = [];

  try {
    while (true) {
      const optionString = options
        .map((option, index) => `${index + 1}. ${option}`)
        .join("\n");
      const answer = await rl.question(
        `${question}\n${optionString}\n(Enterキーで終了): `
      );

      // Enterが押されたら終了
      if (answer.trim() === "") break;

      const index = parseInt(answer, 10) - 1;
      if (index >= 0 && index < options.length) {
        // 有効な選択がされたら追加
        responses.push(options[index]);
      } else {
        console.log("無効な選択です。もう一度選択してください。");
      }
    }
  } finally {
    await rl.close();
  }

  return responses;
}

async function main() {
  const selectedOptions = await askCheckbox(question, options);

  console.log("選択された機能:", selectedOptions);
}

main();

実行すると、このような形でターミナルに表示されていきます。

npx ts-node question.js
> プロジェクトにどのモジュールを追加しますか?
> 1. TypeScript
> 2. ESLint
> 3. Tailwind CSS
> (Enterキーで終了): 1
> プロジェクトにどのモジュールを追加しますか?
> 1. TypeScript
> 2. ESLint
> 3. Tailwind CSS
> (Enterキーで終了): 3
> プロジェクトにどのモジュールを追加しますか?
> 1. TypeScript
> 2. ESLint
> 3. Tailwind CSS
> (Enterキーで終了):
> 選択された機能: [ 'TypeScript', 'Tailwind CSS' ]

とても原始的なやり方ですが、これでターミナル上でチェックボックスを表現することができました。

inquirer

ここで紹介した内容はinquirerというモジュールを使うことでも、簡単に実装することができます。
簡単だし見た目もリッチになります。
ちなみに、inquirerも内部ではreadlineモジュールを使って実装がされております。

先ほどのチェックボックスはinquirerを使うとこんな感じになります。
素のreadlienに比べると、とてもシンプルに書けます。

import inquirer from "inquirer";

async function main() {
  const answers = await inquirer.prompt([
    {
      type: "checkbox",
      name: "features",
      message: "プロジェクトにどのモジュールを追加しますか?",
      choices: [
        { name: "TypeScript", value: "typescript" },
        { name: "ESLint", value: "eslint" },
        { name: "Tailwind CSS", value: "tailwindcss" },
      ],
    },
  ]);

  console.log("選択された機能:", answers.features);
}

main();
GitHubで編集を提案
CureApp テックブログ

Discussion