[Deno/TypeScript] コマンドラインスクリプトのテンプレート(サブコマンド対応)

・コマンドライン スクリプトのテンプレート
・単一の.tsファイル
・サブコマンド対応
・環境
 deno 2.7.10 (stable, release, x86_64-pc-windows-msvc)
  v8 14.7.173.7-rusty
  typescript 5.9.2
・ダウンロード
#!/usr/bin/env -S deno run --allow-all
/**
 * 汎用コマンドラインツールのテンプレート
 *
 * ### 提供コマンド:
 * - `renumber`     : 指定したディレクトリ内の画像を連番で一括リネームする
 * - `downloadfiles`: ファイルをダウンロードする(連番対応)
 * - `resizeimages` : 画像を伸縮する(ImageMagickが必要)
 * - `resizevideo`  : 動画を伸縮する(ffmpegが必要)
 * - `filecompress` : 指定パスを圧縮ファイル化する
 * - `dirtotext`    : ディレクトリ構造とソースファイル群を単一テキスト化。
 *                    AIにプログラムの構造を理解させる手助けにもなるはず。
 *
 * ### 使用例:
 * ```bash
 * deno run -A utils.ts renumber --prefix "vacation_" --start 1
 * ```
 *
 * ### 開発・動作環境:
 * - Deno 2.7.14 (V8 14.7) / TypeScript 5.9 準拠
 * - ImageMagick 7.1
 * - FFmpeg 8.1-full_build
 *
 * ### タグ:
 * - NOTE: なぜこのような実装にしたのか、設計の背景や理由
 * - TODO: 未実装の機能、後で追加予定の処理
 * - HACK: 綺麗ではないが動かすために一時的に書いた暫定コード
 * - FIXME: バグがある、または正しく動くがコードが汚い場所
 *
 * ### 備忘録: Java/Python と似ているけど TypeScript の違う点
 * 1. OSやファイルシステムなどのI/Oはランタイム固有APIを使う必要がある。
 *    DenoはWeb標準APIを実装しているので、ある程度はWebブラウザと共通した
 *    コードが書ける。
 * 2. 単一スレッド・単一イベントループで動作し、2つのキューを捌く。
 *    コールスタックが空になる度に最優先でマイクロタスク(Promise等)を全件消化
 *    し、その後マクロタスク(setTimeout/IO等)を1件のみ実行する直列の時間割。
 *    マイクロタスク内の無限ループはマクロタスクや描画を完全にフリーズさせるた
 *    め注意。
 * 3. 型の判定基準が「構造的型(ダック・タイピング)」である。
 *    Javaのように extends や implements による明示的な関係性(名前)を持たな
 *    くても、オブジェクトの見た目の「形(構造)」さえ一致していれば自動的に互
 *    換性あり(合格)と判定されて代入が可能になる。
 *
 *    このため、Record<string, unknown> や  [key: string]: unknown のような
 *    大雑把な共通の「器(型)」を定義した場合、明示的に継承関係を書いていない
 *    無関係なオブジェクトまですり抜けて合格判定となり、コンパイルエラーや警告
 *    が出なくなるので注意。
 * 4. 関数式(アロー関数やIIFEも含む)は、言語仕様上は生成時にスコープ内の全て
 *    の変数をキャプチャしてヒープメモリ上にクロージャを生成する。しかし、現代
 *    の主要ランタイムは静的解析により、関数式内部で実際に参照している変数
 *    (または this)のみをクロージャに保持(隔離)する。
 *
 *    ただし、クロージャの実体はスコープにつき1つしか生成されないため、同一
 *    スコープ内で関数式を複数生成した場合は、それらが参照している変数の総和
 *    (最大数)が単一のクロージャに共有・保持され、特定の変数の寿命が伸びてGC
 *    に回収されにくくなるリスクがある。
 *
 *    したがって、関数式の生存期間と、スコープの厳密な分離を意識すること。
 * 5. try-catchのerrorはError|unknownであり、型が不確定。
 * 6. メソッドチェーン(.then/.catch/.pipe / .map/.filter)
 *    高負荷時:ループ内では関数オブジェクト生成を避け、
 *              try-catch / for-await / for...of を使う。
 *    一般領域:可読性優先ならよいが、裏でメモリ確保が走るコストを意識する。
 * 7. スプレッド演算子([...])による隠れた O(N) コピーがあるので多用は無用。
 *    大量データ時は push() 等の破壊的変更(In-place)による最適化を選択する。
 * 8. オブジェクトの構造は生成時に固定し、後からのプロパティ追加は禁止。
 *    - 生成後にプロパティを追加するとランタイムの最適化(Hidden Class)から
 *      外れる。
 *    - オプションプロパティ(?:)を使ってその都度異なるキー構成でオブジェクト
 *      を生成(省略)すると、形状(Shape)の不一致により関数のインライン
 *      キャッシュなどの最適化が効かなくなり、実行効率が悪くなる可能性がある。
 *    - 最高のパフォーマンスが必要な領域では、初期値を undefined で固定する。
 *
 * ### 備忘録: 設定しておくと便利なオプション
 * deno.jsonc
 * ```jsonc
 * {
 *   "compilerOptions": {
 *     "strict": true,                      // 厳格モード
 *     "exactOptionalPropertyTypes" : true, // 正確なプロパティタイプに限定
 *     "noImplicitOverride": true,          // 暗黙のオーバーライドを禁止
 *     "noImplicitReturns": true,           // 暗黙の戻り値を禁止
 *     "noFallthroughCasesInSwitch": true,  // switch文の突き抜け防止
 *     "noImplicitAny": true                // 暗黙のanyを禁止
 *   },
 *   "lint": {
 *     "rules": {
 *       "include": [
 *         "explicit-function-return-type", // 暗黙の戻り値型を禁止
 *         "no-explicit-any",               // any 型を禁止 (unknown等への移行)
 *         "no-var",                        // var を禁止 (let/const を強制)
 *         "prefer-const",                  // 再代入しない変数の let を禁止
 *         "eqeqeq",                        // == ではなく === を強制
 *         "no-irregular-whitespace",       // 全角スペース等の混入を排除
 *         "no-throw-literal"               // Errorオブジェクト以外のThrow禁止
 *                                          // ※この指定をしても
 *                                          //   catch内部で型チェックは必須
 *       ]
 *     }
 *   },
 *   "fmt": {
 *     "nextControlFlowPosition": "nextLine", // elseとcatchの位置を次行に
 *     "operatorPosition": "nextLine",        // 演算子の位置を次行に
 *     "newLineKind": "lf"                    // 改行コードを LF に
 *   }
 * }
 * ```
 * @module
 */

