📝

Denoで社内ツールを作る

2025/03/11に公開

はじめに

社内業務を効率化するためのツールとして、キャッチアップも兼ねてDenoでCLIを作ってみました。
Cliffyの使い方についてのメモや、Denoを触る上で迷ったところをまとめていきます。

Denoのインストール

まずはDenoをインストールします。
詳しいインストール方法は公式サイトを参照ください。

MacOSの場合は以下のコマンドでインストールできます。

brew install deno

もしくは

curl -fsSL https://deno.land/install.sh | sh

プロジェクトの作成

Denoではdeno initコマンドでプロジェクトを作成できます。

deno init
# 実行結果
✅ Project initialized

Run these commands to get started

  # Run the program
  deno run main.ts

  # Run the program and watch for file changes
  deno task dev

  # Run the tests
  deno test

コマンドを実行するとdeno.jsonというファイルが作成されます。

deno.json
{
  "tasks": {
    "dev": "deno run --watch main.ts"
  },
  "imports": {
    "@std/assert": "jsr:@std/assert@1"
  }
}

このファイルは Node.jsの package.json に相当するファイルで、
Denoのタスクの設定や依存パッケージの指定を行うことができます。

試しにdeno task devコマンドを実行してみます。

deno task dev

上記を実行すると、deno run --watch main.ts が実行され、下記のような結果が表示されます。

deno task dev
Task dev deno run --watch main.ts
Watcher Process started.
Add 2 + 3 = 5
Watcher Process finished. Restarting on file change...

Cliffyの導入

続いて、Cliffyを導入します。
CliffyはDenoでCLIツールを作成するためのフレームワークで、コマンド定義やオプション解析、ヘルプ生成などを簡単に行うことができます。

Denoではdeno addコマンドで依存パッケージを追加できます。

deno add jsr:@cliffy/command@^1.0.0-rc.7

インストールしたいパッケージがnpmにしかない場合は、

deno add npm:<package-name>

とすることでインストールできます。
例えば、zodをインストールしたい場合は、

deno add npm:zod

とすることでインストールできます。

コマンドの作成

インストールが完了したら、main.tsファイルを下記のように編集します。

main.ts
import { Command } from "@cliffy/command";

const cli = new Command()
  .name("my-cli")
  .description("My CLI Tool!")
  .option("-m, --message <message:string>", "Message to use", { required: true })
  .option("-n, --number <number:number>", "Number to use", { default: 1 })
  .action((options) => {
    const message = options.message;
    const number = options.number;
    console.log(`Hello, world! ${message} ${number}`);
  });

cli.parse(Deno.args);

CliffyではCommandクラスに対して、メソッドチェーンでコマンドを定義できます。
例えば、.descriptionでコマンドの説明、.optionでオプションを定義、.actionでコマンドの処理を記述できます。
また、.optionで定義したオプションは、.actionの引数で受け取ることができます。

この状態でdeno run ./main.ts -hコマンドを実行すると、下記のような結果が表示されます。

deno run ./main.ts -h

Usage: my-cli --message <message>

Description:

  My CLI Tool!

Options:

  -h, --help                - Show this help.              
  -m, --message  <message>  - Message to use   (required)  
  -n, --number   <number>   - Number to use    (Default: 1)

デフォルトで追加された-hオプションに加えて、
-m-nオプションが追加されていることが確認できます。

実行してみると、下記のような結果が表示されます。

deno run ./main.ts -m world -n 10
Hello, world! world 10

また、.optionの引数に<message:string>のように記述することで、オプションの型を推論してくれます。(すごい!)

試しに-nオプションに数値以外の値を設定してみます。

deno run ./main.ts -m world -n hoge

Usage: my-cli --message <message>

Description:

  My CLI Tool!

Options:

  -h, --help                - Show this help.              
  -m, --message  <message>  - Message to use   (required)  
  -n, --number   <number>   - Number to use    (Default: 1)

  error: Option "--number" must be of type "number", but got "hoge".

正しくバリデーションが効いていることが確認できます。

サブコマンドの追加

続いて、サブコマンドを追加してみます。
サブコマンドの追加は、.commandメソッドを使うことで行えます。

main.ts
import { Command } from "@cliffy/command";

const cli = new Command()
  .name("my-cli")
  .description("My CLI Tool!")
  .option("-m, --message <message:string>", "Message to use", { required: true })
  .option("-n, --number <number:number>", "Number to use", { default: 1 })
  .action((options) => {
    const message = options.message;
    const number = options.number;
    console.log(`Hello, world! ${message} ${number}`);
  })
  .command("sub", "Sub command")
  .action(() => {
    console.log("sub command");
  });

cli.parse(Deno.args);

上記のようにメソッドチェーンで書くこともできますが、
読みづらい場合は、Commandインスタンスを受け取る形で、下記のようにコマンドを分けて記述することもできます。

main.ts
import { Command } from "@cliffy/command";

const subCommand = new Command()
  .name("sub")
  .description("Sub command")
  .action(() => {
    console.log("sub command");
  });

const cli = new Command()
  .name("my-cli")
  .description("My CLI Tool!")
  .option("-m, --message <message:string>", "Message to use", { required: true })
  .option("-n, --number <number:number>", "Number to use", { default: 1 })
  .action((options) => {
    const message = options.message;
    const number = options.number;
    console.log(`Hello, world! ${message} ${number}`);
  })
  .command("sub", subCommand);

