背景
ここ最近は自分用にフロントエンドのテンプレートcliを作っています。
なぜ、作ろうと思ったのかというと単純に自分が欲しいのとcreate-next-appやcreate-viteみたいなものを作ってみたいと思ったからです。
テストやstorybookの設定など毎度同じ設定コードを書いていくのは大変に感じていました。
AIに書かせるというのも一つの手ではありますしそのような意見が大半なのかなと思いますがcliを作りたいという願望でそうしました。
参考としてNext.jsの create-next-appのソースコードを参考にして作っています。
https://github.com/vercel/next.js/tree/canary/packages/create-next-app
このcliでは現在React系で6つ、vue系で4つのテンプレートを用意しています。
各吐き出されたテンプレートが正常にビルドができてテストが通るかを検証しなくてはいけません。
一つひとつコマンドを叩いてテスト回してビルドしてというのは疲れてしまいます。
そこで自動化しようと思いました。
cli操作の自動化に使用したもの
今回、言語はpythonを使いました。
pythonにpexpectというパッケージがあります。
今回はこちらを使いました。
当初はJS/TSで挑戦をしていました。
node-ptyというライブラリを発見しました。
https://github.com/microsoft/node-pty
ですが、私の手元では実行ができませんでした。
参考までにこのとき使っていたバージョンはNodejs24系統です。
上記のこともあって使うのこと諦めました。
pexpectによる実装の仕方
cmd_noninteractive = [
node,
"--import",
"tsx",
cli_entry,
]
child = pexpect.spawn(cmd_noninteractive[0], cmd_noninteractive[1:], cwd=os.getcwd(), env=env, encoding="utf-8")
pexpect.spawnで実行するコマンドを設定します。
child.expect(r"What is your project named")
child.expectにて正規表現等でcliにマッチする文字列が現れるのを待機します。
上の場合であれば"What is your project named"という文字列が現れるまで待機します。
現れたら次の処理に行きます。
child.sendline("nextjs-sample\n")
child.sendlineでコマンドに値を送ります。
\nはEnterキーになります。
自作したcliツールの内容
ここで自作したcliについて説明します。
流れとしては以下の通りです。
? What is your project named?
? Select a tech stack for your project: › - Use arrow-keys. Return to submit.
❯ React
Vue
? Select a framework for your project: › - Use arrow-keys. Return to submit.
❯ TanStack Router
Next.js (App Router)
Next.js (Pages Router)
? Select a CSS framework for your project: › - Use arrow-keys. Return to submit.
❯ tailwindCSS
vanilla-extract
? Select packages to add › (Use space to select, and enter to submit)
◯ Popup
◯ Loading
この出力はpromptsを使用して実装しています。
promptsでのcli操作
操作は以下の通りです。
| 種類 | 操作 |
|---|---|
| テキスト | 文字列を入力・Enterで決定 |
| セレクト | ArrowUpかArrowDownで選択・Enterで決定 |
| マルチセレクト | Shiftで選択・ArrowUpかArrowDownでフォーカス・Enterで決定 |
| トグル | ArrowRightかArrowLeftで選択・Enterで決定 |
自作cliツールの動作検証ソースコード
実際のソースコードを記載します。
import os
import sys
import time
import re
import pexpect
import shutil
import subprocess
KEYBOARD = {
"ArrowDown": "\x1b[B",
"ArrowDownAlt": "\x1bOB",
"ArrowUp": "\x1b[A",
"ArrowUpAlt": "\x1bOA",
"Enter": "\r",
"EnterLF": "\n",
"Space": "\x20",
}
def green(s: str) -> str:
return f"\033[32m{s}\033[0m"
def node_path() -> str:
return os.environ.get("NODE", "node")
def resolve_repo_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
def run() -> None:
repo_root = resolve_repo_root()
# path to CLI entry relative to this file
cli_entry = os.path.join(repo_root, "packages", "create-template", "src", "index.ts")
ACTIONS = [
{
"projectName": "next-app-tailwind",
"techStack": KEYBOARD["Enter"],
"framework": KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"css": KEYBOARD["Enter"],
"library": KEYBOARD["Space"] + KEYBOARD["ArrowDown"] + KEYBOARD["Space"] + KEYBOARD["Enter"],
},{
"projectName": "next-app-vanilla-extract",
"techStack": KEYBOARD["Enter"],
"framework": KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"css": KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"library": KEYBOARD["Space"] + KEYBOARD["ArrowDown"]+ KEYBOARD["Space"] + KEYBOARD["Enter"],
},
{
"projectName": "next-pages-tailwind",
"techStack": KEYBOARD["Enter"],
"framework": KEYBOARD["ArrowDown"] + KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"css": KEYBOARD["Enter"],
"library": KEYBOARD["Space"] + KEYBOARD["ArrowDown"] + KEYBOARD["Space"] + KEYBOARD["Enter"],
},{
"projectName": "next-pages-vanilla-extract",
"techStack": KEYBOARD["Enter"],
"framework": KEYBOARD["ArrowDown"] + KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"css": KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"library": KEYBOARD["Space"] + KEYBOARD["ArrowDown"] + KEYBOARD["Space"] + KEYBOARD["Enter"],
},{
"projectName": "tanstack-router-tailwind",
"techStack": KEYBOARD["Enter"],
"framework": KEYBOARD["Enter"],
"css": KEYBOARD["Enter"],
"library": KEYBOARD["Space"] + KEYBOARD["ArrowDown"] + KEYBOARD["Space"] + KEYBOARD["Enter"],
},{
"projectName": "tanstack-router-vanilla-extract",
"techStack": KEYBOARD["Enter"],
"framework": KEYBOARD["Enter"],
"css": KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"library": KEYBOARD["Space"] + KEYBOARD["ArrowDown"]+ KEYBOARD["Space"] + KEYBOARD["Enter"],
}
]
node = node_path()
for action in ACTIONS:
env = os.environ.copy()
env["CLI_TEST"] = "1"
env["TERM"] = env.get("TERM", "xterm-256color")
shutil.rmtree(os.path.join(os.getcwd(), action["projectName"]), ignore_errors=True)
# Try non-interactive invocation first (preferred).
cmd_noninteractive = [
node,
"--import",
"tsx",
cli_entry,
]
print(f"[PTY SAMPLE] Trying non-interactive: {' '.join(cmd_noninteractive)}")
child = pexpect.spawn(cmd_noninteractive[0], cmd_noninteractive[1:], cwd=os.getcwd(), env=env, encoding="utf-8")
child.logfile = sys.stdout
try:
child.expect(r"What is your project named")
child.sendline(action["projectName"]+KEYBOARD["Enter"])
child.expect(r"Select a tech stack for your project")
child.sendline(action["techStack"])
child.expect(r"Select\s+a\s+.?framework.?for your project")
child.sendline(action["framework"])
child.expect(r"elect a CSS framework for your project")
child.sendline(action["css"])
child.expect(r"Select packages to add")
child.sendline(action["library"])
child.expect(pexpect.EOF, timeout=120)
# success if exit code 0
if child.exitstatus == 0 or child.signalstatus is None:
print("[PTY SAMPLE] Non-interactive run finished successfully")
try:
child.close()
except Exception:
pass
project_dir = os.path.join(os.getcwd(), action["projectName"])
if os.path.isdir(project_dir):
print(f"[PTY SAMPLE] Running pnpm in {project_dir}")
try:
subprocess.run(["pnpm", "i"], cwd=project_dir, check=True)
except Exception as e:
print(f"[PTY SAMPLE] pnpm install failed: {e}")
try:
subprocess.run(["pnpm", "test"], cwd=project_dir, check=True)
except Exception as e:
print(f"[PTY SAMPLE] pnpm test failed: {e}")
try:
subprocess.run(["pnpm", "build"], cwd=project_dir, check=True)
except Exception as e:
print(f"[PTY SAMPLE] pnpm build failed: {e}")
continue
except Exception:
# Fall back to interactive if non-interactive path didn't finish cleanly
try:
child.close()
except Exception:
pass
# Fall back to interactive mode: hand control to the user instead of
# sending arrow/shift sequences programmatically.
cmd = [node, "--import", "tsx", cli_entry]
print(f"[PTY SAMPLE] Falling back to interactive: {' '.join(cmd)}")
child = pexpect.spawn(cmd[0], cmd[1:], cwd=os.getcwd(), env=env, encoding="utf-8")
child.logfile = sys.stdout
# Give the user direct control of the spawned process so they can use
# their local keyboard (arrow, shift, space) reliably.
print("[PTY SAMPLE] Entering interactive mode — use arrow/space/enter to answer prompts. Press Ctrl-D to finish.")
try:
child.interact()
except Exception:
pass
try:
child.expect(pexpect.EOF)
except Exception:
pass
try:
child.close()
except Exception:
pass
# After creating the project, install deps, run tests and build.
if __name__ == "__main__":
run()
上記ソースのほとんどはAIに書かせました
KEYBOARD = {
"ArrowDown": "\x1b[B",
"ArrowDownAlt": "\x1bOB",
"ArrowUp": "\x1b[A",
"ArrowUpAlt": "\x1bOA",
"Enter": "\r",
"EnterLF": "\n",
"Space": "\x20",
}
上記でそれぞれのキーのシーケンスをまとめています。
それぞれの操作をACTIONSにまとめています。
ACTIONS = [
{
"projectName": "next-app-tailwind",
"techStack": KEYBOARD["Enter"],
"framework": KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"css": KEYBOARD["Enter"],
"library": KEYBOARD["Space"] + KEYBOARD["ArrowDown"] + KEYBOARD["Space"] + KEYBOARD["Enter"],
},{
"projectName": "next-app-vanilla-extract",
"techStack": KEYBOARD["Enter"],
"framework": KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"css": KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"library": KEYBOARD["Space"] + KEYBOARD["ArrowDown"]+ KEYBOARD["Space"] + KEYBOARD["Enter"],
},
{
"projectName": "next-pages-tailwind",
"techStack": KEYBOARD["Enter"],
"framework": KEYBOARD["ArrowDown"] + KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"css": KEYBOARD["Enter"],
"library": KEYBOARD["Space"] + KEYBOARD["ArrowDown"] + KEYBOARD["Space"] + KEYBOARD["Enter"],
},{
"projectName": "next-pages-vanilla-extract",
"techStack": KEYBOARD["Enter"],
"framework": KEYBOARD["ArrowDown"] + KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"css": KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"library": KEYBOARD["Space"] + KEYBOARD["ArrowDown"] + KEYBOARD["Space"] + KEYBOARD["Enter"],
},{
"projectName": "tanstack-router-tailwind",
"techStack": KEYBOARD["Enter"],
"framework": KEYBOARD["Enter"],
"css": KEYBOARD["Enter"],
"library": KEYBOARD["Space"] + KEYBOARD["ArrowDown"] + KEYBOARD["Space"] + KEYBOARD["Enter"],
},{
"projectName": "tanstack-router-vanilla-extract",
"techStack": KEYBOARD["Enter"],
"framework": KEYBOARD["Enter"],
"css": KEYBOARD["ArrowDown"] + KEYBOARD["Enter"],
"library": KEYBOARD["Space"] + KEYBOARD["ArrowDown"]+ KEYBOARD["Space"] + KEYBOARD["Enter"],
}
]
このリスト分をループして回し下記cli自動操作しています。
child.expect(r"What is your project named")
child.sendline(action["projectName"]+KEYBOARD["Enter"])
child.expect(r"Select a tech stack for your project")
child.sendline(action["techStack"])
child.expect(r"Select\s+a\s+.?framework.?for your project")
child.sendline(action["framework"])
child.expect(r"elect a CSS framework for your project")
child.sendline(action["css"])
child.expect(r"Select packages to add")
child.sendline(action["library"])
child.expect(pexpect.EOF, timeout=120)
cli操作が終了したら、それぞれパッケージインストール、テスト、ビルドを行い正常に実行できるかの確認をします。
# success if exit code 0
if child.exitstatus == 0 or child.signalstatus is None:
print("[PTY SAMPLE] Non-interactive run finished successfully")
try:
child.close()
except Exception:
pass
project_dir = os.path.join(os.getcwd(), action["projectName"])
if os.path.isdir(project_dir):
print(f"[PTY SAMPLE] Running pnpm in {project_dir}")
try:
subprocess.run(["pnpm", "i"], cwd=project_dir, check=True)
except Exception as e:
print(f"[PTY SAMPLE] pnpm install failed: {e}")
try:
subprocess.run(["pnpm", "test"], cwd=project_dir, check=True)
except Exception as e:
print(f"[PTY SAMPLE] pnpm test failed: {e}")
try:
subprocess.run(["pnpm", "build"], cwd=project_dir, check=True)
except Exception as e:
print(f"[PTY SAMPLE] pnpm build failed: {e}")
このような工夫を行うことで実際にcli操作を自動化できかつ、吐き出されたテンプレートのテスト・ビルドが成功するかどうかの検証を行うことができます。
余談(uvについて)
今回、初めてPythonのパッケージ管理ツールであるuvを使ってみました。
- とにかく早い
-
uv run main.pyで勝手に仮想環境を作ってその中で実行されるのが良い -
uv pip install -r requirements.txtとやった時に仮想環境がないと怒られるのが良い- ローカルを汚さなくて済む
-
uv initでmain.pyなど諸々用意されているのが良い
というのが私の感想でした。
まとめ
今回、自作したcliツールの自動動作検証の話をしました。
テンプレートが増えていくと手で動作検証をするのがかなり大変になりますが、自動化するプログラムを作成しておくことによってコマンド一つで動作確認ができます。
また、pexpectに関しては自動動作検証だけではなく業務効率化するツールなどにも使えそうです。
cliツールの動作検証をする際、pexpectを使ってみてはいかがでしょうか。