[[Deno]]で[[CLI]]ツールを作ってみるメモ。 ## [[Deno]]最新化 ```console $ scoop update deno $ deno --version deno 1.36.4 (release, x86_64-pc-windows-msvc) v8 11.6.189.12 typescript 5.1.6 ``` ## プロジェクト作成 ```console $ deno init deno-cli-sample ✅ Project initialized Run these commands to get started cd deno-cli-sample # Run the program deno run main.ts # Run the program and watch for file changes deno task dev # Run the tests deno test # Run the benchmarks deno bench ``` 一通りコマンドが動くことを確認。[[deno.jsonc]]も作成されている。 ## [[Neovim]]環境構築 [[coc.nvim]]で[[Deno]]が使えるように[[coc-deno]]をインストールして設定する。 [[lazy.nvim]]を使っている場合、以下の階層に`coc-deno`を追加。 ```lua require("lazy").setup({ { 'neoclide/coc.nvim', config = function() vim.g.coc_global_extensions = { "coc-deno", ``` ファイルを開くとインストールが始まる。インストールが完了したらワークスペースを初期化する[[Exコマンド]]を実行。 ```console :CocCommand deno.initializeWorkspace ``` ![[Pasted image 20230903224447.png]] `main.ts`の以下で表示されていたエラーが消えればOK. ```ts if (import.meta.main) { // ここでエラーが出なければOK console.log("Add 2 + 3 =", add(2, 3)); } ``` ## Cliffyのインストール フレームワークとして[[Cliffy (Deno)|Cliffy]]を使う。バージョンは1.0.0-rc3. > [https://cliffy.io/[email protected]/command](https://cliffy.io/[email protected]/command) `main.ts`を変更。 ```ts import { Command } from "https://deno.land/x/[email protected]/command/mod.ts"; // Learn more at https://deno.land/manual/examples/module_metadata#concepts if (import.meta.main) { await new Command() .name("cliffy") .version("0.1.0") .description("Command line framework for Deno") .parse(Deno.args); } ``` 実行するとヘルプが表示される。 ```console $ deno run main.ts -h Usage: cliffy Version: 0.1.0 Description: Command line framework for Deno Options: -h, --help - Show this help. -V, --version - Show the version number for this program. ``` > [!positive] ヘルプを自動生成してくれるのは助かる ## コンパイル > [deno compile \| Manual \| Deno](https://deno.land/[email protected]/tools/compiler#cross-compilation) クロスコンパイルを試してみる。 ```console $ deno compile --target x86_64-pc-windows-msvc main.ts deno compile --target x86_64-pc-windows-msvc $ ll deno-cli-sample.exe -a--- 78M 3 Sep 14:55  deno-cli-sample.exe ``` 78Mというのはかなりのサイズだけど[[Electron]]もこんなだから[[JavaScript]]ランタイムの限界なのだろうか...。ちなみに`x86_64-unknown-linux-gnu`は115Mだった。 > [!negative] サイズが大きすぎるので[[Go]]や[[Rust]]に比べると実用面でキツイかも... あまりアップデートしないものならアリか... ## 開発ビルド 以下のコマンドを実行する。 ```console deno task dev ``` [[deno.jsonc]]の`tasks.dev`には以下の設定がしてあったので ```json { "tasks": { "dev": "deno run --watch main.ts" } } ``` `main.ts`が変更されるたびに再実行されるようになる。 ## コマンドを使う ```console deno run main.ts request "http://hogehoge" ``` のように実行したい場合。 ```ts import { Command } from "https://deno.land/x/[email protected]/command/mod.ts"; // Learn more at https://deno.land/manual/examples/module_metadata#concepts if (import.meta.main) { await new Command() .name("cliffy") .version("0.1.0") .description("Command line framework for Deno") .command("request", "リクエストする") .arguments("<url:string>") .action(request) .parse(Deno.args); } function request(_: unknown, url: string) { console.log(url); } ``` > [!positive] 型推論が半端なかった。。 ## [[HTTP GET]] [[HTTP GET]]でデータを取得できるようにする。 ``` https://reqres.in/api/users?page=2 ``` 先ほどの`request`関数の実装を変更する。 ```ts async function request(_: unknown, url: string) { const r = await (await fetch(url)).json(); console.log(r); } ``` これでリクエストを取得できる。 ## Permissionの付与 [[HTTP GET]]で取得する処理が入ると、Permissionとして`--allow-net`が必要になる。 ```console deno run main.ts request "https://reqres.in/api/users?page=2" ┌ ⚠️ Deno requests net access to "reqres.in". ├ Requested by `fetch()` API. ├ Run again with --allow-net to bypass this prompt. └ Allow? [y/n/A] (y = yes, allow; n = no, deny; A = allow all net permissions) > ``` これを実行のたびに聞かれるのは煩わしいのでデフォルトで許可したい。 ...と思っていたが、それができるのは`deno compile`のときだけ。[[Deno]]を通して実行する場合は都度フラグを指定する必要がありそう。詳細は[[DenoのPermissions]]。 よって、以下のように実行すればよい。 ```console deno run --allow-net main.ts request "https://reqres.in/api/users?page=2" ``` ## 環境変数を読み込む 新たにコマンドを増やし、環境変数を読み込んでみる。 ```ts import { Command } from "https://deno.land/x/[email protected]/command/mod.ts"; // Learn more at https://deno.land/manual/examples/module_metadata#concepts if (import.meta.main) { await new Command() .command("env", "環境変数を表示する") .action(showEnv) .command("request", "リクエストする") .arguments("<url:string>") .action(request) .parse(Deno.args); } function showEnv(_: unknown) { console.log(JSON.stringify(Deno.env.toObject(), null, 4)); } async function request(_: unknown, url: string) { const r = await (await fetch(url)).json(); console.log(r); } ``` 環境変数読み込みにも権限設定が必要なので... ```console deno run --allow-net --allow-env main.ts env ``` ## [[HTTP POST]] さっきは[[HTTP GET]]をしたので次は[[HTTP POST]]をしてみる。[[Discord Webhook]]を使う。 また、`env`コマンドは削除する。 ```ts import { Command } from "https://deno.land/x/[email protected]/command/mod.ts"; // Learn more at https://deno.land/manual/examples/module_metadata#concepts if (import.meta.main) { await new Command() .command("notify", "Discordに通知する") .option("--message <message:string>", "通知メッセージ", { required: true }) .action(notify) .parse(Deno.args); } async function notify(option: { message: string }) { const url = Deno.env.get("DISCORD_WEBHOOK_URL"); if (!url) { throw new Error( "環境変数DISCORD_WEBHOOK_URLが設定されていません。設定してください。", ); } const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ content: option.message }), }); if (!r.ok) { throw new Error( `Discordへのリクエストに失敗しました。ステータスコード: ${r.status}, メッセージ: ${await r.text()}`, ); } console.log("Discordへの通知に成功しました"); } ``` 以下のように実行すると[[Discord Webhook]]の紐づくchannelに通知が飛ぶ。 ```console deno run --allow-net --allow-env main.ts notify --message "Hello!" ``` ## 環境変数もCliffyで読み込む [[Cliffyで環境変数の値をオプションに設定]]できる。`.env`で設定するだけ。 ```ts import { Command } from "https://deno.land/x/[email protected]/command/mod.ts"; if (import.meta.main) { await new Command() .command("notify", "Discordに通知する") .option("--message <message>", "通知メッセージ", { required: true }) .env("DISCORD_WEBHOOK_URL=<value>", "Discord Webhook URL", { required: true, }) .action(notify) .parse(Deno.args); } async function notify(option: { message: string; discordWebhookUrl: string }) { const r = await fetch(option.discordWebhookUrl, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ content: option.message }), }); if (!r.ok) { throw new Error( `Discordへのリクエストに失敗しました。ステータスコード: ${r.status}, メッセージ: ${await r.text()}`, ); } console.log("Discordへの通知に成功しました"); } ``` > [!positive] 環境変数を[[コマンドライン引数]]とマージしたりするのって地味に面倒だからこの機能はありがたい。設定ファイルもないのかな..? ## CSVファイルを入力として読み込む 今まで `--message` で入力を受け取っていたけど、これを[[CSV]]形式にしてみる。 [[CSVを読み込む (Deno)|CSVを読み込む]] を参考に、CSVファイルのパスからCSV文字列を読み込んでオブジェクトにパースして表示する関数をつくる。 ```ts interface Request { seq: number; message: string; } async function loadRequests(path: string): Promise<Request[]> { return parse(await Deno.readTextFile(path), { skipFirstRow: true, trimLeadingSpace: true, parse: (input) => { const e = input as { seq: string; message: string }; return { seq: Number(e.seq), message: e.message }; }, }) as Promise<Request[]>; } ``` 以下の[[CSV]] `input.csv` をインプットとしたとき ```csv seq,message 1,1つ目のメッセージ 2,2つ目のメッセージ 3, これで終わり ``` 先ほどの関数を呼び出すと ```ts async function notify( option: { message: string; file: string; discordWebhookUrl: string }, ) { const requests = await loadRequests(option.file); console.log(requests); } ``` 出力は以下のようになる。 ```json [ { seq: 1, message: "1つ目のメッセージ " }, { seq: 2, message: "2つ目のメッセージ " }, { seq: 3, message: "これで終わり" } ] ``` [[Discord]]通知も分離して、`--message`オプションを削除し結合したものが以下。 ```ts import { Command } from "https://deno.land/x/[email protected]/command/mod.ts"; import { parse } from "https://deno.land/[email protected]/encoding/csv.ts"; if (import.meta.main) { await new Command() .command("notify", "Discordに通知する") .option("-f, --file <file:string>", "入力CSVファイルのパス", { required: true, }) .env("DISCORD_WEBHOOK_URL=<value>", "Discord Webhook URL", { required: true, }) .action(notify) .parse(Deno.args); } interface Request { seq: number; message: string; } async function loadRequests(path: string): Promise<Request[]> { return parse(await Deno.readTextFile(path), { skipFirstRow: true, trimLeadingSpace: true, parse: (input) => { const e = input as { seq: string; message: string }; return { seq: Number(e.seq), message: e.message }; }, }) as Promise<Request[]>; } async function notifyToDiscord( url: string, message: string, ): Promise<Error | undefined> { const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ content: message }), }); if (!r.ok) { return new Error( `Discordへのリクエストに失敗しました。ステータスコード: ${r.status}, メッセージ: ${await r.text()}`, ); } } async function notify( option: { file: string; discordWebhookUrl: string }, ) { const requests = await loadRequests(option.file); for (const r of requests) { const err = await notifyToDiscord(option.discordWebhookUrl, r.message); if (err !== undefined) { console.error(err.message); } } } ``` 実行すると、[[CSV]]の各レコード `message` カラムの値が[[Discord]]に連続して通知される。 ## ファイルを分離する 責務がいくつか出てきたのでファイルを以下3ファイルに分割する。 - `main.ts` - `files.ts` - `discord.ts` ついでにdry runオプション `--dry` も追加して最終的なコードは `discord.ts` ```ts export async function notify( url: string, message: string, ): Promise<Error | undefined> { const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ content: message }), }); if (!r.ok) { return new Error( `Discordへのリクエストに失敗しました。ステータスコード: ${r.status}, メッセージ: ${await r.text()}`, ); } } ``` `files.ts` ```ts import { parse } from "https://deno.land/[email protected]/encoding/csv.ts"; export interface Request { seq: number; message: string; } export async function loadRequests(path: string): Promise<Request[]> { return parse(await Deno.readTextFile(path), { skipFirstRow: true, trimLeadingSpace: true, parse: (input) => { const e = input as { seq: string; message: string }; return { seq: Number(e.seq), message: e.message }; }, }) as Promise<Request[]>; } ``` `main.ts` ```ts import { Command } from "https://deno.land/x/[email protected]/command/mod.ts"; import * as files from "./files.ts"; import * as discord from "./discord.ts"; if (import.meta.main) { await new Command() .command("notify", "Discordに通知する") .option("--dry", "dry run") .option("-f, --file <file:string>", "入力CSVファイルのパス", { required: true, }) .env("DISCORD_WEBHOOK_URL=<value>", "Discord Webhook URL", { required: true, }) .action(notify) .parse(Deno.args); } async function notify( option: { file: string; discordWebhookUrl: string; dry?: boolean }, ) { const { file, discordWebhookUrl, dry } = option; const requests = await files.loadRequests(file); for (const r of requests) { if (dry) { console.log(`[Dry run]: Notify "${r.message}"`); } else { const err = await discord.notify(discordWebhookUrl, r.message); if (err !== undefined) { console.error(err.message); } } } } ``` ## import文をシンプルにする [[URI]]をフルに指定しなくてもいいようにする。[[Import Maps (Deno)|Import Maps]]を使う。 `deno.jsonc` ```json { "imports": { "cliffy": "https://deno.land/x/[email protected]/command/mod.ts", "csv": "https://deno.land/[email protected]/encoding/csv.ts" }, "tasks": { "dev": "deno run --watch main.ts" } } ``` これで、import文は以下のように簡略化できる。 ```diff - import { parse } from "https://deno.land/[email protected]/encoding/csv.ts"; + import { parse } from "csv"; - import { Command } from "https://deno.land/x/[email protected]/command/mod.ts"; + import { Command } from "cliffy"; ``` ## 通知した内容を[[CSV]]としても出力する コンバーターなどは[[CSV]]で書き込みことも多いため、書き込み処理の経験値も積んでおく。 [[コマンドライン引数]]で入力ファイルと出力ファイルのオプションを一新し、途中で処理が終了しても、そこまでの結果は確実に記録されるようにするよう実装を変更。 `files.ts` ```ts import { parse } from "csv"; export interface Request { seq: number; message: string; } export async function loadRequests(path: string): Promise<Request[]> { return parse(await Deno.readTextFile(path), { skipFirstRow: true, trimLeadingSpace: true, parse: (input) => { const e = input as { seq: string; message: string }; return { seq: Number(e.seq), message: e.message }; }, }) as Promise<Request[]>; } export interface Result { seq: number; message: string; error: string | undefined; } export class ResultWriter { private constructor(private path: string) {} static async createNewFile( path: string, option?: { withHeader?: boolean }, ): Promise<ResultWriter> { const ins = new ResultWriter(path); await Deno.writeTextFile( ins.path, option?.withHeader ? `seq,message,error\n` : "", ); return ins; } async appendRecord(result: Result) { await Deno.writeTextFile( this.path, `${result.seq},${result.message},${result.error ?? ""}\n`, { append: true }, ); } } ``` `main.ts` ```ts import { Command } from "cliffy"; import * as files from "./files.ts"; import * as discord from "./discord.ts"; if (import.meta.main) { await new Command() .command("notify", "Discordに通知する") .option("--dry", "dry run") .option("-i, --input-file <file:string>", "入力CSVファイルのパス", { required: true, }) .option("-o, --output-file <file:string>", "出力CSVファイルのパス", { required: true, }) .env("DISCORD_WEBHOOK_URL=<value>", "Discord Webhook URL", { required: true, }) .action(notify) .parse(Deno.args); } async function notify( option: { inputFile: string; outputFile: string; discordWebhookUrl: string; dry?: boolean; }, ) { const { inputFile, outputFile, discordWebhookUrl, dry } = option; const requests = await files.loadRequests(inputFile); const writer = await files.ResultWriter.createNewFile(outputFile, { withHeader: true, }); for (const r of requests) { const err = dry ? console.log(`[Dry run]: Notify "${r.message}"`) : await discord.notify(discordWebhookUrl, r.message); writer.appendRecord({ seq: r.seq, message: r.message, error: err ? err.message : "", }); } } ``` 以下のようなコマンドを実行すると、`output.csv`に結果が出力される。 ```console deno run --allow-net --allow-env --allow-read --allow-write main.ts notify -i input.csv -o output.csv ``` ## 入出力ファイルはデフォルト値を設定する 入力は`input.csv`、出力は`output.csv`をデフォルトにする。`main.ts`の以下部分を変えるだけ。`required`は外して`default`を指定。 ```ts await new Command() .command("notify", "Discordに通知する") .option("--dry", "dry run") .option("-i, --input-file <file:string>", "入力CSVファイルのパス", { default: "input.csv", }) .option("-o, --output-file <file:string>", "出力CSVファイルのパス", { default: "output.csv", }) .env("DISCORD_WEBHOOK_URL=<value>", "Discord Webhook URL", { required: true, }) .action(notify) .parse(Deno.args); ``` これで必須の[[コマンドライン引数]]はなくなった。インターフェースがシンプルに。 ```console deno run --allow-net --allow-env --allow-read --allow-write main.ts notify ``` ## CLIのメタデータを記載する `name` `version` `description`を改めて記載する。 ```ts await new Command() .name("dcs") .version("0.1.0") .description("Deno CLI Sample") .command("notify", "Discordに通知する") .option("--dry", "dry run") .option("-i, --input-file <file:string>", "入力CSVファイルのパス", { default: "input.csv", }) .option("-o, --output-file <file:string>", "出力CSVファイルのパス", { default: "output.csv", }) .env("DISCORD_WEBHOOK_URL=<value>", "Discord Webhook URL", { required: true, }) .action(notify) .parse(Deno.args); ``` ## コマンドを指定しないで実行したときはヘルプを表示する `help`コマンドに`HelpCommand`を割り当て、`default("help")`を設定しておく。 ```ts import { Command, HelpCommand } from "cliffy"; import * as files from "./files.ts"; import * as discord from "./discord.ts"; if (import.meta.main) { await new Command() .name("dcs") .version("0.1.0") .description("Deno CLI Sample") .default("help") .command("notify", "Discordに通知する") .option("--dry", "dry run") .option("-i, --input-file <file:string>", "入力CSVファイルのパス", { default: "input.csv", }) .option("-o, --output-file <file:string>", "出力CSVファイルのパス", { default: "output.csv", }) .env("DISCORD_WEBHOOK_URL=<value>", "Discord Webhook URL", { required: true, }) .action(notify) .command("help", new HelpCommand()) .parse(Deno.args); } ``` 引数なしでヘルプが表示されるようになった。 ```console $ deno run --allow-net --allow-env --allow-read --allow-write main.ts Usage: dcs Version: 0.1.0 Description: Deno CLI Sample Options: -h, --help - Show this help. -V, --version - Show the version number for this program. Commands: notify - Discordに通知する help [command] - Show this help or the help of a sub-command ``` ## シングルバイナリにビルドする 先ほどの[[#コンパイル]]をオプションつきでもう一度行う。実行ファイル名も`dsc`に。 ```console deno compile --allow-net --allow-env --allow-read --allow-write --target x86_64-pc-windows-msvc main.ts -o dsc.exe ``` 確認。 ```console $ ./dsc Usage: dcs Version: 0.1.0 Description: Deno CLI Sample Options: -h, --help - Show this help. -V, --version - Show the version number for this program. Commands: notify - Discordに通知する help [command] - Show this help or the help of a sub-command. ``` 実際に通知されるかどうかも。 ```console $ ./dsc notify -o result.csv ``` > [!positive] コマンドがシンプルになってシングルバイナリで達成感ある😊 サイズは80M近くあるけどzip圧縮で30M台になるから、ギリギリ許容範囲な気がする。