🤖

Node.js から対話的な CLI プログラムを操作する

に公開

はじめに

CLIコマンドの中には git commit のようにエディタを起動したり、sshsudo のようにパスワードや認証情報を要求したりと、対話的な入力を求めるものがあります。このようなツールをスクリプトから自動化しようとした場合に、標準入出力 (stdin/stdout) だけではうまくいかないケースに遭遇しました。今回は、sudo のように対話的にパスワードを求めるようなコマンドを例に説明したいと思います。

この記事では、Node.js を使って対話的な CLI プログラム、具体例として sudo コマンドのパスワード入力を自動化する方法について解説します。

TL;DR

  • Node.jsの child_process.spawn ではパスワードプロンプトがうまく表示できない場合がある
  • これはTTY (端末) に接続されているかチェックし挙動を変えるため
  • node-pty ライブラリ経由でコマンドを実行するのが良さそう

spawnコマンド

最初に試したのは、Node.js の標準モジュールである child_processspawn 関数を使う方法です。

実行例

sudo には -S オプションがあり、これによりパスワードを標準入力 (stdin) から読み込むことができます。これを使えば spawn で実行出来るかも、と考えていました。

import { spawn } from 'child_process';

const password = 'your_sudo_password\n'; // パスワードの末尾には改行が必要

// sudo -S で stdin からパスワードを読むようにする
const sudoProcess = spawn('sudo', ['-S', 'whoami']);

// stdout や stderr でプロンプトを待つ?
sudoProcess.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

sudoProcess.stderr.on('data', (data) => {
  console.error(`stderr: ${data}`);
  // sudo のパスワードプロンプトは stderr に出ることが多いが...
  // ここでプロンプトを検知してパスワードを送る、という単純な方法ではうまくいかない
});

sudoProcess.on('spawn', () => {
  console.log('Process spawned, writing password to stdin...');
  // プロンプトを待たずにパスワードを書き込む (タイミングの問題あり)
  sudoProcess.stdin.write(password);
  sudoProcess.stdin.end();
});

sudoProcess.on('close', (code) => {
  console.log(`sudo process exited with code ${code}`); // code 1 で失敗することが多い
});

パスワードのプロンプトがstdout/stderrに出てこない

しかし、この spawn-S を使う方法ではパスワードプロンプトが表示されず、対話的な実行が出来ませんでした。理由は大きく2つです。

  1. プロンプトの非表示: sudo は、自分が TTY に接続されていない (つまり、スクリプトから実行されている) と判断すると、セキュリティのため、あるいはパイプ経由での実行を想定して、パスワードプロンプト ([sudo] password for <user>: ) を stdout や stderr にはっきりと表示しないことがあります。 -S はパスワードを 読む 場所を stdin にするだけで、プロンプトを 表示する ことを保証しません。
  2. タイミングの問題: 仮にプロンプトが表示されたとしても、spawn で stdout/stderr のデータを受け取ってから stdin に書き込むのでは、sudo がパスワード入力を待っているタイミングとずれてしまい、認証に失敗することがあります。非同期でのコマンドでは、上の例のように spawn イベントで即座に書き込んでも、入力を受け付ける前に書き込んでしまう可能性があります。

根本的な原因は、sudo が TTY の存在を前提とした対話的な動作を基本としている点にあります。spawn で起動されたプロセスは TTY に接続されていないため、期待した対話的な動作ができませんでした。

ttyについて

ttyとは

TTY は Teletypewriter の略で、Unix ライクなシステムでは、ユーザーがコンピュータと対話するための仮想的な端末インターフェースを指します。シェルや多くの CLI プログラム (特に sudo のようなパスワードを要求するもの) は、この TTY を介してユーザーからの入力を受け取り、結果を出力します。

プログラムは、多くの場合 C ライブラリ関数 isatty などを用いて、自身の標準入出力が TTY に接続されているかどうかを確認し、その結果に基づいて対話的な動作(プロンプト表示、パスワードのエコーバック無効化、文字カラーなど)を変更することがあります。

例えば、mysql コマンドも TTY 接続の有無で出力形式が変化します。

ターミナルで直接実行した場合:

$ mysql -u user -p database -e "SELECT id, name FROM users LIMIT 2;"
Enter password: ****
+----+----------+
| id | name     |
+----+----------+
|  1 | Alice    |
|  2 | Bob      |
+----+----------+

このように、人間が読みやすいように罫線で囲まれたテーブル形式で出力されます。

出力をパイプで cat などに渡した場合 (非 TTY):

$ mysql -u user -p database -e "SELECT id, name FROM users LIMIT 2;" | cat
Enter password: ****
id      name
1       Alice
2       Bob

