👏

npmのCLIコードリーディングをできるようになるためにnpm packageを作ってみる

2022/05/09に公開

はじめに

先日、TypeORM(TypeScript製のORM)のCLIを使ってデータベースの migration を公式ドキュメントに従って進めていたところ途中でエラーが出て進めなくなりました。そこでTypeORMのCLIのコードの中身を読もうとしたところ、エントリーポイントが分からずどこを読めばいいのか分かりませんでした。

今後同じような状況に陥った際にCLIのコードリーディングがある程度できた方がいいと思い、npm のpackageを作って公開し、使ってみることにしました。CLIコードリーディングのとっかかりを掴むことができることを本記事では目的としています。

対象読者

  • 自作のnpm packageの作り方が分からない
  • CLIのコードリーディングの仕方が分からない
  • npxコマンドを実行するとライブラリのどのコードが実行されるのか分からない

最小構成のnpm packageを公開する

コマンドラインでhelloというコマンドを実行すると、hello world!!と表示されるコマンドラインツールを作成したいと思います。

npm でコマンドラインツールを作成する流れは以下の npm 公式blogを参考に作りました
Building a simple command line tool with npm

npm package 開発用のフォルダを作成

$ mkdir cli-example(任意だが公開するパッケージ名にしておくと`npm init`の際の設定が楽)

npm init で package.json を作成する

$ npm init --scope={userName}

optionでscopeをつけることによって、名前空間を持ったパッケージ名にすることができる。scope名はnpmに登録してあるユーザ名にする必要があります。

質問には適当に答えると、以下のような内容のpackage.jsonが生成される。

package.json
{
  "name": "@kntowd/cli-example",
  "version": "1.0.0",
  "description": "cli example",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "kntowd",
  "license": "ISC"
}

hello コマンドで実行されるスクリプトを作成する

src/hello.js
#! /usr/bin/env node
console.log("hello world!!")

hello コマンドで hello.js が実行されるように設定する

package.json
"bin": {
  "hello": "src/hello.js"
}

一行目は、スクリプトが実行されるインタープリタを指定しています。hello.jsがNode.js上で実行されます。

コマンドをコマンドラインで実行できるようにする

プロジェクトのルートパスで以下を実行

$ npm link

上記のコマンドを実行することによってシンボリックリンクが作成されhelloコマンドを使うことができます。

コマンドを実行してみましょう。

$ hello
hello world!!

これで無事にコマンドを実行することができました。

npm package を公開する

npm packageを公開するためにはnpmにユーザ登録が必要です。
ユーザ登録

ユーザ登録後、パッケージを npm 上で公開するために以下のコマンドを実行しましょう。--access=publicオプションをつけて実行しないと private なパッケージとして公開されてしまうので注意してください。また private なリポジトリを公開・利用するためにはnpmで月額課金または、GitHub の Enterprise プランを契約する必要があります。フリープランのユーザで private な package を publish しようとすると402エラーで弾かれます。

※以下のコマンドを実行するとこれまでに作成したパッケージがnpm上で公開されるので秘匿情報がパッケージ上に含まれていないことを確認してから実行してください。

npm publish --access=public

エラーが発生せず以下のようなログが出力されたら、publish完了です

(中略)
+ @kntowd/cli-example@1.0.0

npm上でパッケージを確認

npmのサイトで公開したパッケージ名で検索をしてpublishできていることを確認しましょう。
https://www.npmjs.com/

公開したパッケージは通常の npm package 同様、npm installをして使用することができるのでインストールをして package.json の scripts内にコマンドを記述するか、npx で実行して使ってみてください。

ここまでの流れを体験することによって、CLIのエントリーポイントが package.json の "bin" 内で定義されていることが分かりました。次章ではコマンドライン上で引数を受け取り処理をするコマンドを追加してみましょう。

コマンドラインで引数を受け取る

コマンドライン経由で引数を渡す

