CLI Tool

Node.js Testing

最小限ながら本番品質の形をしたコマンドラインツール: npm の bin 契約を満たす bin/ のシム、トップレベルの ディスパッチャ(Cli.res)、サブコマンドごとのモジュール(Commands.GreetCommands.Init)、そして自前で完全に管理できる小さなフラグパースヘルパー(Args.res)から構成される。commanderyargsclipanion は使っていない — 拡張が必要になっても、ヘルパーは十分短くて拡張しやすい。

npx で実行できるもの、社内ツール、コードジェネレータ、あるいは将来サブコマンドが増えていく CLI の 初期 scaffold を作る場合に本テンプレートを選択する。構造は gitdockerrescript バイナリ自身の 整理方針を踏襲している: 1 つのエントリポイントが各コマンドモジュールへディスパッチする形である。

生成内容

my-project/
├── rescript.json
├── package.json                   # ESM, "bin" object pointing at bin/cli.mjs
├── bin/
│   └── cli.mjs                    # #!/usr/bin/env node — imports Cli.res.mjs
├── src/
│   ├── Cli.res                    # entry — dispatches argv to subcommands
│   ├── Args.res                   # positional + named flag helpers
│   ├── Commands.res               # Commands.Greet, Commands.Init nested modules
│   ├── Validation.res             # zod or sury — selected in the wizard
│   └── __tests__/
│       └── Args.test.mjs          # hasFlag + namedValue suites
├── README.md                      # script docs + Usage + Project Layout + Install Locally
├── LICENSE                        # MIT, holder = project name
├── .nvmrc                         # Node 24
├── .gitignore                     # node_modules + ReScript build artifacts
├── .editorconfig                  # 2-space indent, LF line endings
└── .github/
    ├── dependabot.yml             # weekly npm updates
    └── workflows/ci.yml           # install + rescript build + vitest

ウィザードオプション

オプション

効果

Project name

npm の namebin キー(npm link でバイナリがこの名前で公開される)、LICENSE 保有者、--help で表示されるプログラム名となる

Package manager

npm / pnpm / yarn / bun。packageManager フィールド、README のインストール/実行コマンド、CI のキャッシュキーに影響する

Validation library

zodsury。同梱される src/Validation.res のバリアントを選択する — バリデータは init の内部で --name / --dir をチェックするために動作する

主要な依存

パッケージ

用途

バージョン

rescript

ReScript コンパイラ

TemplateVersions.RESCRIPT

@rescript/core

標準ライブラリ

TemplateVersions.RESCRIPT_CORE

@rescript/runtime

コンパイル後の .res.mjs がランタイムでインポートするランタイムスタブ

TemplateVersions.RESCRIPT_RUNTIME

zod または sury

バリデーションのバックエンド(ウィザードで選択)

TemplateVersions.ZOD / TemplateVersions.SURY

vitest (dev)

スモークテストランナー

TemplateVersions.VITEST

@vitest/coverage-v8 (dev)

test:coverage 用のカバレッジプロバイダ

TemplateVersions.VITEST_COVERAGE_V8

特筆すべきは、サードパーティ製の CLI フレームワークを一切利用していない点である。Args.res は約 25 行、Cli.res がディスパッチャであり、DSL を覚えることなくどちらも拡張できる。

主要なファイル

bin/cli.mjs

npm の bin の定番シムである。バイナリが直接実行されたときに Unix が正しいインタプリタを解決できるよう、ファイルは #!/usr/bin/env node で始まらなければならない。それ以外はすべて ReScript 側に存在する:

#!/usr/bin/env node
import "../src/Cli.res.mjs";

package.json はプロジェクト名をこのファイルにマップする:

"bin": {
  "<projectName>": "./bin/cli.mjs"
}

bin フィールドが(単なる文字列ではなく)オブジェクト なのは意図的である — 後から 2 つ目の バイナリを追加する際に、構造を変えずにエントリを並べて追加できる。

src/Cli.res

エントリ。process.argv を読み、サブコマンド名を切り出してディスパッチする:

let rec run = () => {
  let allArgs = Args.positional()
  let (subcommand, remaining) = Args.splitSubcommand(allArgs)

  switch subcommand {
  | Some("greet") => Commands.Greet.run(remaining)
  | Some("init") => Commands.Init.run(remaining)
  | Some("--help") | Some("-h") | None => printUsage()
  | Some(cmd) =>
    Console.error(`Unknown subcommand: ${cmd}`)
    printUsage()
  }
}