// ===================================================================
// ランタイムに依存しない外部ライブラリ
// ===================================================================
// deno-lint-ignore-file no-external-import no-import-prefix
import { parseArgs } from "jsr:@std/cli@^1.0.28/parse-args";
import * as path from "jsr:@std/path@^1.1.4";
import * as posix from "jsr:@std/path@^1.1.4/posix";
import { delay } from "jsr:@std/async@^1.2.0";
import { walk } from "jsr:@std/fs@^1.0.23";
import * as zip_js from "jsr:@zip-js/zip-js@^2.8.26";

const PROG_VERSION = "2026.04.30";

// ===================================================================
// 共通関数
// ===================================================================

/**
 * 不明なエラーオブジェクトを Error インスタンスに正規化する。
 *
 * 言語仕様上の必然性:
 * JavaScript の `throw` は任意の型を許容する仕様(ECMAScript 準拠)であり、
 * TypeScript 5.9 においても `catch(error)` は `unknown` 型として定義される。
 * 本関数は、この不確実な入力を後続処理で安全に扱うための「型ガード」および
 * 「正規化」として機能する。
 *
 * @param error 判定対象のエラー。ランタイムから流入する不確実な型。
 * @returns 判定済み、または正規化された Error インスタンス。
 */
function ensureError(error: unknown): Error {
  return error instanceof Error ? error : new Error(String(error));
}

/**
 * テンプレート文字列。
 *
 * Javaなどの `String.format` に相当する簡易動的テンプレート。
 * ロケール毎のテキストに対応したり、予めメッセージを別で定義しておく場合に使用
 * する。
 *
 * @param template テンプレート文字列
 * @param args 注入する値(可変長引数)
 * @returns 構築済み文字列
 *
 * @remarks 補足
 * - 置換対象の書式は {0}, {1}, ...{n} であり、{数字}で記述する。
 * - 置換対象の並び順も個数(重複可)も自由。
 *
 * @example
 * ```typescript
 * // "ようこそ、{1}さん。{0}".format("Now Loading...", "Tarou")相当の使用例
 * const msg1 = t(MSG[lang].welcome, "Now Loading...", "Tarou");
 * // => "ようこそ、Tarouさん。Now Loading..."
 *
 * // 複数の値を注入する例
 * const msg2 = t("{0}のエラーが発生しました (Code: {1})", "システム", 500);
 * // => "システムのエラーが発生しました (Code: 500)"
 *
 * // インデックスが引数の数を超過した場合
 * // (元のマッチ文字列を維持し情報の欠損を防ぐ)
 * const msg3 = t("{0}と{1}の同期", "データ");
 * // => "データと{1}の同期"
 */
// deno-lint-ignore no-unused-vars
const t = (() => {
  const RE_TEMPLATE: RegExp = /{(\d+)}/g;

  // NOTE: スタックトレースが出来るように名前付き関数式にする。
  //       ここではアロー関数式にするメリットは特にない
  return function t(template: string, ...args: unknown[]): string {
    return template.replace(RE_TEMPLATE, (match, p1) => {
      // NOTE: Numberと同じ動作をする+演算子の方が変換コストが低い
      //       可読性が犠牲にならない箇所のみ使用可
      // const i = Number(p1);
      const i = +p1;
      return i < args.length ? String(args[i]) : match;
    });
  };
})();

/**
 * 数値を適切な接頭辞(2進、SI、または bps)付きの文字列に変換する。
 *
 * @param src - 変換対象の数値。
 * @param kind - 単位の体系を指定する。初期値は "bin"。
 *   - "bin": 1024単位の2進接頭辞(KiB, MiB 等)を使用する。
 *   - "SI": 1000単位のSI接頭辞(KB, MB 等)を使用する。
 *   - "bps": 1000単位のビットレート単位(Kbps, Mbps 等)を使用する。
 * @returns 小数点以下2桁固定の数値と単位を連結した文字列。
 *
 * @example
 * ```typescript
 * formatBinaryNumber(1024);              // "1.00 KiB" (default: bin)
 * formatBinaryNumber(1572864);           // "1.50 MiB" (default: bin)
 * formatBinaryNumber(1000, "SI");        // "1.00 KB"
 * formatBinaryNumber(1000000, "bps");    // "1.00 Mbps"
 * formatBinaryNumber(0);                 // "0 B"
 * formatBinaryNumber(0, "SI");           // "0 B"
 * formatBinaryNumber(0, "bps");          // "0 bps"
 *  ```
 */
const formatBinaryNumber = (() => {
  type KIND = "bin" | "SI" | "bps";

  const UNITS: Readonly<Record<KIND, readonly string[]>> = {
    "bin": ["B", "KiB", "MiB", "GiB", "TiB", "PiB"],
    "SI": ["B", "KB", "MB", "GB", "TB", "PB"],
    "bps": ["bps", "Kbps", "Mbps", "Gbps", "Tbps", "Pbps"],
  };

  function calc(src: number, divisor: number, unitLen: number): {
    value: number;
    i: number;
  } {
    let value = src;
    let i = 0;
    while (value >= divisor && i < unitLen) {
      value /= divisor;
      i++;
    }

    return { value, i };
  }

  // NOTE: スタックトレースが出来るように名前付き関数式にする。
  //       ここではアロー関数式にするメリットは特にない
  return function formatBytes(
    src: number,
    kind: KIND = "bin",
  ): string {
    if (src <= 0) return kind === "bps" ? "0 bps" : "0 B";

    const units = UNITS[kind];
    const divisor = kind === "bin" ? 1024 : 1000;

    const res = calc(src, divisor, units.length - 1);
    const value = res.value.toFixed(2);
    const suffix = units[res.i];

    return `${value} ${suffix}`;
  };
})();