この場合、出力はタブ区切りとなり、他のスクリプトやコマンドで処理しやすい形式になります。

ttyをエミュレートする

spawn で TTY がない問題を解決するには、プログラム (sudo) に対して TTY をエミュレートする必要があります。

実現する方法としては、シェルスクリプトで利用されるexpect コマンドや screenのようなターミナルマルチプレクサがありますが、Node.js アプリケーションから利用するには、さらに別のプロセスを起動したり、複雑なシェルスクリプトを介したりする必要があり、連携が少し面倒でした。

そこで、擬似ターミナル を作成できる、node-pty ライブラリを利用してみます。これにより、spawn したプロセス (sudo) は自分が本物の TTY に接続されていると認識し、対話的な動作(パスワードプロンプトの表示など)を期待通りに行うようになります。

node-ptyでの実行

node-pty を使用して sudo whoami を実行してみます。

import * as pty from 'node-pty';

// 動作説明向けであり、パスワードをハードコードするのは非推奨です。
const SUDO_PASSWORD = 'your_sudo_password\n';

// 1. pty.spawn を使用 (sudo には -S を付けない)
function startSudoProcess(command: string, args: string[]): pty.IPty {
  console.log(`Starting sudo process: sudo ${command} ${args.join(' ')}`);
  const shell = pty.spawn('sudo', [command, ...args], {
    name: 'xterm-256color',
    cols: 80,
    rows: 30,
    cwd: process.cwd(),
    env: { ...process.env }, // 環境変数を引き継ぐ
  });
  console.log('Sudo Process spawned with PID:', shell.pid);
  return shell;
}

// 2. sudo コマンドの実行
async function runSudoCommand(command: string, args: string[], password: string): Promise<string> {
  const shell = startSudoProcess(command, args); // pty.spawn でプロセス開始
  let commandOutput = ''; // コマンドの出力を保持
  let passwordSent = false;

  return new Promise((resolve, reject) => {
    let outputBuffer = ''; // プロンプト検出用のバッファ

    // 3. onData で出力を監視し、アクションを決定
    const onData = async (data: string) => {
      const currentOutput = data.toString();
      console.log('Received data:', currentOutput);
      outputBuffer += currentOutput;

      // パスワードプロンプトを検出したらパスワードを送信 (一度だけ)
      if (!passwordSent && detectPasswordPrompt(outputBuffer)) {
        console.log('Password prompt detected. Sending password...');
        await writeToStdin(shell, password);
        passwordSent = true;
        outputBuffer = ''; // プロンプト処理後はバッファをクリア
      } else if (passwordSent) {
        // パスワード送信後は、コマンドの実際の出力を記録
        commandOutput += currentOutput;
      }
    };

    // 4. onExit で終了処理
    const onExit = ({ exitCode }: { exitCode: number }) => {
      console.log('Sudo process exited with code:', exitCode);
      if (exitCode === 0) {
        resolve(commandOutput.trim()); // 正常終了ならコマンドの出力を返す
      } else {
        console.error('Sudo command failed. Last output:', outputBuffer + commandOutput);
        reject(new Error(`Sudo process exited with non-zero code: ${exitCode}`));
      }
      // shell.kill() は自動的に呼ばれることが多いが、念のため
    };

    shell.onData(onData);
    shell.onExit(onExit);
  });
}

// 5. パスワードプロンプト検出
// sudo のプロンプトは環境や設定により若干異なる場合がある
const detectPasswordPrompt = (data: string): boolean => {
  return data.includes('[sudo] password for') || data.endsWith(': ');
};

async function writeToStdin(shell: pty.IPty, data: string): Promise<void> {
  return new Promise((resolve) => {
    shell.write(data); // pty インスタンスの write メソッドを使用
    console.log('Successfully wrote to pty.');
    resolve();
  });
}

// メイン実行部分 (例)
async function main() {
  try {
    console.log('Running: sudo whoami');
    const result = await runSudoCommand('whoami', [], SUDO_PASSWORD);
    console.log('Sudo command finished successfully. Output:', result);
  } catch (err) {
    console.error('Error running sudo command:', err);
    process.exit(1);
  }
}

main();

このように node-pty を使うことで、sudo のように TTY の存在を前提とするコマンドに対してもプロンプトを受け取ることができ、標準の spawn では困難だった対話的な応答が可能になりました🙌

まとめ

本記事では、Node.js から対話的な CLI プログラムを自動化する際に直面する課題と、それを node-pty ライブラリを用いて解決する方法について解説しました。 CI上でのCLIコマンドの実行や、各種ワークフローの自動化など、参考になれば嬉しいです!

DRESS CODE TECH BLOG

Discussion