新しいサブコマンドを追加するには: Commands.res の下に新しいモジュールを追加し、上記の switch に新しいパターン分岐を追加する。未知のコマンド分岐は printUsage に流れるので、入力ミスがあった場合に 何も表示せず exit 0 する代わりに、有用なエラーが出る。

src/Args.res

process.argv に対して書かれた 4 つの小さなヘルパー:

関数

用途

positional()

node バイナリとスクリプトパスを取り除いた argv を返す

splitSubcommand(args)

(Some(first), rest) または (None, []) を返す

hasFlag(args, flag)

flagargs のどこかに現れる場合 true

namedValue(args, flag)

flag の直後にある値、存在しない場合は None

hasFlagnamedValue には @genType が付与されているため、JS 側のテストから直接呼び出せる (Args.test.mjs がまさにそれを行っている)。短いフラグの結合、--flag=value 構文、サブコマンド固有のヘルプなどで このヘルパーを越える要求が生じても、フレームワークに手を伸ばすのではなく Args.res を拡張すること。モジュール全体が 一気に読み切れる長さに収まっている。

src/Commands.res

サブコマンドはネストされたモジュールとして実装されており、ディスパッチャは Commands.<Name>.run(args) と書ける。テンプレートには 2 つ同梱されている:

  • Commands.Greetgreet <name> [--shout]。位置引数とブール値フラグ (hasFlag)の組み合わせを示す。

  • Commands.Initinit --name <project-name> --dir <path>。実処理を行う前に オプションをバリデート するサブコマンドの例である:

module Init = {
  let run = (args: array<string>) => {
    let raw =
      Dict.fromArray([
        ("name", args->Args.namedValue("--name")->Option.map(JSON.Encode.string)->Option.getOr(JSON.Encode.null)),
        ("dir", args->Args.namedValue("--dir")->Option.map(JSON.Encode.string)->Option.getOr(JSON.Encode.null)),
      ])->JSON.Encode.object
    switch Validation.parseInitOptions(raw) {
    | Error(message) => Console.error(`init: ${message}`)
    | Ok({name, dir}) =>
      Console.log(`Initializing new "<projectName>"-style project "${name}" at ${dir}...`)
      Console.log("(In a real CLI, this would create files on disk.)")
    }
  }
}

このパターン — 生の引数を JSON.t にまとめ、Validation に渡し、result で分岐する — は 構造化オプションを取るあらゆるサブコマンドに一般化できる。さらに、バリデーションライブラリも差し替え可能なままにできる: ウィザードで zod ↔ sury を変えても、変わるのは Validation.res だけである。

src/__tests__/Args.test.mjs

スモークテストは コンパイル後Args.res.mjs をインポートし、2 つのフラグヘルパーを実行する:

import { describe, expect, it } from "vitest";
import { hasFlag, namedValue } from "../Args.res.mjs";

describe("hasFlag", () => {
  it("returns true when the flag is present", () => {
    expect(hasFlag(["--shout", "Alice"], "--shout")).toBe(true);
  });
  it("returns false when the flag is absent", () => {
    expect(hasFlag(["Alice"], "--shout")).toBe(false);
  });
});

describe("namedValue", () => {
  it("returns the value following the flag", () => {
    expect(namedValue(["--out", "dist"], "--out")).toEqual("dist");
  });
  it("returns undefined when the flag is missing", () => {
    expect(namedValue(["Alice"], "--out")).toBeUndefined();
  });
});

これは ReScript のバンプを跨いでも argv パースヘルパーが同じ挙動を保つことを保証する定番のガードである。Args.res を拡張する場合(例: --flag=value のパースを追加する場合)には、対応する describe ブロックを ここにも追加すること。スイートはコンパイル後の .res.mjs をインポートするため、ソースが今もコンパイルできることの チェックも兼ねている。

src/Validation.res

parseInitOptions: JSON.t => result<initOptions, string>Commands.Init が argv から収集した --name--dir オプションをバリデートする。シグネチャは zod 版と sury 版で同一なので、Commands.res はどちらが読み込まれているかを知る必要がない。

構造化オプションを取る新しいサブコマンドを追加する場合は、ここに新しい parseFooOptions を定義し、コマンドの本体から 呼び出すこと。result を返すことで契約が誠実に保たれる: ディスパッチャは検証済みのデータを受け取るか、<command>: <error> を stderr に表示するかのどちらかになる。

npm スクリプト

スクリプト