// ===================================================================
// ランタイム固有処理の抽象化
//
// NOTE: テスト用モックや異なるランタイムに切り替えやすくするため
// ===================================================================

/**
 * ファイル情報を抽象化した型。
 *
 * {@link Deno.FileInfo}を元にした構造であり現時点で必要な項目だけを
 * 記述している。
 */
interface RuntimeFileInfo {
  size: number;
}

/**
 * ディレクトリエントリ。
 *
 * {@link Deno.DirEntry}を元にした構造。
 */
interface RuntimeDirEntry {
  /** ファイル名・ディレクトリ名。 */
  name: string;

  /** ファイルなら true。 */
  isFile: boolean;

  /** ディレクトリなら true。 */
  isDirectory: boolean;

  /** シンボリックリンクなら true。 */
  isSymlink: boolean;
}

/**
 * 読み込み用ストリームと解放処理をセットにしたリソース。
 *
 * [Symbol.asyncDispose] により、await using での自動解放に対応する。
 */
interface ReadableResource extends AsyncDisposable {
  readonly stream: ReadableStream<Uint8Array>;
}

/**
 * OpenSyncの戻り値型。
 */
interface OpenSyncResult {
  writeSync(p: Uint8Array): number;
  close(): void;
}

/**
 * 外部プロセス(同期)の実行結果を格納する構造体。
 *
 * 終了ステータスおよび標準入出力のバイナリデータを保持する。
 * {@link Deno.CommandOutput}を元にした構造。
 */
interface SubProcessResult {
  /**
   * プロセスの終了コード。
   *
   * 一般に 0 は成功、それ以外はエラーまたは特定の状態を示す。
   */
  code: number;

  /**
   * プロセスから出力された標準出力(stdout)の生データ。
   *
   * テキストとして扱う場合は、必要に応じて TextDecoder 等でデコードする。
   */
  stdout: Uint8Array;

  /**
   * プロセスから出力された標準エラー出力(stderr)の生データ。
   *
   * 主にエラーメッセージや診断情報が格納される。
   */
  stderr: Uint8Array;
}

/**
 * 外部プロセス(非同期)の実行結果を格納する構造体。
 *
 * {@link Deno.CommandStatus}を元にした構造。
 */
interface SubProcessStatus {
  success: boolean;
  code: number;
  // signal: Signal | null;
}

/**
 * 実行環境(ランタイム)固有の操作を抽象化するインターフェース。
 *
 * @remarks 補足
 * - 異なるランタイム環境への適応を多少容易にするための抽象レイヤー。
 * - ランタイム固有機能はこの一箇所に纏める。
 * @internal
 */
interface Runtime {
  /** 指定した終了コードでプロセスを終了する。 */
  exit(code: number): void;

  /** コマンドライン引数を取得する。 */
  getArgs(): string[];

  /** ディレクトリを作成する。 */
  mkdir(
    path: string,
    options?: {
      recursive?: boolean;
      mode?: number;
    },
  ): Promise<void>;

  /** ファイルをコピーする。 */
  copyFile(src: string, dest: string): Promise<void>;

  /** ディレクトリ内のエントリを走査する。 */
  readDir(path: string): AsyncIterable<RuntimeDirEntry>;

  /** ファイルまたはディレクトリをリネーム(移動)する。 */
  rename(oldPath: string, newPath: string): Promise<void>;

  /** ファイルのメタデータを取得する。 */
  stat(path: string): Promise<RuntimeFileInfo>;

  /** パスが存在するか確認する。 */
  exists(path: string): Promise<boolean>;

  /** ストリームからデータを読み取り、指定パスへ書き込む。 */
  writeFile(
    path: string,
    stream: ReadableStream<Uint8Array> | null,
  ): Promise<boolean>;

  /** 指定パスのファイルをリソースとして取得する。 */
  getReadableResource(path: string): Promise<ReadableResource>;

  /**
   * 指定されたパスのファイルを同期的に開く。
   *
   * @param path ファイルパス
   * @param options {@link Deno.OpenOptions} に準拠したオプション
   * @returns {@link Deno.openSync}に準拠したファイル操作オブジェクト
   */
  openSync(path: string, options: {
    write?: boolean;
    create?: boolean;
    truncate?: boolean;
    read?: boolean;
  }): OpenSyncResult;

  /**
   * 外部コマンドを実行する。
   *
   * @param command 実行するコマンド名またはパス
   * @param args コマンドに渡す引数の配列
   */
  execSync(command: string, args: string[]): Promise<SubProcessResult>;

  exec(
    command: string,
    args: string[],
    onOutput: (text: string, source: "stdout" | "stderr") => void,
  ): Promise<SubProcessStatus>;

  /**
   * 改行を付与せず、標準出力に文字列を直接書き込む(同期版)。
   *
   * @param message 出力する文字列
   */
  printSync(message: string): void;

  /** 実行環境情報を出力する。 */
  outputEnv(): void;
}

/**
 * Deno ランタイム標準 API を使用する {@link Runtime} の本番用実装。
 *
 * @remarks ランタイム固有の外部ライブラリを使う場合は、ここの中でインポート
 * する。
 */
