CLI Tool¶
Node.js Testing
最小限ながら本番品質の形をしたコマンドラインツール: npm の bin 契約を満たす bin/ のシム、トップレベルの ディスパッチャ(Cli.res)、サブコマンドごとのモジュール(Commands.Greet、Commands.Init)、そして自前で完全に管理できる小さなフラグパースヘルパー(Args.res)から構成される。commander、yargs、clipanion は使っていない — 拡張が必要になっても、ヘルパーは十分短くて拡張しやすい。
npx で実行できるもの、社内ツール、コードジェネレータ、あるいは将来サブコマンドが増えていく CLI の 初期 scaffold を作る場合に本テンプレートを選択する。構造は git、docker、rescript バイナリ自身の 整理方針を踏襲している: 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 の |
Package manager |
npm / pnpm / yarn / bun。 |
Validation library |
|
主要な依存¶
パッケージ |
用途 |
バージョン |
|---|---|---|
|
ReScript コンパイラ |
|
|
標準ライブラリ |
|
|
コンパイル後の |
|
|
バリデーションのバックエンド(ウィザードで選択) |
|
|
スモークテストランナー |
|
|
|
|
特筆すべきは、サードパーティ製の 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 つの小さなヘルパー:
関数 |
用途 |
|---|---|
|
node バイナリとスクリプトパスを取り除いた argv を返す |
|
|
|
|
|
|
hasFlag と namedValue には @genType が付与されているため、JS 側のテストから直接呼び出せる (Args.test.mjs がまさにそれを行っている)。短いフラグの結合、--flag=value 構文、サブコマンド固有のヘルプなどで このヘルパーを越える要求が生じても、フレームワークに手を伸ばすのではなく Args.res を拡張すること。モジュール全体が 一気に読み切れる長さに収まっている。
src/Commands.res¶
サブコマンドはネストされたモジュールとして実装されており、ディスパッチャは Commands.<Name>.run(args) と書ける。テンプレートには 2 つ同梱されている:
Commands.Greet—greet <name> [--shout]。位置引数とブール値フラグ (hasFlag)の組み合わせを示す。Commands.Init—init --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 スクリプト¶
スクリプト |
説明 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
init は Validation.parseInitOptions 経由で --name / --dir オプションをバリデートする。オプションが欠けていたり不正な形をしていると、stderr に init: <message> のエラーが出力され、非ゼロで終了する (Console.error 由来の捕捉されないエラーに対して Node はデフォルトで終了コードを自動的に変更しない — 明確な失敗が必要な場合は process.exit(1) でラップすること)。
ローカルインストール¶
$PATH 上で CLI をテストする 2 つの方法:
npm link— パッケージをグローバルにリンクし、任意のディレクトリからその名前でバイナリを呼び出せるようにする:pnpm build npm link <projectName> greet Alice
npm unlink -g <projectName>で元に戻る。(pnpmユーザは代わりにpnpm link --globalを使ってもよい。)npxを tarball から実行 — グローバルインストールなしで実行する:pnpm build npm pack npx ./<projectName>-1.0.0.tgz greet Alice
公開の準備ができたら、npm publish でパッケージを配布すれば、利用者は直接 npx <projectName> を実行できる。
2日目以降のレシピ¶
デッドコードの検出 —
reanalyzeを実行して、削除したサブコマンドやヘルパーが 残らないようにするImport の最適化 — ディスパッチャが成長しても
Cli.resとCommands.resを整然と保つReScript のデバッグ —
node bin/cli.mjsにデバッガをアタッチして サブコマンドの本体をステップ実行する
プロジェクトを開いた後の ReScript 側のエディタワークフローについては、機能概要 を参照すること。
補足¶
nodeエンジンは>=24に固定されており、.nvmrcには24が記載されている。これより古いメジャーバージョンは CI で検証されない。package.jsonのbinキーは プロジェクト名 であり、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"を追加すること。