説明

build

rescript — ワンショットコンパイル

start

node bin/cli.mjsnpm link なしでローカルに CLI を一度実行する

test

vitest run — スモークスイートを実行

test:coverage

vitest run --coverage — 同上に v8 のカバレッジレポートを付与

res:build

rescript — 他テンプレートとの整合性のために残されたエイリアス

res:clean

rescript clean — 生成された .res.mjs を削除

res:dev

rescript -w — 保存時に再コンパイル

start 経由で引数を渡すには、パッケージマネージャの pass-through を使う: pnpm start -- greet Alice --shout

使用例

ビルド(pnpm build)を実行した後、node bin/cli.mjs あるいは pnpm start -- で CLI を呼び出す:

pnpm start -- greet Alice
pnpm start -- greet Alice --shout
pnpm start -- init --name my-project --dir ./projects/my-project
pnpm start -- --help

initValidation.parseInitOptions 経由で --name / --dir オプションをバリデートする。オプションが欠けていたり不正な形をしていると、stderr に init: <message> のエラーが出力され、非ゼロで終了する (Console.error 由来の捕捉されないエラーに対して Node はデフォルトで終了コードを自動的に変更しない — 明確な失敗が必要な場合は process.exit(1) でラップすること)。

ローカルインストール

$PATH 上で CLI をテストする 2 つの方法:

  1. npm link — パッケージをグローバルにリンクし、任意のディレクトリからその名前でバイナリを呼び出せるようにする:

    pnpm build
    npm link
    <projectName> greet Alice
    

    npm unlink -g <projectName> で元に戻る。(pnpm ユーザは代わりに pnpm link --global を使ってもよい。)

  2. npx を tarball から実行 — グローバルインストールなしで実行する:

    pnpm build
    npm pack
    npx ./<projectName>-1.0.0.tgz greet Alice
    

公開の準備ができたら、npm publish でパッケージを配布すれば、利用者は直接 npx <projectName> を実行できる。

2日目以降のレシピ

  • デッドコードの検出reanalyze を実行して、削除したサブコマンドやヘルパーが 残らないようにする

  • Import の最適化 — ディスパッチャが成長しても Cli.resCommands.res を整然と保つ

  • ReScript のデバッグnode bin/cli.mjs にデバッガをアタッチして サブコマンドの本体をステップ実行する

プロジェクトを開いた後の ReScript 側のエディタワークフローについては、機能概要 を参照すること。

補足

  • node エンジンは >=24 に固定されており、.nvmrc には 24 が記載されている。これより古いメジャーバージョンは CI で検証されない。

  • package.jsonbin キーは プロジェクト名 であり、npm link(または公開+グローバルインストール)後にバイナリはこの名前で公開される。ウィザードでプロジェクト名を決める際はこの点を踏まえて選ぶこと — ユーザの $PATH に最終的に乗る名前になる。

  • bin/cli.mjs は意図的に、コンパイル後の ReScript 出力を import する 1 行のみで構成されている。ロジックを追加するために手で編集してはならない — 代わりに Cli.res を拡張すること。シムの唯一の役割は shebang を提供し、エントリモジュールを解決することである。

  • ローカルテスト用に CLI をグローバルインストールするには: pnpm build && npm link。その後、任意の場所から プロジェクト名でバイナリを実行できる。npm unlink で元に戻る。

  • 2 つ目のバイナリを追加するには: package.json#bin に新しいエントリを追加し、bin/ 直下に 姉妹ラッパー(例: bin/<name>-init.mjs)を配置する。各ラッパーには #!/usr/bin/env node の shebang が依然必要である。

  • Args.test.mjs スイートは コンパイル後Args.res.mjs をインポートするため、.res ソースが今も コンパイルできることのガードを兼ねる。開発中は別ターミナルで pnpm res:dev を走らせ、保存時に再コンパイルされてから Vitest が再実行されるようにすること。

  • init の本体は意図的に argv を JSON にエンコードしてからバリデートしている。この非対称性は、すでに JSON を扱う HTTP / RPC エントリポイントから後で同じバリデータを再利用できるようにするためにある — 公開面ごとに オプションの契約を再実装する必要はない。

  • prepare スクリプトは存在しない。npm Library テンプレートと異なり、本 scaffold は利用者がインストール時に コンパイルするのではなく、ビルド済みの CLI を公開することを前提としている。同じ prepare の挙動が欲しい場合は、scripts ブロックに "prepare": "rescript" を追加すること。