const nativeRuntime: Runtime = {
  exit(code: number): void {
    Deno.exit(code);
  },

  getArgs(): string[] {
    return Deno.args;
  },

  mkdir: Deno.mkdir,
  copyFile: Deno.copyFile,
  readDir: Deno.readDir,
  rename: Deno.rename,

  async stat(path: string): Promise<RuntimeFileInfo> {
    return await Deno.stat(path);
  },

  // @std/fs/existsの競合状態回避策
  async exists(path: string): Promise<boolean> {
    try {
      await Deno.lstat(path);
      return true;
    }
    catch {
      return false;
    }
  },

  async writeFile(
    path: string,
    stream: ReadableStream<Uint8Array> | null,
  ): Promise<boolean> {
    try {
      using file = await Deno.open(path, {
        write: true,
        create: true,
        truncate: true,
      });

      if (stream !== null) {
        await stream.pipeTo(file.writable);
      }

      return true;
    }
    catch {
      return false;
    }
  },

  async getReadableResource(path: string): Promise<ReadableResource> {
    const file = await Deno.open(path, { read: true });

    return {
      stream: file.readable,

      [Symbol.asyncDispose](): Promise<void> {
        try {
          file.close();
        }
        catch { /** ignore */ }

        return Promise.resolve();
      },
    };
  },

  openSync(path: string, options: {
    write?: boolean;
    create?: boolean;
    truncate?: boolean;
    read?: boolean;
  }): OpenSyncResult {
    const file = Deno.openSync(path, options);
    return {
      writeSync: (p: Uint8Array): number => file.writeSync(p),
      close: (): void => file.close(),
    };
  },

  async execSync(command: string, args: string[]): Promise<SubProcessResult> {
    const cmd = new Deno.Command(command, { args });
    return await cmd.output();
  },

  async exec(
    command: string,
    args: string[],
    onOutput: (text: string, source: "stdout" | "stderr") => void,
  ): Promise<SubProcessStatus> {
    /**
     * 非同期の読み込み。
     *
     * @remarks 補足
     * - steam: true でマルチバイトの境界を認識させる。
     * - steam: true を指定しているので最終チャンクは手動で処理する。
     */
    const readStream = async (
      stream: ReadableStream<Uint8Array>,
      source: "stdout" | "stderr",
    ): Promise<void> => {
      const decoder = new TextDecoder();
      for await (const chunk of stream) {
        onOutput(decoder.decode(chunk, { stream: true }), source);
      }

      const remaining = decoder.decode();
      if (remaining) {
        onOutput(remaining, source);
      }
    };

    const cmd = new Deno.Command(command, {
      args,
      stdout: "piped",
      stderr: "piped",
    });
    const child = cmd.spawn();
    const [stdoutDone, stderrDone] = [
      readStream(child.stdout, "stdout"),
      readStream(child.stderr, "stderr"),
    ];
    const [status] = await Promise.all([
      child.status,
      stdoutDone,
      stderrDone,
    ]);

    return status;
  },

  printSync(message: string): void {
    const data = new TextEncoder().encode(message);
    Deno.stdout.writeSync(data);
  },

  outputEnv(): void {
    console.log("--- Deno Built-in Properties ---");

    const envInfo = {
      "Deno.hostname": Deno.hostname(),
      "Deno.execPath()": Deno.execPath(),
      "Deno.noColor": Deno.noColor,
      "Deno.version": Deno.inspect(Deno.version),
      "Deno.build": Deno.inspect(Deno.build),
    };
    for (const [key, value] of Object.entries(envInfo)) {
      console.log(`${key}: ${value}`);
    }

    console.log("--- Deno Related Environment Variables ---");

    // deno-fmt-ignore
    const denoRelatedVars = [
      "DENO_DIR",            // キャッシュルート
      "DENO_INSTALL",        // インストール先
      "DENO_AUTH_TOKENS",    // 秘密リポジトリ等の認証情報
      "DENO_CERT",           // TLS証明書のパス
      "DENO_WEBGPU_ADAPTER", // WebGPUの使用アダプタ
      "NO_COLOR",            // コンソール出力の色抑制
      "HTTP_PROXY",          // ネットワークプロキシ
      "HTTPS_PROXY",         // ネットワークプロキシ
      "NO_PROXY",            // プロキシ除外設定
    ];
    for (const name of denoRelatedVars) {
      const value = Deno.env.get(name);
      console.log(`${name}: ${value ?? "(not set)"}`);
    }
  },
};

// ===================================================================
// 画像処理ラッパー
// ===================================================================
//                           ~~中略~~
// ===================================================================
// 動画処理ラッパー
// ===================================================================

/**
 * 動画の統計情報。
 */
type VideoStats = {
  /** ファイル名/パス */
  name: string;

  /** 横幅 (px) */
  w: number;

  /** 縦幅 (px) */
  h: number;

  /** 表示アスペクト比 (例: "16:9") */
  display_aspect_ratio: string;

  /** 再生時間 (秒単位) */
  duration: number;

  /** 総フレーム数 */
  nb_frames: number;

  /** ビットレート (bps) */
  bit_rate: number;

  /** ファイルサイズ (bytes) */
  size: number;
};

/**
 * ビデオ処理の進捗状況を通知するためのコールバック関数型。
 *
 * @param data 進捗データオブジェクト
 * @param data.percent 処理の進捗率(0〜100 の数値)
 * @param data.currentTime 現在処理中の動画内時間(秒単位の浮動小数点数)
 * @example
 * ```typescript
 * const onProgress: VideoProgressCallback = (p) => {
 *   console.log(
 *     `進捗: ${p.percent.toFixed(1)}% (時間: ${p.currentTime.toFixed(2)}s)`
 *   );
 * };
 * ```
 */
type VideoProgressCallback = (
  data: { percent: number; currentTime: number },
) => void;

/**
 * 動画操作に関する高レベルな機能を提供するプロセッサ。
 *
 * 内部で実行される具体的なエンジンの詳細は抽象化されている。
 * 利用側は環境の違いを意識することなく動画解析や加工が可能。
 */
class VideoProcessor {
  constructor(private readonly runtime: Runtime) {}

  /**
   * 指定された動画ファイルの統計情報(寸法、容量等)を取得する。
   *
   * @param path 解析対象ファイルのパス
   * @returns 動画ファイルの統計情報。
   *          解析出来ない場合は`null`。
   */
  async getStats(path: string): Promise<VideoStats | null> {
    const fileInfo = await this.runtime.stat(path);
    if (fileInfo.size === 0) {
      // 解析不能
      return null;
    }

    // deno-fmt-ignore
    const { code, stdout } = await this.runtime.execSync("ffprobe", [
      "-v", "quiet",
      "-print_format", "json",
      "-show_format",
      "-show_streams",
      path,
    ]);

    if (code !== 0 || stdout.length === 0) {
      // 解析不能
      return null;
    }

    const decoded = new TextDecoder().decode(stdout);
    if (!decoded.trim()) {
      // JSONとして正しくない
      return null;
    }

    const json = JSON.parse(decoded);
    const stream = json.streams?.[0];
    const format = json.format;

    if (!stream || !format) return null;

    return {
      name: String(format.filename),
      w: Number(stream.width),
      h: Number(stream.height),
      display_aspect_ratio: String(stream.display_aspect_ratio),
      duration: Number(stream.duration),
      nb_frames: Number(stream.nb_frames),
      bit_rate: Number(stream.bit_rate),
      size: Number(format.size),
    };
  }

