0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

cliツールの動作確認を自動化

0
Last updated at Posted at 2026-02-01

背景

ここ最近は自分用にフロントエンドのテンプレート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 initmain.pyなど諸々用意されているのが良い

というのが私の感想でした。

まとめ

今回、自作したcliツールの自動動作検証の話をしました。
テンプレートが増えていくと手で動作検証をするのがかなり大変になりますが、自動化するプログラムを作成しておくことによってコマンド一つで動作確認ができます。

また、pexpectに関しては自動動作検証だけではなく業務効率化するツールなどにも使えそうです。

cliツールの動作検証をする際、pexpectを使ってみてはいかがでしょうか。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?