[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
/**
 * 汎用コマンドラインツールのテンプレート - cmdutils
 *
 * ### 提供コマンド:
 * - `renumber`: 指定したディレクトリ内の画像を連番で一括リネームする。
 *
 * ### 使用例:
 * ```bash
 * deno run -A cmdutils.ts renumber --prefix "vacation_" --start 1
 * ```
 *
 * Deno 2.7.10 / TypeScript 準拠
 *
 * @module
 */
import { parseArgs } from "jsr:@std/cli@^1.0.28/parse-args";
import * as path from "jsr:@std/path@^1.1.4";
import { exists } from "jsr:@std/fs@^1.0.23/exists";

const PROG_VERSION = "2026.04.04";

/**
 * コマンドのインターフェース
 */
interface BaseCommand {
  /** コマンド識別子 */
  readonly name: string;

  /** ヘルプ画面に表示される説明文 */
  readonly description: string;

  /**
   * ヘルプ情報を標準出力。
   */
  showHelp(): void;

  /**
   * コマンドのメインロジックを実行する。
   *
   * @param args 解析前のコマンドライン引数
   * @returns 終了コード(0: 成功、それ以外: 失敗)
   */
  run(args: string[]): Promise<number>;
}

/**
 * オプション解析機能を持つサブコマンドのインターフェース
 *
 * 各サブコマンドごとに独自の引数を持つため、
 * その解析ロジック(parseOptions)と戻り値の型をBaseCommandから分離。
 *
 * @template T コマンド独自の解析済みオプション型
 */
interface SubCommand<T> extends BaseCommand {
  /**
   * サブコマンド名を除いた引数リストを解析し、独自のオプション型を返す。
   *
   * @param args サブコマンド以降の引数
   */
  parseOptions(args: string[]): T;
}


/**
 * `renumber` コマンドで使用するパース済みオプション
 */
interface ReNumberOptions {
  /** 出力ファイル名の先頭に付与する文字列 */
  prefix: string;

  /** 連番の開始番号 */
  startNo: number;

  /** リネーム実施フラグ(trueで実際にリネームをする) */
  isFix: boolean;

  /** 最低桁数 */
  minDigits: number;
}

interface FileInfo {
  fullPath: string;
  baseName: string;
  ext: string;
}

interface RenamePlan {
  file: FileInfo;
  newName: string;
  isNew: boolean;
}

/**
 * ディレクトリ内の画像ファイルを連番で一括リネームするコマンド
 */
export class ReNumberCommand implements SubCommand<ReNumberOptions> {
  readonly name = "renumber";
  readonly description = "画像ファイルを連番でリネームします。";

  /**
   * 処理対象とする拡張子のリスト。
   * 全て小文字、ドット付きで定義。
   */
  private static readonly TARGET_EXTS: readonly string[] = [
    ".jpg", ".png", ".webp", ".bmp", ".jpeg", ".gif"
  ];

  /**
   * カレントディレクトリから対象ファイルを収集し、自然順でソートし取得する。
   *
   * @returns ソート済みのファイル情報(FileInfo)の配列
   *
   * @remarks
   * - 拡張子の判定は大文字小文字を区別しない。
   * - ソートは「自然順(数値順)」かつ
   *   「大文字小文字・全角半角を区別する」設定で行う。
   */
  private async collectFiles(): Promise<FileInfo[]> {
    const files: FileInfo[] = [];

    for await (const entry of Deno.readDir(".")) {
      if (!entry.isFile) continue;

      const { name: basename, ext } = path.parse(entry.name);
      if (ReNumberCommand.TARGET_EXTS.includes(ext.toLowerCase())) {
        files.push({
          fullPath: entry.name,
          ext: ext,
          baseName: basename,
        });
      }
    }

    const collator = new Intl.Collator("ja", {
      numeric: true,
      sensitivity: "variant",
    });
    files.sort((a, b) => collator.compare(a.baseName, b.baseName));

    return files;
  }

  /**
   * 対象ファイル群に対して、新しいファイル名を含むリネーム計画を作成する。
   *
   * @param targetFiles - リネーム対象となるファイルの情報の配列
   * @param prefix - ファイル名の先頭に付与する文字列
   * @param startNo - 連番の開始番号
   * @param digits - 連番部分の最小桁数(足りない場合は `0` で埋められる)
   * @returns 各ファイルの新旧名と変更の有無を含む計画オブジェクトの配列
   *
   * @remarks
   * - 実際のファイル名は元の拡張子の大小文字を維持する。
   * - 変更の有無判定においては、ベース名は厳密に比較するが、
   *   拡張子のみ大文字小文字の違いを無視して比較を行う。
   * - 例:`Photo.JPG` から `Photo.jpg` への変更は「変更なし」と判定する。
   */
  private createRenamePlan(
    targetFiles: FileInfo[],
    prefix: string,
    startNo: number,
    digits: number
  ) {
    return targetFiles.map((file, index) => {
      const numPart = String(startNo + index).padStart(digits, "0");
      const newName = `${prefix}${numPart}${file.ext}`;
      const isNew = file.fullPath !== newName;

      return {
        file,
        newName,
        isNew
      };
    });
  }

 /**
   * 作成されたリネーム計画に衝突や重複がないか、実行前に一括検証する。
   *
   * @param renamePlan - 検証対象となるリネーム計画の配列
   * @param allTargets - 今回のリネーム処理の全対象ファイル情報の配列
   * @returns 計画が安全で実行可能な場合は true、衝突が検知された場合は false
   *
   * @remarks
   * 以下の 2 点をチェックし、データの安全性を担保する:
   *
   * 1. **リネーム予定名同士の重複**:
   *    計画内で複数のファイルが同じ名前になろうとしていないかを確認する。
   *    判定は大文字小文字を区別せずに行う。
   *
   * 2. **外部ファイルとの衝突**:
   *    リネーム先のパスに既にファイルが存在する場合、それが「今回移動して場所を
   *    空ける予定のファイル」であるかを確認する。
   *    今回の対象外である(居座り続ける)ファイルと衝突する場合は、上書き事故を
   *    防ぐため不許可とする。
   */
  private async validatePlan(
    renamePlan: RenamePlan[],
    allTargets: FileInfo[]
  ): Promise<boolean> {
    const usedNames = new Set<string>();

    for (const plan of renamePlan) {
      if (!plan.isNew) continue;

      const newNorm = plan.newName.toLowerCase();
      if (usedNames.has(newNorm)) {
        console.error(
          "中止: リネーム予定同士のファイル名が重複します: " +
          `${plan.newName}`
        );

        return false;
      }
      usedNames.add(newNorm);

      if (await exists(plan.newName)) {
        const isWillBeMoved = allTargets.some(f =>
          f.fullPath.toLowerCase() === plan.newName.toLowerCase()
        );
        if (!isWillBeMoved) {
          console.error(`中止: 既存の別ファイルと衝突します: ${plan.newName}`);

          return false;
        }
      }
    }

    return true;
  }

  /**
   * リネーム計画に基づき、実際のファイル移動処理を実行する。
   *
   * @param renamePlan - 作成されたリネーム計画の配列
   * @param isFix - true の場合は実際にリネームを実行し、false の場合は表示のみ行う
   * @returns 全てのリネームが正常に完了した場合は true、途中で失敗した場合は false
   *
   * @remarks
   * - **玉突き衝突の回避**: `01.jpg -> 02.jpg`, `02.jpg -> 03.jpg` のような
   *   連鎖的なリネームを安全に行うため計画を逆順(配列の末尾)から処理すること
   *   で、移動先のパスを常に空けた状態で実行する。
   * - **エラーハンドリング**: いずれかのリネームに失敗した場合、ファイルシステ
   *   ムの一貫性を守るためエラーを出力して即座に処理を中断し `false` を返す。
   * - **スキップ判定**: 名前変更なし または シミュレーション の場合は
   *   コンソール表示のみを行い、実際のリネーム処理はスキップする。
   */
  private async executeRename(
    renamePlan: RenamePlan[],
    isFix: boolean,
  ): Promise<boolean> {
    for (let i = renamePlan.length - 1; i >= 0; i--) {
      const { file, newName, isNew } = renamePlan[i];

      const status = isNew ? "" : " (変更なし)";
      console.log(`${file.fullPath} -> ${newName}${status}`);

      if (!isNew || !isFix) continue;

      try {
        await Deno.rename(file.fullPath, newName);
      }
      catch (error) {
        console.error(
          `FAILED: ${file.fullPath} のリネームに失敗しました:`,
          error
        );

        return false;
      }
    }

    return true;
  }


  /**
   * ヘルプ情報を標準出力。
   */
  showHelp(): void {
    console.log(`
Command: ${this.name}
Description: ${this.description}

Usage:
  deno run -A cmdutils.ts ${this.name} [options]

Options:
  -e, --prefix <string>  出力ファイル名の接頭辞 (デフォルト: "")
  -n, --start <number>   開始番号 (デフォルト: 1)
  -d, --digits <number>  最低桁数 (デフォルト: 自動計算)
  --fix                  シミュレーションではなく実際にリネームを実行
  -h, --help             このヘルプを表示

FEATURES:
  - 自然順ソート: "file2.txt" が "file10.txt" より前に並びます。

CAUTION:
  - リネーム先に「対象外の同名ファイル」が存在する場合は
    上書き防止のため処理を中断します。`
    );
  }

  /**
   * 引数を `ReNumberOptions` 型にパースする。
   *
   * @param rawArgs コマンドライン引数の配列
   * @returns パース済みのオプションオブジェクト
   */
  parseOptions(rawArgs: string[]): ReNumberOptions {
    const args = parseArgs(rawArgs, {
      string: ["e"],
      boolean: ["fix"],
      alias: {
        e: "prefix",
        n: "start",
        d: "digits",
      },
      default: {
        e: "",
        n: 1,
        d: 0, // 0は自動計算を意味する
        fix: false,
      },
    });

    return {
      prefix: String(args.e),
      startNo: Number(args.n) || 1,
      minDigits: Number(args.d) || 0,
      isFix: Boolean(args.fix),
    };
  }

  /**
   * コマンドのメインロジックを実行する。
   *
   * @param rawArgs 引数配列
   * @returns 終了コード (0: 成功, 1: 衝突による中止)
   * @throws {Error} ファイルシステム操作に失敗した場合
   */
  async run(rawArgs: string[]): Promise<number> {
    const { prefix, startNo, isFix, minDigits } = this.parseOptions(rawArgs);

    const targetFiles = await this.collectFiles();
    if (targetFiles.length === 0) {
      console.log("対象となる画像ファイルが見つかりませんでした。");
      return 0;
    }

    const autoDigits = String(targetFiles.length + startNo - 1).length;
    const digits = Math.max(autoDigits, minDigits);

    const renamePlan = this.createRenamePlan(targetFiles, prefix, startNo, digits);

    if (!await this.validatePlan(renamePlan, targetFiles)) {
      return 1;
    }

    await this.executeRename(renamePlan, isFix);

    if(!isFix) {
      console.log(
        "\n【シミュレーション】" +
        " 実際に変更するには --fix を指定してください"
      );
    }

    return 0;
  }
}

/**
 * ツール全体のライフサイクルとコマンド実行を管理するメインクラス
 */
class Main {
  private commands: Map<string, BaseCommand> = new Map();

  constructor() {
    this.registerCommand(...[
      new ReNumberCommand(),
      // -----------------------------------------------------------
      // TODO: **** ここにサブコマンドを追記していく ****
      // -----------------------------------------------------------
    ]);
  }

  /**
   * コマンドを内部レジストリに登録する。
   *
   * @param cmd 登録するコマンドインスタンス
   */
  private registerCommand(cmd: BaseCommand): void {
    this.commands.set(cmd.name, cmd);
  }

  /**
   * ヘルプ情報を標準出力。
   *
   * @returns 常に0(正常終了コード)
   */
  private showHelp(): number {
    console.log(
      "Usage: deno run -A cmdUtils.ts [command] [options]"
      + "\nCommands:"
    );

    for (const cmd of this.commands.values()) {
      console.log(`  ${cmd.name.padEnd(15)} : ${cmd.description}`);
    }

    return 0;
  }

 /**
   * アプリケーションのメインエントリーポイント。
   *
   * 引数を解析し、適切なサブコマンドを呼び出す。
   *
   * @returns プロセスの終了コード
   */
  async exec(): Promise<number> {
    const args = parseArgs(Deno.args, {
      stopEarly: true, // 最初の引数をサブコマンドとして扱う
      boolean: ["help", "version"],
      alias: { h: "help", v: "version" },
    });

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

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

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

    // サブコマンド固有のヘルプ (例: renumber --help)
    // remainingArgs の中に --help が含まれているかチェック
    const subArgs = parseArgs(remainingArgs, {
      boolean: ["help"],
      alias: { h: "help" },
    });
    if (remainingArgs.length === 0 || subArgs.help) {
      command.showHelp();
      return 0;
    }

    try {
      const exitCode = await command.run(remainingArgs);
      return exitCode;
    }
    // 想定外のエラー(権限不足やディスクフルなど)
    catch (error) {
      console.error(`[Fatal Error] ${command.name}:`, error instanceof Error ? error.message : error);

      return 1;
    }
  }
}

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