  /**
   * 指定されたパラメータに基づいて動画をリサイズする。
   *
   * @param input 入力ソースのパス
   * @param output 出力先のパス
   * @param width リサイズ幅
   * @param height リサイズ高た
   * @param srcVideo 入力ソースの統計情報
   * @param onProgress 進捗状況を受け取るためのオプションのコールバック関数
   * @returns 実行結果のステータス
   * - `code`: プロセスの終了コード(0の場合は成功)
   * - `message`: 失敗時のエラー内容、成功時は空文字列
   */
  async resize(
    input: string,
    output: string,
    width: number,
    height: number,
    srcVideo: Readonly<VideoStats>,
    onProgress?: VideoProgressCallback,
  ): Promise<{
    code: number;
    message: string;
  }> {
    // deno-fmt-ignore
    const args = [
      "-y",
      "-i", input,
      "-vf", `scale='trunc(min(${width},iw*${height}/ih)/2)*2':-2`,
      "-c:a", "copy",
      "-maxrate", String(srcVideo.bit_rate),
      "-bufsize", String(srcVideo.bit_rate * 2),
      "-preset", "slow",
      output,
    ];
    const totalDuration = srcVideo.duration;
    let lastErrorLines = "";
    const re_time: RegExp = /time=(\d+):(\d+):([\d.]+)/;

    const res = await this.runtime.exec("ffmpeg", args, (text, source) => {
      if (source === "stderr" && totalDuration > 0 && onProgress) {
        const match = text.match(re_time);
        if (!match) {
          lastErrorLines = text;
          return;
        }

        // deno-fmt-ignore
        const currentTime =
            parseFloat(match[1]) * 3600
          + parseFloat(match[2]) * 60
          + parseFloat(match[3]);
        const percent = Math.min(100, (currentTime / totalDuration) * 100);

        onProgress({ percent, currentTime });
      }
    });

    // deno-fmt-ignore
    const msg = res.success
      ? ""
      : lastErrorLines !== ""
        ? lastErrorLines
        : "ffmpeg error";
    return {
      code: res.code,
      message: msg,
    };
  }
}

// ===================================================================
// fetchのラッパー
// ===================================================================

/**
 * fetch 実行の結果を分類して表す。
 *
 * 判別共用体 (Discriminated Union)。
 *
 * @remarks 補足
 * - ネットワークの成否だけでなく、HTTP ステータスコードに基づく。
 * - 論理的な成功・失敗も型レベルで区別するために使用する。
 */
type SafeFetchResult =
  | ({ type: "success"; response: Response } & AsyncDisposable)
  | ({ type: "http_error"; response: Response } & AsyncDisposable)
  | ({ type: "network_error"; error: Error } & AsyncDisposable);

/**
 * fetch を実行し、例外をスローせずに実行結果を型安全なオブジェクトとして返す。
 *
 * @param url - リクエスト対象の URL 文字列
 * @returns 実行結果の状態 (`type`) に応じた {@link SafeFetchResult} を含む
 *
 * @remarks 素のfetchではエラーハンドリングが面倒くさいので例外を全て吸収し
 * 戻り値として返す。
 */
async function safeFetch(
  url: string,
  headers: HeadersInit = {},
): Promise<SafeFetchResult> {
  try {
    const response = await fetch(url, { method: "GET", headers });

    const disposer = {
      async [Symbol.asyncDispose](): Promise<void> {
        if (!response.bodyUsed && response.body) {
          await response.body.cancel();
        }
      },
    };

    return {
      type: response.ok ? "success" : "http_error",
      response,
      ...disposer,
    };
  }
  catch (error) {
    return {
      type: "network_error",
      error: ensureError(error),
      [Symbol.asyncDispose](): Promise<void> {
        return Promise.resolve();
      },
    };
  }
}

// ===================================================================
// コマンドの定義
// ===================================================================

/**
 * コマンドの実行結果を表す型定義。
 *
 * `success` プロパティの値によって、成功時と失敗時の状態を区別する。
 * 判別共用体 (Discriminated Union)
 */
type CommandResult = Readonly<
  | { success: true; code: 0; message?: string }
  | { success: false; code: number; message: string; error?: Error }
>;

/**
 * コマンドのオプションの基底型
 */
type CommandOptions = {
  /** 詳細な実行ログを出力するかどうか (現在未使用) */
  verbose?: boolean;
};

/**
 * コマンドの実行結果に、解析済みオプション情報を付加した派生型。
 *
 * {@link CommandResult} を基底とし、処理が成功した場合(`success: true`)のみ、
 * {@link T} 型の `options` プロパティが追加される。
 * @template T - オプション情報の型。{@link CommandOptions} を継承する。
 */
type OptionResult<T extends CommandOptions> =
  | (CommandResult & { success: true; options: T })
  | (CommandResult & { success: false });

/**
 * サブコマンドの「クラス定義そのもの」が満たすべき共通ルール。
 *
 * @remarks 抽象クラスでは制限できない `static` メンバの実装を、コンパイル時に
 * 強制するための仕組み。
 *
 * これにより、クラスをインスタンス化(new)する前の段階で、
 * 識別用の `static ID` などが正しく定義されているかがチェックされる。
 */
interface SubCommandConstructor {
  /**
   * コマンドの識別名(例: "renumber", "download")。
   *
   * @remarks 具象クラスでは `static readonly ID: string` として定義する。
   */
  readonly ID: string;