cli.parse(Deno.args);

実行してみると、下記のような結果が表示されます。

deno run ./main.ts sub -h

Usage: my-cli sub

Description:

  Sub command

Options:

  -h, --help  - Show this help.  

CLIとして配布

作成したアプリケーションをCLIとして配布します。
配布先のユーザーがDenoのランタイムをインストールしている場合には、deno installコマンドを実行することで、
--nameオプションで指定した名前でコマンドをインストールできます。
今回は下記のオプションで配布しました。

deno install --global --force --name my-cli --import-map deno.json ./main.ts

オプションを簡単に説明すると

  • --global: グローバルにインストール
  • --force: すでに同名のコマンドが存在する場合に上書き
  • --name: コマンド名を指定
  • --import-map: 外部依存パッケージがある場合に同梱する必要があるため、deno.json(もしくはimport_map.json)を指定

となっています。

インストール後、~/.zshrcにパスを追記し、
ターミナルを再起動することで、
自作のCLIコマンドがどこでも使用できるようになります。

export PATH="$HOME/.deno/bin:$PATH"
source ~/.zshrc
# --name オプションで指定したコマンド名
my-cli -h

Usage: my-cli --message <message>

Description:

  My CLI Tool!

Options:

  -h, --help                - Show this help.              
  -m, --message  <message>  - Message to use   (required)  
  -n, --number   <number>   - Number to use    (Default: 1)

Commands:

  sub  - Sub command

Denoのランタイムがない場合は、deno compileコマンドでバイナリをビルドして配布します。

deno compile --target=aarch64-apple-darwin <必要に応じて`--allow-read`などのオプションを追加> main.ts

ビルドしたバイナリを/usr/local/binなどパスが通っている場所に配置することで、
自作のCLIコマンドがどこでも使用できるようになります。

cp <your-binary-path> /usr/local/bin/<任意のコマンド名>
# /usr/local/bin/my-cli として配置した場合
my-cli -h

Usage: my-cli --message <message>

Description:

  My CLI Tool!

Options:

  -h, --help                - Show this help.              
  -m, --message  <message>  - Message to use   (required)  
  -n, --number   <number>   - Number to use    (Default: 1)

Commands:

  sub  - Sub command

Denoを触る上で迷ったところ

以上でCLIのアプリケーションを作成・配布することができました。
ここからは、Denoを触る上で迷ったところをまとめていきます。

パッケージの管理をどうするか

Deno v1.x系では、公式のパッケージマネージャーが存在しなかったため、
deps.tsimport_map.jsonというファイルを作成して
パッケージのインポートの記述をまとめて管理していました。

Deno v2からは、deno.jsonimports

deno.json
{
  "imports": {
    "@std/assert": "jsr:@std/assert@1"
  }
}

のように記述することで、パッケージの管理を行うことができるようになりました。

一方で、deno install時に依存関係のとして--import-map ./deno.jsonのように指定すると、
下記のようにdeno.jsonimportsセクション以外に書かれているtaskscompilerOptionsに対してワーニングが出力されます。

Import map diagnostics:
  - Invalid top-level key "tasks". Only "imports" and "scopes" can be present.
  - Invalid top-level key "compilerOptions". Only "imports" and "scopes" can be present.

上記を避けるために、deno.jsonimportsの代わりに

deno.json
{
  "importMap": "./import_map.json"
}

のようにimportMapを使用して、
deno install --import-map ./import_map.jsonのようにJSONファイルの指定を分離できないか試してみました。

結果としては、deno install自体はうまくいくものの、
importMapを使用するとdeno addコマンドが使えなくなる問題がありました。

deno addが使えなくなるのは開発体験を損なうため、
deno.jsonimportsを使用してパッケージの管理を行い、deno install時のワーニングは無視することにしました。
(deno.jsonimportsからimport_map.jsonを作成するスクリプトを組めば解決するかもですが、そこまでする必要もないかと思いやりませんでした。)

deno.landとjsrのどちらを使うべきか

パッケージの管理についてはdeno.jsonにまとめていく方針にしましたが、
さらにその中でdeno.landjsrのどちらを使用するべきか迷いました。
具体的には、httpsから始まるURLを使用するか、jsr:<package-name>のようにjsrを使用するかです。

deno.json
{
  "imports": {
    "@cliffy/command": "https://deno.land/x/cliffy@v1.0.0-rc.7/command/mod.ts", // deno.land
    "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.7" // jsr
  }
}

これについてはdeno.landが今後大規模な開発が行われなくなるとのことで、基本的にはjsrを使用するようにしました。
下記の記事がわかりやすかったため、詳細について気になる方はご参照ください。

https://zenn.dev/kt3k/articles/4aa235ff817a6c
https://zenn.dev/uki00a/articles/whats-new-for-deno-in-2024#deno_std-(%40std-)

まとめ

Denoをちゃんと触るのは初めてでしたが、

  • fmtlintなどがDenoに付随しており、様々なツールをインストールする必要がないこと
  • 使いやすいAPIが揃っていること
  • tsconfig.jsonを作成せずに、すぐTypeScriptを実行できること

といった点で非常に楽に開発できました。

今回使用したCliffyの他にも
daxというツールも使いやすそうだったので
機会があれば試してみたいと思います。

Aidemy Tech Blog

Discussion