まずは簡単な Node.js のスクリプトを作成して、コマンドラインの引数を受け取りコマンドライン上に表示してみましょう。(参考

command-test.js
console.log(process.argv);

process.argvでコマンドラインの引数を受け取ることができます。argv は  argument values の略です。

$ node command-test.js hoge foo
[
  '/Users/oowadakenta/.anyenv/envs/nodenv/versions/12.22.1/bin/node',
  '/Users/oowadakenta/app/cli-example/src/command-test.js',
  'hoge',
  'foo'
]

コマンドラインの引数を受け取ることができました。配列の一つ目の値はNode.jsのパス、二つ目の要素は実行したスクリプトのパスを表しています。

コマンドライン引数を受け取り表示するコマンドを追加する

self-introduction.js
#! /usr/bin/env node

const argv = process.argv
console.log(`I am ${process.argv[2]}`)
package.json
  (中略)
  "bin": {
    "hello": "src/hello.js",
    "self-introduction": "src/self-introduction.js"
  },

コマンドを実行し、引数を受け取り表示できていることを確認する。

$ npm link
$ self-introduction kenta
I am kenta

package を更新

packageのバージョンを上げてからでないとpublishできないので、package.jsonのversionを1.0.0から1.0.1に更新する。

package.json
"version": "1.0.1",

バージョン更新後、npm publishをするとnpm上のパッケージを更新することができる。

optionを受け取れるようにする

よくcliでは以下のようなoptionを渡せるコマンドをよく目にすると思います。以下ではAWSのCLIを例にしています。

$ aws ec2 start-instances --instance-ids i-1348636c

独自で--のついた引数を受け取った時はオプションとして扱うようなスクリプトを書くこともできますが、少し面倒です。そこで(yargs)[https://github.com/yargs/yargs]というライブラリを使うとコマンドライン引数のパースが簡単にできるので使ってみましょう。yargs は weekly download でも数千万超えでさまざまなライブラリで使われているので、主要なAPIを把握しておくとコードリーディングをする際にも役立つと思います。また、yargsと似たライブラリとしてcommandersというライブラリがあり、こちらもある程度把握しておくとコードリーディングに役立ちそうです。

yargs を install します

$ npm install yargs
cli.js
#!/usr/bin/env node

const yargs = require('yargs/yargs')
const { hideBin } = require('yargs/helpers')
const argv = yargs(hideBin(process.argv)).argv

console.log(argv);

以下のように簡単にコマンドラインの引数をパースすることができます

node src/cli.js --name kenta --age 10
{ _: [], name: 'kenta', age: 10, '$0': 'src/cli.js' }

サブコマンドを作成

yargsを使うと引数をパースするだけではなくサブコマンドも作成できるので作ってみましょう。

まず、cli-example helloを実行すると、hello world!!を出力するコマンドを作成します。

#!/usr/bin/env node

const yargs = require('yargs/yargs')

yargs(process.argv.slice(2))
  .command({
    // サブコマンドの名前
    command: "hello",
    // コマンド実行時の処理
    handler: (argv) => {
      console.log('hello world!!');
    }
  }).argv
$ npm link
$ cli-example hello
hello world!!

次に、cli-example self-introduction kenta(任意の文字列)を実行すると、I am kentaを出力するコマンドを追加します。

cli.js
#!/usr/bin/env node

const yargs = require('yargs/yargs')

yargs(process.argv.slice(2))
  .command({
    command: "hello",
    handler: (argv) => {
      console.log('hello world!!');
    }
  })
  .command({
      // <>で囲んだ引数は必須、[]で囲んだ引数は任意の引数にできる
    command: "self-introduction <name>",
    handler: (argv) => {
      // パースされたコマンドライン引数を取得できる
      console.log(`I am ${argv.name}`)
    }
  })
  .argv

追加したサブコマンドに引数を渡して実行すると期待した結果が出力されました。

$ npm link
$ cli-example self-introduction kenta
I am kenta

必須の引数をコマンドに入れないで実行をするとyargsによって作成されたエラーメッセージが出力されます。

$ cli-example self-introduction
cli-example self-introduction <name>

オプション:
  --help     ヘルプを表示                                                 [真偽]
  --version  バージョンを表示                                             [真偽]

オプションではない引数が 0 個では不足しています。少なくとも 1 個の引数が必要です:

最後に、--ageというoptionで年齢を受け取れるようにしましょう。

cli.js
#!/usr/bin/env node

const yargs = require('yargs/yargs')

yargs(process.argv.slice(2))
  .command({
    command: "hello",
    handler: () => {
      console.log('hello world!!');
    }
  })
  .command({
    command: "self-introduction <name>",
    builder: (args) => {
      return args
        .option("age", {
            alias: "a",
            type: "number",
            describe: "age",
            demandOption: true,
        })
    },
    handler: (argv) => {
      console.log(`I am ${argv.name}!! I am ${argv.age} old.`)
    }
  })
  .argv

TypeORMのCLIコードリーディングをしてみる

これまでの内容を踏まえてTypeORMのCLIコードリーディングに挑戦してみます。npm installしたパッケージはコンパイルされていて非常に読みづらいので、TypeORMのリポジトリからソースコードをcloneします。cloneしたTypeORMのバージョンは0.3.5です。

今回はtypeorm migration:generateというコマンドで実行されるコードを読んでいこうと思います。cloneをしたらpackage.jsonの以下のコードから、typeormコマンドのエントリーポイントが "./cli.js" であることが分かります。

  "bin": {
    "typeorm": "./cli.js",
    "typeorm-ts-node-commonjs": "./cli-ts-node-commonjs.js",
    "typeorm-ts-node-esm": "./cli-ts-node-esm.js"
  },

エントリーポイントのファイルを見ると、yargsを使って複数のサブコマンドが登録されています。

cli.ts
(中略)
yargs
    .usage("Usage: $0 <command> [options]")
    .command(new SchemaSyncCommand())
    .command(new SchemaLogCommand())
    .command(new SchemaDropCommand())
    .command(new QueryCommand())
    .command(new EntityCreateCommand())
    .command(new SubscriberCreateCommand())
    .command(new MigrationCreateCommand())
    .command(new MigrationGenerateCommand())
    .command(new MigrationRunCommand())
    .command(new MigrationShowCommand())
    .command(new MigrationRevertCommand())
    .command(new VersionCommand())
    .command(new CacheClearCommand())
    .command(new InitCommand())
    .recommendCommands()
    .demandCommand(1)
    .strict()
    .alias("v", "version")
    .help("h")
    .alias("h", "help").argv

.command(new MigrationGenerateCommand())の部分でmigrationの生成に関するコマンドの設定がされていそうなので、MigrationGenerateCommand classが定義してあるクラスを見に行くと、command = "migration:generate <path>"というコードが見つかり、引数が一つ必須のコマンドであることが分かります。さらに、dataSourceというオプションが必須であることや他のオプションについても確認することができます。handlerの中身を見るとコマンド実行時の処理が確認できました。

ここまでのコードはこちらのリポジトリを参照ください。

感想

npm のパッケージを自分で作ってみることによって、CLI のコードリーディングの際にどのコードを辿ればいいのか分かるようになりました。ドキュメント通りにコマンドが動かなかったり、ライブラリの細かい挙動を確認したいときにはライブラリの中身のコードを読みに行くのも一つの手段だと思います。また、ライブラリのコードを読むこと自体もとても勉強になるので積極的に今後も読んでいきたいです。

Discussion