  /**
   * ヘルプ画面に表示されるコマンドの簡潔な説明文。
   *
   * @remarks 具象クラスでは `static readonly DESCRIPTION: string` として定義
   * する。
   */
  readonly DESCRIPTION: string;

  /**
   * サブコマンドのインスタンスを生成するためのコンストラクタ。
   *
   * @param runtime ランタイムの実装
   * @returns サブコマンドのインスタンス
   */
  new (runtime?: Runtime): SubCommand<CommandOptions>;
}

/**
 * 全てのサブコマンドの基底となる抽象クラス。
 *
 * 引数の解析(Template Methodパターンの構成)と実行フローを制御する。
 * 継承先では、具体的なオプション型 `T` を定義し、解析と実行のロジックを
 * 実装すること。
 *
 * @template T コマンドが使用する解析済みオプションの型
 */
abstract class SubCommand<T extends CommandOptions> {
  /**
   * コンストラクター。
   *
   * @param runtime - ランタイム固有機能。
   * デフォルトは `nativeRuntime`を使用。
   * 単体テスト時には、ここへモックオブジェクトを注入することで、
   * 実際のファイルシステムを汚さずにロジックを検証できる。
   * @example
   * // 本番用(通常はこちらを使用)
   * const cmd = new ReNumberCommand();
   * @example
   * // テスト用(ディレクトリ内のファイル一覧を偽装する)
   * const mock: Runtime = {
   * readDir: async function* () { yield { name: "file1.txt", isFile: true, isDirectory: false, isSymlink: false }; },
   * exists: async () => false,
   * rename: async (s, d) => console.log(`Moved: ${s} -> ${d}`),
   * };
   * const cmd = new ReNumberCommand(mock);
   */
  constructor(protected runtime: Runtime = nativeRuntime) {}

  /**
   * コマンド固有のヘルプ情報(使用法、オプションの詳細など)を
   * 標準出力に表示する。
   * @protected
   */
  protected abstract showHelp(): void;

  /**
   * コマンドライン引数の配列を解析し、型 `T` のオブジェクトに変換する。
   *
   * @param args サブコマンド名を除いた、解析対象の引数リスト
   * @returns 解析・構造化されたオプションオブジェクト
   * @protected
   */
  protected abstract parseOptions(args: string[]): OptionResult<T>;

  /**
   * コマンドの具体的な実行ロジックを実装する。
   *
   * @param options `parseOptions` によって解析されたオプションオブジェクト
   * @returns 実行結果(成功・失敗のステータスとメッセージ)を含むPromise
   * @protected
   */
  protected abstract runImpl(options: T): Promise<CommandResult>;

  /**
   * コマンドの実行エントリポイント。
   *
   * 引数の解析 (`parseOptions`) を行い、その結果を元に実行 (`runImpl`) を
   * 呼び出す。
   *
   * @param args コマンドラインから渡された未解析の引数リスト
   * @returns コマンドの実行結果を保持するオブジェクト
   */
  async run(args: string[]): Promise<CommandResult> {
    if (args.length === 0) {
      this.showHelp();
      return { success: true, code: 0 };
    }

    const subArgs = parseArgs(args, {
      boolean: ["help"],
      alias: { h: "help" },
    });
    if (subArgs.help) {
      this.showHelp();
      return { success: true, code: 0 };
    }

    const resOption = this.parseOptions(args);
    if (resOption.success === false) {
      return {
        success: false,
        code: resOption.code,
        message: resOption.message,
        ...(resOption.error ? { error: resOption.error } : {}),
      };
    }

    return await this.runImpl(resOption.options);
  }
}

// ===================================================================
// SubCommand: renumber (連番リネーム)
// ===================================================================
//                           ~~中略~~
// ===================================================================
// SubCommand: downloadfiles (ファイルダウンロード)
// ===================================================================
//                           ~~中略~~
// ===================================================================
// SubCommand: resizeimages (画像ファイルサイズ変換)
// ===================================================================
//                           ~~中略~~
// ===================================================================
// SubCommand: resizevideo (動画のサイズ変換)
// ===================================================================
//                           ~~中略~~
// ===================================================================
// SubCommand: filecompress (圧縮ファイル化)
// ===================================================================
//                           ~~中略~~
// ===================================================================
// SubCommand:  dirtotext (構成内容一括出力)
// ===================================================================

/**
 * `dirtotext` コマンドで使用するパース済みオプション。
 */
type DirToTextOptions = CommandOptions & {
  rootPath: string;
  isTreeOnly: boolean;
  outputPath?: string | undefined;
};

/**
 * 内部用の書き込みリソース抽象化型。
 *
 * @remarks `using` 宣言に対応する。
 *
 * @example
 * ```typescript
 * {
 * using write = createDirToTextWriter("output.txt");
 * write("some content");
 * // スコープを抜ける際に Symbol.dispose が自動的に close() を呼び出す
 * }
 * ```
 */
type DirToTextWriter = {
  (content: string): void;
  close: () => void;
  [Symbol.dispose](): void;
};

/**
 * 開発プロジェクト内部のディレクトリ構造を単一のテキストに変換し、ソース
 * ファイルに相当する内容を結合して1つにする。
 */
class DirToTextCommand extends SubCommand<DirToTextOptions> {
  static readonly ID = "dirtotext";
  static readonly DESCRIPTION =
    "プロジェクトディレクトリ構造とソースファイルを1つのテキストに変換";

  private static readonly IGNORE_DIRS: ReadonlySet<string> = new Set([
    "node_modules",
    "vedor",
    ".git",
    ".github",
    ".gitlab",
    "test",
    "dist",
    "build",
    "out",
    "coverage",
    ".venv",
    "__pycache__",
    // ".vscode",
  ]);

  /**
   * 処理対象とする拡張子のリスト。
   * 全て小文字、ドット付きで定義。
   */
  // deno-fmt-ignore
  private static readonly TARGET_EXTS: ReadonlySet<string> = new Set([
    // Web / Script
    ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".d.ts",
    // Python
    ".py", ".pyi",
    // Java
    ".java", ".jsp", ".properties", ".kt", ".kts",
    // C / C++
    ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh", ".c",
    // SQL
    ".sql",
    // Configs
    ".json", ".jsonc", ".yaml", ".yml", ".toml", ".ini", ".conf", ".cfg",
    ".gitignore", ".code-workspace",
  ]);

