diff --git a/.changeset/improve-cli-output.md b/.changeset/improve-cli-output.md new file mode 100644 index 0000000..9f1e348 --- /dev/null +++ b/.changeset/improve-cli-output.md @@ -0,0 +1,12 @@ +--- +"@tktco/create-devenv": minor +--- + +CLI出力を改善 + +- すべてのファイル操作に上書き戦略を適用 +- .devenv.json は常に更新(設定管理ファイルとして特別扱い) +- セットアップ後にモジュール別説明を表示 +- 全スキップ時は「変更はありませんでした」と表示 +- ts-pattern で網羅的なパターンマッチング +- Zod スキーマで型安全性を向上 diff --git a/packages/create-devenv/package.json b/packages/create-devenv/package.json index 481de1d..c301dcf 100644 --- a/packages/create-devenv/package.json +++ b/packages/create-devenv/package.json @@ -28,6 +28,7 @@ "defu": "^6.1.4", "giget": "^2.0.0", "pathe": "^2.0.3", + "ts-pattern": "^5.9.0", "zod": "^4.1.13" }, "devDependencies": { diff --git a/packages/create-devenv/src/commands/init.ts b/packages/create-devenv/src/commands/init.ts index ec2be1f..416c075 100644 --- a/packages/create-devenv/src/commands/init.ts +++ b/packages/create-devenv/src/commands/init.ts @@ -1,11 +1,19 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync } from "node:fs"; import { defineCommand } from "citty"; import { consola } from "consola"; import { resolve } from "pathe"; -import { modules } from "../modules/index"; -import type { Answers } from "../modules/schemas"; +import { getModuleById, modules } from "../modules/index"; +import type { + Answers, + FileOperationResult, + OverwriteStrategy, +} from "../modules/schemas"; import { promptInit } from "../prompts/init"; -import { fetchTemplates } from "../utils/template"; +import { + fetchTemplates, + logResult, + writeFileWithStrategy, +} from "../utils/template"; // ビルド時に置換される定数 declare const __VERSION__: string; @@ -68,31 +76,39 @@ export const initCommand = defineCommand({ return; } - // テンプレート取得・適用 - await fetchTemplates({ + const effectiveStrategy: OverwriteStrategy = args.force + ? "overwrite" + : answers.overwriteStrategy; + + // テンプレート取得・適用(結果を収集) + const templateResults = await fetchTemplates({ targetDir, modules: answers.modules, - overwriteStrategy: args.force ? "overwrite" : answers.overwriteStrategy, + overwriteStrategy: effectiveStrategy, }); - // devcontainer.env.example を作成 + const allResults: FileOperationResult[] = [...templateResults]; + + // devcontainer.env.example を戦略に従って作成 if (answers.modules.includes("devcontainer")) { - createEnvExample(targetDir); + const envResult = await createEnvExample(targetDir, effectiveStrategy); + logResult(envResult); + allResults.push(envResult); } - // 設定ファイル生成 - createDevEnvConfig(targetDir, answers.modules); + // 設定ファイル生成(常に更新) + const configResult = await createDevEnvConfig(targetDir, answers.modules); + logResult(configResult); + allResults.push(configResult); consola.box("セットアップ完了!"); - consola.info("次のステップ:"); - consola.info(" 1. .devcontainer/devcontainer.env を作成"); - consola.info(" 2. code . で VS Code を開く"); - consola.info(" 3. DevContainer で再オープン"); + + // モジュール別の説明を表示 + displayModuleDescriptions(answers.modules, allResults); }, }); -function createEnvExample(targetDir: string): void { - const content = `# 環境変数サンプル +const ENV_EXAMPLE_CONTENT = `# 環境変数サンプル # このファイルを devcontainer.env にコピーして値を設定してください # GitHub Personal Access Token @@ -107,31 +123,68 @@ AWS_DEFAULT_REGION=ap-northeast-1 WAKATIME_API_KEY= `; - const devcontainerDir = resolve(targetDir, ".devcontainer"); - if (!existsSync(devcontainerDir)) { - mkdirSync(devcontainerDir, { recursive: true }); - } - - const examplePath = resolve( - targetDir, - ".devcontainer/devcontainer.env.example", - ); - writeFileSync(examplePath, content); - consola.success("作成: .devcontainer/devcontainer.env.example"); +async function createEnvExample( + targetDir: string, + strategy: OverwriteStrategy, +): Promise { + return writeFileWithStrategy({ + destPath: resolve(targetDir, ".devcontainer/devcontainer.env.example"), + content: ENV_EXAMPLE_CONTENT, + strategy, + relativePath: ".devcontainer/devcontainer.env.example", + }); } -function createDevEnvConfig(targetDir: string, modules: string[]): void { +/** + * 設定ファイル生成(常に更新 - 特別枠) + */ +async function createDevEnvConfig( + targetDir: string, + selectedModules: string[], +): Promise { const config = { version: "0.1.0", installedAt: new Date().toISOString(), - modules, + modules: selectedModules, source: { owner: "tktcorporation", repo: ".github", }, }; - const configPath = resolve(targetDir, ".devenv.json"); - writeFileSync(configPath, JSON.stringify(config, null, 2)); - consola.success("作成: .devenv.json"); + // .devenv.json は常に上書き(設定管理ファイルなので) + return writeFileWithStrategy({ + destPath: resolve(targetDir, ".devenv.json"), + content: JSON.stringify(config, null, 2), + strategy: "overwrite", + relativePath: ".devenv.json", + }); +} + +/** + * モジュール別の説明を表示 + */ +function displayModuleDescriptions( + selectedModules: string[], + fileResults: FileOperationResult[], +): void { + const hasChanges = fileResults.some( + (r) => + r.action === "copied" || + r.action === "created" || + r.action === "overwritten", + ); + + if (!hasChanges) { + consola.info("変更はありませんでした"); + return; + } + + consola.info("追加されたモジュール:"); + for (const moduleId of selectedModules) { + const mod = getModuleById(moduleId); + if (mod?.setupDescription) { + consola.info(` ${mod.name}: ${mod.setupDescription}`); + } + } } diff --git a/packages/create-devenv/src/modules/index.ts b/packages/create-devenv/src/modules/index.ts index 0c2b714..daf4e66 100644 --- a/packages/create-devenv/src/modules/index.ts +++ b/packages/create-devenv/src/modules/index.ts @@ -5,6 +5,8 @@ export const modules: TemplateModule[] = [ id: "devcontainer", name: "DevContainer 設定", description: "VS Code DevContainer、mise、Docker-in-Docker", + setupDescription: + "VS Code で DevContainer を開くと自動でセットアップされます", files: [".devcontainer"], excludeFiles: [".devcontainer/devcontainer.env"], }, @@ -12,24 +14,29 @@ export const modules: TemplateModule[] = [ id: "github-actions", name: "GitHub Actions", description: "issue-link、labeler ワークフロー", + setupDescription: "PR 作成時に自動でラベル付け、Issue リンクが行われます", files: [".github"], }, { id: "mcp", name: "MCP サーバー設定", description: "Context7、Playwright、Chrome DevTools", + setupDescription: "Claude Code で MCP サーバーが自動的に利用可能になります", files: [".mcp.json"], }, { id: "mise", name: "mise 設定", description: "Node.js、uv、Claude Code などのツール管理", + setupDescription: + "mise trust && mise install でツールがインストールされます", files: [".mise.toml"], }, { id: "claude", name: "Claude IDE 設定", description: "Claude Code のローカル設定", + setupDescription: "Claude Code のプロジェクト設定が適用されます", files: [".claude"], }, ]; diff --git a/packages/create-devenv/src/modules/schemas.ts b/packages/create-devenv/src/modules/schemas.ts index 70cb051..4b61c4a 100644 --- a/packages/create-devenv/src/modules/schemas.ts +++ b/packages/create-devenv/src/modules/schemas.ts @@ -1,9 +1,31 @@ import { z } from "zod"; +// 上書き戦略 +export const overwriteStrategySchema = z.enum(["overwrite", "skip", "prompt"]); +export type OverwriteStrategy = z.infer; + +// ファイル操作のアクション種別 +export const fileActionSchema = z.enum([ + "copied", // テンプレートからコピー(新規) + "created", // 生成されたコンテンツで作成(新規) + "overwritten", // 上書き + "skipped", // スキップ +]); +export type FileAction = z.infer; + +// ファイル操作結果 +export const fileOperationResultSchema = z.object({ + action: fileActionSchema, + path: z.string(), +}); +export type FileOperationResult = z.infer; + +// テンプレートモジュール export const moduleSchema = z.object({ id: z.string(), name: z.string(), description: z.string(), + setupDescription: z.string().optional(), // セットアップ後の説明 files: z.array(z.string()), excludeFiles: z.array(z.string()).optional(), }); @@ -27,7 +49,7 @@ export const answersSchema = z.object({ modules: z .array(z.string()) .min(1, "少なくとも1つのモジュールを選択してください"), - overwriteStrategy: z.enum(["overwrite", "skip", "prompt"]), + overwriteStrategy: overwriteStrategySchema, }); export type Answers = z.infer; diff --git a/packages/create-devenv/src/utils/__tests__/template.test.ts b/packages/create-devenv/src/utils/__tests__/template.test.ts index 04125b3..3597479 100644 --- a/packages/create-devenv/src/utils/__tests__/template.test.ts +++ b/packages/create-devenv/src/utils/__tests__/template.test.ts @@ -25,10 +25,15 @@ vi.mock("consola", () => ({ })); // モック後にインポート -const { copyFile, copyDirectory } = await import("../template"); +const { copyFile, copyDirectory, writeFileWithStrategy } = await import( + "../template" +); const { confirm } = await import("@inquirer/prompts"); const mockConfirm = vi.mocked(confirm); +// 型をインポート +import type { FileOperationResult } from "../../modules/schemas"; + describe("copyFile", () => { beforeEach(() => { vol.reset(); @@ -254,3 +259,136 @@ describe("copyDirectory", () => { expect(results[0].path).toBe("mydir/subdir/file.txt"); }); }); + +describe("writeFileWithStrategy", () => { + beforeEach(() => { + vol.reset(); + vi.clearAllMocks(); + }); + + describe("新規ファイル", () => { + it("常に作成する(skip戦略でも)", async () => { + vol.fromJSON({}); + + const result = await writeFileWithStrategy({ + destPath: "/dest/file.txt", + content: "new content", + strategy: "skip", + relativePath: "file.txt", + }); + + expect(result).toEqual({ + action: "created", + path: "file.txt", + }); + expect(vol.readFileSync("/dest/file.txt", "utf8")).toBe("new content"); + }); + + it("親ディレクトリが存在しない場合は作成する", async () => { + vol.fromJSON({}); + + await writeFileWithStrategy({ + destPath: "/dest/nested/dir/file.txt", + content: "new content", + strategy: "skip", + relativePath: "nested/dir/file.txt", + }); + + expect(vol.existsSync("/dest/nested/dir")).toBe(true); + expect(vol.readFileSync("/dest/nested/dir/file.txt", "utf8")).toBe( + "new content", + ); + }); + }); + + describe("既存ファイル - overwrite 戦略", () => { + it("上書きする", async () => { + vol.fromJSON({ + "/dest/file.txt": "old content", + }); + + const result = await writeFileWithStrategy({ + destPath: "/dest/file.txt", + content: "new content", + strategy: "overwrite", + relativePath: "file.txt", + }); + + expect(result).toEqual({ + action: "overwritten", + path: "file.txt", + }); + expect(vol.readFileSync("/dest/file.txt", "utf8")).toBe("new content"); + }); + }); + + describe("既存ファイル - skip 戦略", () => { + it("スキップする(書き込まない)", async () => { + vol.fromJSON({ + "/dest/file.txt": "old content", + }); + + const result = await writeFileWithStrategy({ + destPath: "/dest/file.txt", + content: "new content", + strategy: "skip", + relativePath: "file.txt", + }); + + expect(result).toEqual({ + action: "skipped", + path: "file.txt", + }); + // 元のファイルが保持されている + expect(vol.readFileSync("/dest/file.txt", "utf8")).toBe("old content"); + }); + }); + + describe("既存ファイル - prompt 戦略", () => { + it("ユーザーが Yes の場合は上書きする", async () => { + vol.fromJSON({ + "/dest/file.txt": "old content", + }); + + mockConfirm.mockResolvedValueOnce(true); + + const result = await writeFileWithStrategy({ + destPath: "/dest/file.txt", + content: "new content", + strategy: "prompt", + relativePath: "file.txt", + }); + + expect(result).toEqual({ + action: "overwritten", + path: "file.txt", + }); + expect(vol.readFileSync("/dest/file.txt", "utf8")).toBe("new content"); + expect(mockConfirm).toHaveBeenCalledWith({ + message: "file.txt は既に存在します。上書きしますか?", + default: false, + }); + }); + + it("ユーザーが No の場合はスキップする", async () => { + vol.fromJSON({ + "/dest/file.txt": "old content", + }); + + mockConfirm.mockResolvedValueOnce(false); + + const result = await writeFileWithStrategy({ + destPath: "/dest/file.txt", + content: "new content", + strategy: "prompt", + relativePath: "file.txt", + }); + + expect(result).toEqual({ + action: "skipped", + path: "file.txt", + }); + expect(vol.readFileSync("/dest/file.txt", "utf8")).toBe("old content"); + }); + }); +}); diff --git a/packages/create-devenv/src/utils/template.ts b/packages/create-devenv/src/utils/template.ts index 3a5e49a..f357401 100644 --- a/packages/create-devenv/src/utils/template.ts +++ b/packages/create-devenv/src/utils/template.ts @@ -5,21 +5,23 @@ import { readdirSync, rmSync, statSync, + writeFileSync, } from "node:fs"; import { confirm } from "@inquirer/prompts"; import { consola } from "consola"; import { downloadTemplate } from "giget"; import { join } from "pathe"; +import { match } from "ts-pattern"; import { getModuleById } from "../modules/index"; +import type { + FileOperationResult, + OverwriteStrategy, +} from "../modules/schemas"; const TEMPLATE_SOURCE = "gh:tktcorporation/.github"; -export type OverwriteStrategy = "overwrite" | "skip" | "prompt"; - -export interface CopyResult { - action: "copied" | "skipped" | "overwritten"; - path: string; -} +// 後方互換性のためのエイリアス +export type CopyResult = FileOperationResult; export interface DownloadOptions { targetDir: string; @@ -28,8 +30,60 @@ export interface DownloadOptions { overwriteStrategy: OverwriteStrategy; } -export async function fetchTemplates(options: DownloadOptions): Promise { +export interface WriteFileOptions { + destPath: string; + content: string; + strategy: OverwriteStrategy; + relativePath: string; +} + +/** + * 上書き戦略に従ってファイルを書き込む + */ +export async function writeFileWithStrategy( + options: WriteFileOptions, +): Promise { + const { destPath, content, strategy, relativePath } = options; + const destExists = existsSync(destPath); + + // ファイルが存在しない場合は常に作成 + if (!destExists) { + const destDir = join(destPath, ".."); + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }); + } + writeFileSync(destPath, content); + return { action: "created", path: relativePath }; + } + + // 既存ファイルの処理 - ts-pattern で網羅的にマッチ + return match(strategy) + .with("overwrite", () => { + writeFileSync(destPath, content); + return { action: "overwritten" as const, path: relativePath }; + }) + .with("skip", () => { + return { action: "skipped" as const, path: relativePath }; + }) + .with("prompt", async () => { + const shouldOverwrite = await confirm({ + message: `${relativePath} は既に存在します。上書きしますか?`, + default: false, + }); + if (shouldOverwrite) { + writeFileSync(destPath, content); + return { action: "overwritten" as const, path: relativePath }; + } + return { action: "skipped" as const, path: relativePath }; + }) + .exhaustive(); +} + +export async function fetchTemplates( + options: DownloadOptions, +): Promise { const { targetDir, modules, excludeFiles = [], overwriteStrategy } = options; + const allResults: FileOperationResult[] = []; // 一時ディレクトリにテンプレートをダウンロード const tempDir = join(targetDir, ".devenv-temp"); @@ -79,6 +133,7 @@ export async function fetchTemplates(options: DownloadOptions): Promise { pattern, ); logResults(results, pattern); + allResults.push(...results); } else { const result = await copyFile( srcPath, @@ -87,6 +142,7 @@ export async function fetchTemplates(options: DownloadOptions): Promise { pattern, ); logResult(result); + allResults.push(result); } } } @@ -96,6 +152,8 @@ export async function fetchTemplates(options: DownloadOptions): Promise { rmSync(tempDir, { recursive: true, force: true }); } } + + return allResults; } export async function copyFile( @@ -183,26 +241,23 @@ export async function copyDirectory( return results; } -function logResult(result: CopyResult): void { - switch (result.action) { - case "copied": - consola.success(`コピー: ${result.path}`); - break; - case "overwritten": - consola.success(`上書き: ${result.path}`); - break; - case "skipped": - consola.info(`スキップ: ${result.path}`); - break; - } +export function logResult(result: FileOperationResult): void { + match(result.action) + .with("copied", () => consola.success(`コピー: ${result.path}`)) + .with("created", () => consola.success(`作成: ${result.path}`)) + .with("overwritten", () => consola.success(`上書き: ${result.path}`)) + .with("skipped", () => consola.info(`スキップ: ${result.path}`)) + .exhaustive(); } -function logResults(results: CopyResult[], prefix: string): void { +function logResults(results: FileOperationResult[], prefix: string): void { const copied = results.filter((r) => r.action === "copied").length; + const created = results.filter((r) => r.action === "created").length; const overwritten = results.filter((r) => r.action === "overwritten").length; const skipped = results.filter((r) => r.action === "skipped").length; if (copied > 0) consola.success(`コピー: ${prefix}/ (${copied} files)`); + if (created > 0) consola.success(`作成: ${prefix}/ (${created} files)`); if (overwritten > 0) consola.success(`上書き: ${prefix}/ (${overwritten} files)`); if (skipped > 0) consola.info(`スキップ: ${prefix}/ (${skipped} files)`); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9320b71..c34c577 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: pathe: specifier: ^2.0.3 version: 2.0.3 + ts-pattern: + specifier: ^5.9.0 + version: 5.9.0 zod: specifier: ^4.1.13 version: 4.1.13 @@ -1752,6 +1755,9 @@ packages: peerDependencies: tslib: '2' + ts-pattern@5.9.0: + resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3493,6 +3499,8 @@ snapshots: dependencies: tslib: 2.8.1 + ts-pattern@5.9.0: {} + tslib@2.8.1: {} typescript@5.9.3: {}