Node.js から対話的な CLI プログラムを操作する
はじめに
CLIコマンドの中には git commit
のようにエディタを起動したり、ssh
や sudo
のようにパスワードや認証情報を要求したりと、対話的な入力を求めるものがあります。このようなツールをスクリプトから自動化しようとした場合に、標準入出力 (stdin/stdout) だけではうまくいかないケースに遭遇しました。今回は、sudo
のように対話的にパスワードを求めるようなコマンドを例に説明したいと思います。
この記事では、Node.js を使って対話的な CLI プログラム、具体例として sudo
コマンドのパスワード入力を自動化する方法について解説します。
TL;DR
- Node.jsの
child_process.spawn
ではパスワードプロンプトがうまく表示できない場合がある - これはTTY (端末) に接続されているかチェックし挙動を変えるため
-
node-pty
ライブラリ経由でコマンドを実行するのが良さそう
spawnコマンド
最初に試したのは、Node.js の標準モジュールである child_process
の spawn
関数を使う方法です。
実行例
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つです。
-
プロンプトの非表示:
sudo
は、自分が TTY に接続されていない (つまり、スクリプトから実行されている) と判断すると、セキュリティのため、あるいはパイプ経由での実行を想定して、パスワードプロンプト ([sudo] password for <user>:
) を stdout や stderr にはっきりと表示しないことがあります。-S
はパスワードを 読む 場所を stdin にするだけで、プロンプトを 表示する ことを保証しません。 -
タイミングの問題: 仮にプロンプトが表示されたとしても、
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コマンドの実行や、各種ワークフローの自動化など、参考になれば嬉しいです!
Discussion