  /** 日本語環境に最適化された自然順ソート用の比較器。 */
  // deno-fmt-ignore
  private static readonly I18N_COLLATOR: Readonly<Intl.Collator> =
    new Intl.Collator("ja", { numeric: true, sensitivity: "variant" });

  /**
   * ヘルプ情報を標準出力。
   */
  protected override showHelp(): void {
    console.log(`
Description:
  ${DirToTextCommand.DESCRIPTION}

Usage:
  deno run -A utils.ts ${DirToTextCommand.ID} [Options]

Options:
  -p, --rootPath  出力対象のルートディレクトリ
  -t, --tree      ディレクトリ構造のみ出力
  -o, --output    出力ファイルのパス (未指定時は標準出力)
  -h, --help      このヘルプを表示`);
  }

  /**
   * 引数を `DirToTextOptions` 型にパースする。
   *
   * @param rawArgs コマンドライン引数の配列
   * @returns パース済みのオプションオブジェクト
   */
  protected override parseOptions(
    rawArgs: string[],
  ): OptionResult<DirToTextOptions> {
    const args = parseArgs(rawArgs, {
      string: ["rootPath", "output"],
      boolean: ["tree"],
      default: {
        rootPath: ".",
        tree: false,
      },
      alias: {
        p: "rootPath",
        t: "tree",
        o: "output",
      },
    });

    return {
      success: true,
      code: 0,
      options: {
        rootPath: args.rootPath,
        isTreeOnly: args.tree,
        ...(args.output ? { outputPath: args.output } : {}),
      },
    };
  }

  /**
   * コマンドのメインロジックを実行する。
   *
   * @param options `parseOptions` によって解析されたオプションオブジェク
   *
   * @returns 実行結果(成功・失敗のステータスとメッセージ)を含むPromise
   * @throws {Error} ファイルシステム操作に失敗した場合
   */
  protected override async runImpl(
    options: Readonly<DirToTextOptions>,
  ): Promise<CommandResult> {
    const absoluteRoot = path.resolve(options.rootPath);
    const treeLines: string[] = [];
    const files: string[] = [];

    await this.scanDirectory(absoluteRoot, treeLines, files);

    using writer = this.createWriter(options.outputPath);

    writer("=".repeat(40));
    writer("PROJECT STRUCTURE:");
    writer(treeLines.join("\n"));

    if (options.isTreeOnly) {
      return { success: true, code: 0 };
    }

    writer("\n" + "=".repeat(40));
    writer("FILE CONTENTS:");

    for (const file of files) {
      const relativePath = path.relative(absoluteRoot, file);
      writer(`\n---- ${relativePath} ----`);

      await using res = await this.runtime.getReadableResource(file);
      const text = await new Response(res.stream).text();
      writer(text);
    }

    return { success: true, code: 0 };
  }

  /**
   * ディレクトリ構造を再帰的に走査し、ツリー表示用の文字列配列とファイル情報の
   * 配列を構築する。
   *
   * 各階層のエントリは、まず「ディレクトリ > ファイル」の優先順位で分類し、
   * 同一タイプ内ではロケールを考慮した辞書順でソートされた上で走査する。
   *
   * @param currentPath - 現在走査中、および起点となるディレクトリの絶対パス。
   * @param treeLines - ツリー形式の文字列を格納する`出力用`配列。
   * @param files - 抽出されたファイル情報を格納する`出力用`配列。
   * @param prefix - ツリー表示の階層を示すためのインデント文字列。
   * @returns 非同期処理の完了を示す Promise。
   * @throws {Error} ファイルシステム操作に失敗した場合
   */
  async scanDirectory(
    currentPath: string,
    treeLines: string[],
    files: string[],
    prefix = "",
  ): Promise<void> {
    const entries: RuntimeDirEntry[] = [];

    for await (const entry of this.runtime.readDir(currentPath)) {
      if (await this.isValidEntry(currentPath, entry)) {
        entries.push(entry);
      }
    }

    entries.sort((a, b) => {
      if (a.isDirectory !== b.isDirectory) {
        return a.isDirectory ? -1 : 1;
      }

      return DirToTextCommand.I18N_COLLATOR.compare(a.name, b.name);
    });

    for (let i = 0; i < entries.length; i++) {
      const entry = entries[i];
      const isLast = i === entries.length - 1;
      const fullPath = path.join(currentPath, entry.name);
      const line = `${prefix}${isLast ? "└── " : "├── "}${entry.name}`;
      treeLines.push(line);

      if (entry.isDirectory) {
        await this.scanDirectory(
          fullPath,
          treeLines,
          files,
          prefix + (isLast ? "    " : "│   "),
        );
      }
      else {
        files.push(fullPath);
      }
    }
  }

  /**
   * 処理対象エントリ判定。
   *
   * @remarks
   * 判定基準:
   * - `IGNORE_DIRS` に該当しないディレクトリであり、その配下に1つ以上の
   *   処理対象ファイルが存在する。
   * - `TARGET_EXTS` に該当するファイル。
   * - シンボリックリンクは処理対象外。
   *
   * @param currentPath - 現在走査しているディレクトリの絶対パス。
   * @param entry - エントリ情報。
   * @returns 処理対象である場合は `true`、それ以外は `false`。
   * @throws {Error} ファイルシステム操作に失敗した場合
   */
  private async isValidEntry(
    currentPath: string,
    entry: RuntimeDirEntry,
  ): Promise<boolean> {
    if (entry.isDirectory) {
      if (DirToTextCommand.IGNORE_DIRS.has(entry.name)) {
        return false;
      }

      const childPath = path.join(currentPath, entry.name);
      return await this.containsTargetEntries(childPath);
    }
    else if (entry.isFile) {
      const ext = path.extname(entry.name).toLowerCase();
      return DirToTextCommand.TARGET_EXTS.has(ext);
    }

    // シンボリックリンクは無視する
    return false;
  }

