1つのプロジェクトディレクト内で複数のプロジェクトを格納するタイプ。 ※他のスクリプト系とDenoは運用コンセプトが異なるので 1.Deno自体をバージョン毎に使い分ける必要はない 2.外部ライブラリのバージョンを意識するだけ(importで互換性指定が可能) # ディレクトリ構造 [ユーザー名]-deno-world ├── .vscode/ ├── .gitignore ├── deno.json 全体の共通設定 ├── scripts/ │ └── backup.ts 全体バックアップ ├── dist/ 生成した配布物 ├── apps/ │ ├── app1/ ワークスペース1 │ │ ├── deno.json WSの専用設定 │ │ ├── scripts/ │ │ │ └── build.ts 配布物生成 │ │ ├── src/ │ │ │ ├── main.ts │ │ │ └── shared/ WS内専用の共有コード │ │ └── test/ │ └── app2/ ワークスペース2 ├── shared/ 全体の共有コード/ライブラリ │ └── mod.ts └── playground/ 実験コード # 設定 ## .vscode/settings.json## .gitignore{ "deno.enable": true, "deno.lint": true, "deno.cacheOnSave": true, "deno.suggest.imports.autoDiscover": true }## ./deno.jsondist/##./apps/app1/deno.json{ "workspace": [ "./apps/*", "./shared", "./playground/*" ], "tasks": { "check-update": "deno outdated", "update": "deno outdated --update", "backup": "deno run --allow-read --allow-write --allow-run scripts/backup.ts", "test-all": "deno test --allow-all", "lint-all": "deno lint", "start-app1": "deno task --cwd apps/app1 start", "build-app1": "deno task --cwd apps/app1 build" }, "imports": { "@shared/root": "./shared/mod.ts" } }{ "name": "app1", "version": "0.1.0", "tasks": { "start": "deno run --allow-all src/main.ts", "build": "deno run --allow-all scripts/build.ts" }, "imports": { "@shared/local": "./src/shared/mod.ts" } }
気まぐれメモ
[Deno] プロジェクトディレクト 案 その2
[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 /** * 汎用コマンドラインツールのテンプレート - fuzzyutilsTS * * ### 提供コマンド: * - `renumber`: 指定したディレクトリ内の画像を連番で一括リネームする。 * * ### 使用例: * ```bash * deno run -A fuzzyutilsTS.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"; const PROG_VERSION = "2026.04.07"; /** * ファイル操作を抽象化するインターフェース。 * * @remark * テスト時にモックに差し替えるために export 指定。 * @internal */ export interface FileSystem { readDir(path: string): AsyncIterable<Deno.DirEntry>; rename(oldPath: string, newPath: string): Promise<void>; exists(path: string): Promise<boolean>; } /** * 実際の OS ファイルシステムを使用する {@link FileSystem} の本番用実装。 * * @remarks * `Deno.readDir` や `Deno.rename` などの標準 API のラップ。 * 通常はデフォルトで使用し、テスト時はテスト用のモックを用意する。 */ const RealFS: FileSystem = { readDir: Deno.readDir, rename: Deno.rename, // 標準ライブラリのexistsは使うまでもないので自前で実装 exists: async (path) => { try { await Deno.stat(path); return true; } catch { return false; } }, }; /** * ファイル内部でのみ使用する処理結果を管理する型。 * * 成功、論理エラー(理由)、例外(Error)の3つの状態を厳格に区別する。 * 判別共用体 (Discriminated Union) * @internal */ export type InternalResult<T> = | { success: true; value?: T; reason?: never; error?: never } | { success: false; value?: never; reason : string; error?: never } | { success: false; value?: never; reason?: string; error : Error }; /** * コマンドの実行結果を表す型定義。 * * `success` プロパティの値によって、成功時と失敗時の状態を区別する。 * 判別共用体 (Discriminated Union) */ export type CommandResult = | { success: true; code: 0; message?: string } | { success: false; code: number; message: string; error?: Error }; /** * 全てのサブコマンドの基底となる抽象クラス。 * * 引数の解析(Template Methodパターンの構成)と実行フローを制御する。 * 継承先では、具体的なオプション型 `T` を定義し、解析と実行のロジックを * 実装すること。 * * @template T コマンドが使用する解析済みオプションの型 */ abstract class BaseCommand<T> { /** * コマンドの識別名(例: "renumber", "copy")。 * コマンドラインでサブコマンドとして指定される文字列になる。 */ abstract readonly name: string; /** * ヘルプ画面に表示されるコマンドの簡潔な説明文。 */ abstract readonly description: string; /** * コンストラクター * * @param fs - 使用するファイルシステム操作の実装。 * デフォルトは `RealFS`(Deno 標準 API を使用する実体)を使用。 * 単体テスト時には、ここへモックオブジェクトを注入することで、 * 実際のファイルシステムを汚さずにロジックを検証できる。 * @example * // 本番用(通常はこちらを使用) * const cmd = new ReNumberCommand(); * @example * // テスト用(ディレクトリ内のファイル一覧を偽装する) * const mockFS: FileSystem = { * 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(mockFS); */ constructor(protected fs: FileSystem = RealFS) {} /** * コマンド固有のヘルプ情報(使用法、オプションの詳細など)を * 標準出力に表示する。 */ abstract showHelp(): void; /** * コマンドライン引数の配列を解析し、型 `T` のオブジェクトに変換する。 * * @param args サブコマンド名を除いた、解析対象の引数リスト * @returns 解析・構造化されたオプションオブジェクト * @protected */ protected abstract parseOptions(args: string[]): 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> { const subArgs = parseArgs(args, { boolean: ["help"], alias: { h: "help" }, }); if (args.length === 0 || subArgs.help) { this.showHelp(); return { success: true, code: 0 }; } const options = this.parseOptions(args); return await this.runImpl(options); } } // ================================================================== // SubCommand: renumber (連番リネーム) // ================================================================== /** * `renumber` コマンドで使用するパース済みオプション */ export interface ReNumberOptions { /** 出力ファイル名の先頭に付与する文字列 */ prefix: string; /** 連番の開始番号 */ startNo: number; /** リネーム実施フラグ(trueで実際にリネームをする) */ isFix: boolean; /** 最低桁数 */ minDigits: number; } interface RenameFileInfo { fullPath: string; baseName: string; ext: string; } interface RenamePlan { file: RenameFileInfo; newName: string; isNew: boolean; } /** * ディレクトリ内の画像ファイルを連番で一括リネームするコマンド */ export class ReNumberCommand extends BaseCommand<ReNumberOptions> { readonly name = "renumber"; readonly description = "画像ファイルを連番でリネームします。"; /** * ヘルプ情報を標準出力。 */ showHelp(): void { console.log(` Description: ${this.description} Command: ${this.name} Usage: deno run -A fuzzyutilsTS.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 パース済みのオプションオブジェクト */ protected 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.start ?? 1), minDigits: Number(args.d) || 0, isFix: Boolean(args.fix), }; } /** * コマンドのメインロジックを実行する。 * * @param options `parseOptions` によって解析されたオプションオブジェク * * @returns 実行結果(成功・失敗のステータスとメッセージ)を含むPromise * @throws {Error} ファイルシステム操作に失敗した場合 */ async runImpl(options: ReNumberOptions): Promise<CommandResult> { const targetFiles = await this.collectFiles(); if (targetFiles.length === 0) { return { success: true, code: 0, message: "対象となる画像ファイルが見つかりませんでした。", }; } console.log(`対象ファイル: ${targetFiles.length} 件`); const autoDigits = String(targetFiles.length + options.startNo - 1).length; const digits = Math.max(autoDigits, options.minDigits); const renamePlan = this.createRenamePlan( targetFiles, options.prefix, options.startNo, digits, ); let res = await this.validatePlan(renamePlan); if (!res.success) { return { success: false, code: 1, message: res.reason || "安全のため処理を中断しました。", error: res.error, }; } res = await this.executeRename(renamePlan, options.isFix); if (!res.success) { return { success: false, code: 1, message: res.reason || "実行エラーが発生しました。", error: res.error, }; } if (!options.isFix) { console.log( "\n【シミュレーション】" + " 実際に変更するには --fix を指定してください", ); } return { success: true, code: 0 }; } /** * 処理対象とする拡張子のリスト。 * 全て小文字、ドット付きで定義。 */ private static readonly TARGET_EXTS: readonly string[] = [ ".jpg", ".png", ".webp", ".bmp", ".jpeg", ".gif", ]; /** * カレントディレクトリから対象ファイルを収集し、自然順でソートし取得する。 * * @returns ソート済みのファイル情報(FileInfo)の配列 * * @remarks * - 拡張子の判定は大文字小文字を区別しない。 * - ソートは「自然順(数値順)」かつ * 「大文字小文字・全角半角を区別する」設定で行う。 */ private async collectFiles(): Promise<RenameFileInfo[]> { const files: RenameFileInfo[] = []; for await (const entry of this.fs.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: RenameFileInfo[], 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 - 検証対象となるリネーム計画の配列 * @returns 計画が安全で実行可能な場合は true、衝突が検知された場合は false * * @remarks * 以下の 2 点をチェックし、データの安全性を担保する: * * 1. **リネーム予定名同士の重複**: * 計画内で複数のファイルが同じ名前になろうとしていないかを確認する。 * 判定は大文字小文字を区別せずに行う。 * * 2. **外部ファイルとの衝突**: * リネーム先のパスに既にファイルが存在する場合、それが「今回移動して場所を * 空ける予定のファイル」であるかを確認する。 * 今回の対象外である(居座り続ける)ファイルと衝突する場合は、上書き事故を * 防ぐため不許可とする。 */ private async validatePlan( renamePlan: RenamePlan[], ): Promise<InternalResult<number>> { const originalCount = renamePlan.length; const newNamesSet = new Set<string>(); const oldNamesSet = new Set<string>(); for (const plan of renamePlan) { newNamesSet.add(plan.newName.toLowerCase()); oldNamesSet.add(plan.file.fullPath.toLowerCase()); } if (newNamesSet.size !== originalCount) { const lostCount = originalCount - newNamesSet.size; return { success: false, reason: "中止: ファイル名が重複しています。" + "\nこのまま実行すると、上書きにより " + `${lostCount} 個のファイルが消失します。`, }; } for (const plan of renamePlan) { if (!plan.isNew) continue; const lowerNewName = plan.newName.toLowerCase(); if (oldNamesSet.has(lowerNewName)) { continue; } if (await this.fs.exists(lowerNewName)) { return { success: false, reason: "中止: 既存の別ファイルと衝突します: " + `${lowerNewName}`, }; } } return { success: true }; } /** * リネーム計画に基づき、実際のファイル移動処理を実行する。 * * @param renamePlan - 作成されたリネーム計画の配列 * @param isFix - true の場合は実際にリネームを実行し、false の場合は表示のみ行う * @returns 全てのリネームが正常に完了した場合は true、途中で失敗した場合は false * * @remarks * - **玉突き衝突の動的回避**: * リネームによる移動の向き(前方または後方)を自動判定し、処理順序を最適化 * することで「移動先にまだ古いファイルが存在する」ことによる上書き消失や * エラーを防ぐ。 * * - **後方移動 (例: 1→2)**: * 配列を逆順(後ろ)から処理し、末尾から空き地を作る。 * * - **前方移動 (例: 2→1)**: * 配列を正順(前)から処理し、先頭から空き地を作る。 * * - **二段構えの安全性**: * 1. `validatePlan` による事前の全数・重複チェック。 * 2. `Deno.rename` 直前の `exists` による最終衝突確認。 * * - **エラーハンドリング**: * いずれかのリネームに失敗した場合、それ以上の被害(ファイル消失や不整合) * を防ぐため即座に処理を中断し `false` を返す。 */ private async executeRename( renamePlan: RenamePlan[], isFix: boolean, ): Promise<InternalResult<number>> { if (renamePlan.length === 0) return { success: true }; // 最初のファイルが「手前」に行くか「後ろ」に行くかを比較 // localeCompare で 新名 > 旧名 なら「後ろへ移動(逆順が必要)」と判断 const first = renamePlan[0]; const isMovingBackward = first.newName.localeCompare( first.file.fullPath, undefined, { numeric: true }, ) > 0; // 向きに合わせてインデックスの並びを作成 // 番号が増えるリネームなら後ろから(逆順) const indices = renamePlan.map((_, i) => i); if (isMovingBackward) { indices.reverse(); } for (const i of indices) { const { file, newName, isNew } = renamePlan[i]; const status = isNew ? "" : " (変更なし)"; const logPrefix = isFix ? "" : "Simulation:"; console.log(`${logPrefix} ${file.fullPath} -> ${newName}${status}`); if (!isNew || !isFix) continue; try { // 最終確認 if (await this.fs.exists(newName)) { return { success: false, reason: `移動先 "${newName}" が既に存在します。`, }; } await this.fs.rename(file.fullPath, newName); } catch (error) { return { success: false, reason: `${file.fullPath} のリネームに失敗しました。`, error: error instanceof Error ? error : new Error(String(error)), }; } } return { success: true }; } } // ================================================================== // メイン処理 // ================================================================== /** * ツール全体のライフサイクルとコマンド実行を管理するメインクラス。 * * 本プログラムはシンプルなコマンドラインプログラムなので、高度な * ハンドリングは必要ない。 */ class Main { private commands: Map<string, BaseCommand<unknown>> = new Map(); constructor() { const subCommands: BaseCommand<unknown>[] = [ new ReNumberCommand(), // ----------------------------------------------------------- // TODO: **** ここにサブコマンドを追記していく **** // ----------------------------------------------------------- ]; for (const cmd of subCommands) { this.registerCommand(cmd); } } /** * コマンドを内部レジストリに登録する。 * * @param cmd 登録するコマンドインスタンス */ private registerCommand(cmd: BaseCommand<unknown>): void { if (this.commands.has(cmd.name)) { console.warn(`Warning: Command "${cmd.name}" is already registered.`); } this.commands.set(cmd.name, cmd); } /** * ヘルプ情報を標準出力。 * * @returns 常に0(正常終了コード) */ private showHelp(): number { console.log( "Usage: deno run -A fuzzyutils.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; } try { const result = await command.run(remainingArgs); if (result.success) { if (result.message) { console.log(`${result.message}`); } } else { console.error(`[Error] ${result.message}`); if (result.error) console.debug(result.error.stack); } return result.code; } // 想定外のエラー(権限不足やディスクフルなど) catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error(`[Fatal Error] ${command.name}: ${msg}`); return 1; } } } /** * 直接実行された場合のみアプリケーションを起動 */ if (import.meta.main) { const exitCode = await new Main().exec(); Deno.exit(exitCode); }
登録:
コメント (Atom)