  /**
   * 指定されたディレクトリが処理対象エントリを含んでいるかを判定する。
   *
   * @param dirPath 走査対象のディレクトリの絶対パス
   * @returns `dirPath`が処理対象エントリを含むディレクトリなら true。
   * @throws {Error} ファイルシステム操作に失敗した場合
   */
  async containsTargetEntries(dirPath: string): Promise<boolean> {
    for await (const entry of this.runtime.readDir(dirPath)) {
      if (await this.isValidEntry(dirPath, entry)) {
        return true;
      }
    }

    return false;
  }

  /**
   * 出力先に応じた Writer オブジェクトを生成する。
   *
   * @param outputPath - 出力先ファイルのパス。
   *                     省略した場合は標準出力(console.log)を使用する。
   * @returns `DirToTextWriter` オブジェクト。
   *          `using` 構文による自動リソース解放に対応する。
   * @throws {Error} ファイルシステム操作に失敗した場合
   *
   * @example
   * ```typescript
   * using writer = this.createWriter("output.txt");
   * writer("content");
   * ```
   */
  private createWriter(outputPath?: string): DirToTextWriter {
    if (outputPath) {
      return this.createFileWriter(outputPath);
    }

    return this.createConsoleWriter();
  }

  private createFileWriter(outputPath: string): DirToTextWriter {
    const encoder = new TextEncoder();
    const file = this.runtime.openSync(outputPath, {
      write: true,
      create: true,
      truncate: true,
    });

    const writer = ((content: string): void => {
      file.writeSync(encoder.encode(content + "\n"));
    }) as DirToTextWriter;

    writer.close = (): void => {
      file.close();
    };

    writer[Symbol.dispose] = (): void => {
      writer.close();
    };

    return writer;
  }

  private createConsoleWriter(): DirToTextWriter {
    const writer = ((content: string): void => {
      console.log(content);
    }) as DirToTextWriter;

    writer.close = (): void => {};
    writer[Symbol.dispose] = (): void => {};

    return writer;
  }
}

// ===================================================================
// メイン処理
// ===================================================================

/**
 * ツール全体のライフサイクルとコマンド実行を管理するメインクラス。
 *
 * 本プログラムはシンプルなコマンドラインプログラムなので、高度な
 * ハンドリングは必要ない。
 */
class Main {
  // NOTE: ここで SubCommandConstructor型 を指定することで
  // サブコマンドにstaticメンバーとコンストラクタを継承(強制)させられる
  // deno-fmt-ignore
  private readonly COMMANDS: ReadonlyMap<string, SubCommandConstructor> =
    new Map<string, SubCommandConstructor>([
      // [ReNumberCommand.ID, ReNumberCommand],
      // [DownloadFilesCommand.ID, DownloadFilesCommand],
      // [ReSizeImagesCommand.ID, ReSizeImagesCommand],
      // [ReSizeVideoCommand.ID, ReSizeVideoCommand],
      // [FileCompressorCommand.ID, FileCompressorCommand],
      [DirToTextCommand.ID, DirToTextCommand],
  ]);

  /**
   * ヘルプ情報を標準出力。
   *
   * @returns 常に0(正常終了コード)
   */
  private showHelp(): void {
    console.log(`Usage: deno run -A utils.ts [Options] [SubCommands]

Options:
  -h, --help      Show help
  -v, --version   Show version
  -e, --env       Show environment info

SubCommands:`);

    // deno-fmt-ignore
    const maxDigits = [...this.COMMANDS.keys()].reduce(
      (max, key) => Math.max(max, key.length), 0
    ) + 2;

    for (const cmd of this.COMMANDS.values()) {
      console.log(`  ${cmd.ID.padEnd(maxDigits)} : ${cmd.DESCRIPTION}`);
    }
  }

  /**
   * コマンドライン引数をパースする。
   *
   * @param args コマンドライン引数の配列
   * @returns パース済みのオプションオブジェクト
   */
  private parseOptions(args: string[]): {
    help: boolean;
    version: boolean;
    env: boolean;
    _: string[]; // 上記の残りの引数がここに格納される
  } {
    return parseArgs(args, {
      stopEarly: true, // 最初の引数をサブコマンドとして扱う
      boolean: ["help", "version", "env"],
      alias: { h: "help", v: "version", e: "env" },
    });
  }

  /**
   * アプリケーションのメインエントリポイント。
   *
   * コマンドライン引数を解析し、適切なサブコマンドを呼び出す。
   *
   * @returns プロセスの終了コード
   */
  async exec(): Promise<number> {
    const args = this.parseOptions(nativeRuntime.getArgs());

    if (args.version) {
      console.log(`${PROG_VERSION}`);
      return 0;
    }

    if (args.env) {
      nativeRuntime.outputEnv();
      return 0;
    }

    const [subCommandName, ...remainingArgs] = args._;
    if (args.help || !subCommandName) {
      this.showHelp();
      return 0;
    }

    const command = this.COMMANDS.get(subCommandName);
    if (!command) {
      console.error(`Unknown command: ${subCommandName}`);
      return 1;
    }

    try {
      const cmd = new command();
      const result = await cmd.run(remainingArgs);

      if (result.message) {
        const kind = result.success ? "" : "[Error] ";
        console.log(`${kind}${result.message}`);
      }

      if (!result.success && result.error) {
        console.debug(result.error.stack);
      }

      return result.code;
    }
    // 想定外のエラー(権限不足やディスクフルなど)
    catch (error) {
      const e = ensureError(error);
      console.error(`[Fatal Error] ${command.ID}: ${e.message}`);
      console.debug(e.stack);

      return 2;
    }
  }
}

/**
 * 直接実行された場合のみアプリケーションを起動
 */
if (import.meta.main) {
  const exitCode = await new Main().exec();
  nativeRuntime.exit(exitCode);
}

// /**
//  * 唯一の公開シンボル(テスト用ゲートウェイ)
//  */
// export const __Test__ = {
//   safeFetch,
//   DirToTextCommand,
//   // テストで必要な内部定義をすべてここに集約
// } as const;