diff --git a/.changeset/add-untracked-detection.md b/.changeset/add-untracked-detection.md new file mode 100644 index 0000000..7439827 --- /dev/null +++ b/.changeset/add-untracked-detection.md @@ -0,0 +1,21 @@ +--- +"@tktco/create-devenv": minor +--- + +push コマンドにホワイトリスト外ファイル検知機能を追加し、モジュール定義を外部化 + +### ホワイトリスト外ファイル検知 +- push 時にホワイトリスト(patterns)に含まれていないファイルを検出 +- モジュールごとにグループ化して選択UI を表示 +- 選択したファイルを modules.jsonc に自動追加(PR に含まれる) +- gitignore されているファイルは自動で除外 + +### モジュール定義の外部化 +- モジュール定義をコードから `.devenv/modules.jsonc` に外部化 +- テンプレートリポジトリの modules.jsonc から動的に読み込み +- `customPatterns` を廃止し modules.jsonc に統合 + +### ディレクトリベースのモジュール設計 +- モジュール ID をディレクトリパスベースに変更(例: `.devcontainer`, `.github`, `.`) +- ファイルパスから即座にモジュール ID を導出可能に +- モジュール間のファイル重複を構造的に防止 diff --git a/.devenv/modules.jsonc b/.devenv/modules.jsonc new file mode 100644 index 0000000..58dc5c0 --- /dev/null +++ b/.devenv/modules.jsonc @@ -0,0 +1,53 @@ +{ + "$schema": "./modules.schema.json", + "modules": [ + { + // ルート直下のファイル(ディレクトリに属さないファイル) + "id": ".", + "name": "ルート設定", + "description": "MCP、mise などのルート設定ファイル", + "setupDescription": "プロジェクトルートの設定ファイルが適用されます", + "patterns": [ + ".mcp.json", + ".mise.toml" + ] + }, + { + "id": ".devcontainer", + "name": "DevContainer", + "description": "VS Code DevContainer、Docker-in-Docker", + "setupDescription": "VS Code で DevContainer を開くと自動でセットアップされます", + // ホワイトリスト形式: テンプレートとして配布するファイルのみを明示 + "patterns": [ + ".devcontainer/devcontainer.json", + ".devcontainer/.gitignore", + ".devcontainer/setup-*.sh", + ".devcontainer/test-*.sh", + ".devcontainer/.env.devcontainer.example" + // 除外: devcontainer.env(秘密情報) + ] + }, + { + "id": ".github", + "name": "GitHub", + "description": "GitHub Actions、labeler ワークフロー", + "setupDescription": "PR 作成時に自動でラベル付け、Issue リンクが行われます", + "patterns": [ + ".github/workflows/issue-link.yml", + ".github/workflows/label.yml", + ".github/labeler.yml" + // 除外: ci.yml, release.yml(リポジトリ固有のCI/CD設定) + ] + }, + { + "id": ".claude", + "name": "Claude", + "description": "Claude Code のプロジェクト共通設定", + "setupDescription": "Claude Code のプロジェクト設定が適用されます", + "patterns": [ + ".claude/settings.json" + // 除外: settings.local.json(個人設定) + ] + } + ] +} diff --git a/packages/create-devenv/package.json b/packages/create-devenv/package.json index d829d8c..d1a44b7 100644 --- a/packages/create-devenv/package.json +++ b/packages/create-devenv/package.json @@ -30,6 +30,7 @@ "diff": "^8.0.2", "giget": "^2.0.0", "ignore": "^7.0.5", + "jsonc-parser": "^3.3.1", "pathe": "^2.0.3", "tinyglobby": "^0.2.15", "ts-pattern": "^5.9.0", diff --git a/packages/create-devenv/src/commands/init.ts b/packages/create-devenv/src/commands/init.ts index 416c075..cda8bd7 100644 --- a/packages/create-devenv/src/commands/init.ts +++ b/packages/create-devenv/src/commands/init.ts @@ -2,14 +2,21 @@ import { existsSync, mkdirSync } from "node:fs"; import { defineCommand } from "citty"; import { consola } from "consola"; import { resolve } from "pathe"; -import { getModuleById, modules } from "../modules/index"; +import { + defaultModules, + getModuleById, + loadModulesFile, + modulesFileExists, +} from "../modules/index"; import type { Answers, FileOperationResult, OverwriteStrategy, + TemplateModule, } from "../modules/schemas"; import { promptInit } from "../prompts/init"; import { + downloadTemplateToTemp, fetchTemplates, logResult, writeFileWithStrategy, @@ -59,52 +66,70 @@ export const initCommand = defineCommand({ mkdirSync(targetDir, { recursive: true }); } - // プロンプトまたは自動選択 - let answers: Answers; - if (args.yes) { - answers = { - modules: modules.map((m) => m.id), - overwriteStrategy: "overwrite", - }; - consola.info("すべてのモジュールを自動選択しました"); - } else { - answers = await promptInit(); - } - - if (answers.modules.length === 0) { - consola.warn("テンプレートが選択されませんでした"); - return; + // テンプレートをダウンロード + const { templateDir, cleanup } = await downloadTemplateToTemp(targetDir); + + try { + // modules.jsonc からモジュールを読み込み(なければデフォルト使用) + let moduleList: TemplateModule[]; + if (modulesFileExists(templateDir)) { + const { modules: loadedModules } = await loadModulesFile(templateDir); + moduleList = loadedModules; + } else { + moduleList = defaultModules; + } + + // プロンプトまたは自動選択 + let answers: Answers; + if (args.yes) { + answers = { + modules: moduleList.map((m) => m.id), + overwriteStrategy: "overwrite", + }; + consola.info("すべてのモジュールを自動選択しました"); + } else { + answers = await promptInit(moduleList); + } + + if (answers.modules.length === 0) { + consola.warn("テンプレートが選択されませんでした"); + return; + } + + const effectiveStrategy: OverwriteStrategy = args.force + ? "overwrite" + : answers.overwriteStrategy; + + // テンプレート取得・適用(結果を収集) + const templateResults = await fetchTemplates({ + targetDir, + modules: answers.modules, + overwriteStrategy: effectiveStrategy, + moduleList, + templateDir, + }); + + const allResults: FileOperationResult[] = [...templateResults]; + + // devcontainer.env.example を戦略に従って作成 + if (answers.modules.includes("devcontainer")) { + const envResult = await createEnvExample(targetDir, effectiveStrategy); + logResult(envResult); + allResults.push(envResult); + } + + // 設定ファイル生成(常に更新) + const configResult = await createDevEnvConfig(targetDir, answers.modules); + logResult(configResult); + allResults.push(configResult); + + consola.box("セットアップ完了!"); + + // モジュール別の説明を表示 + displayModuleDescriptions(answers.modules, allResults, moduleList); + } finally { + cleanup(); } - - const effectiveStrategy: OverwriteStrategy = args.force - ? "overwrite" - : answers.overwriteStrategy; - - // テンプレート取得・適用(結果を収集) - const templateResults = await fetchTemplates({ - targetDir, - modules: answers.modules, - overwriteStrategy: effectiveStrategy, - }); - - const allResults: FileOperationResult[] = [...templateResults]; - - // devcontainer.env.example を戦略に従って作成 - if (answers.modules.includes("devcontainer")) { - const envResult = await createEnvExample(targetDir, effectiveStrategy); - logResult(envResult); - allResults.push(envResult); - } - - // 設定ファイル生成(常に更新) - const configResult = await createDevEnvConfig(targetDir, answers.modules); - logResult(configResult); - allResults.push(configResult); - - consola.box("セットアップ完了!"); - - // モジュール別の説明を表示 - displayModuleDescriptions(answers.modules, allResults); }, }); @@ -167,6 +192,7 @@ async function createDevEnvConfig( function displayModuleDescriptions( selectedModules: string[], fileResults: FileOperationResult[], + moduleList: TemplateModule[], ): void { const hasChanges = fileResults.some( (r) => @@ -182,7 +208,7 @@ function displayModuleDescriptions( consola.info("追加されたモジュール:"); for (const moduleId of selectedModules) { - const mod = getModuleById(moduleId); + const mod = getModuleById(moduleId, moduleList); if (mod?.setupDescription) { consola.info(` ${mod.name}: ${mod.setupDescription}`); } diff --git a/packages/create-devenv/src/commands/push.ts b/packages/create-devenv/src/commands/push.ts index 4766d83..e6af0b3 100644 --- a/packages/create-devenv/src/commands/push.ts +++ b/packages/create-devenv/src/commands/push.ts @@ -4,9 +4,16 @@ import { defineCommand } from "citty"; import consola from "consola"; import { downloadTemplate } from "giget"; import { join, resolve } from "pathe"; -import type { DevEnvConfig } from "../modules/schemas"; +import { + addPatternToModulesFile, + defaultModules, + loadModulesFile, + modulesFileExists, +} from "../modules"; +import type { DevEnvConfig, TemplateModule } from "../modules/schemas"; import { configSchema } from "../modules/schemas"; import { + promptAddUntrackedFiles, promptGitHubToken, promptPrBody, promptPrTitle, @@ -15,8 +22,10 @@ import { } from "../prompts/push"; import { detectDiff, formatDiff, getPushableFiles } from "../utils/diff"; import { createPullRequest, getGitHubToken } from "../utils/github"; +import { detectUntrackedFiles } from "../utils/untracked"; const TEMPLATE_SOURCE = "gh:tktcorporation/.github"; +const MODULES_FILE_PATH = ".devenv/modules.jsonc"; export const pushCommand = defineCommand({ meta: { @@ -96,6 +105,56 @@ export const pushCommand = defineCommand({ force: true, }); + // modules.jsonc を読み込み + let moduleList: TemplateModule[]; + let modulesRawContent: string | undefined; + + if (modulesFileExists(templateDir)) { + const loaded = await loadModulesFile(templateDir); + moduleList = loaded.modules; + modulesRawContent = loaded.rawContent; + } else { + moduleList = defaultModules; + } + + // ホワイトリスト外ファイルの検出と追加確認 + let updatedModulesContent: string | undefined; + + if (!args.force && modulesRawContent) { + const untrackedByFolder = await detectUntrackedFiles({ + targetDir, + moduleIds: config.modules, + config, + moduleList, + }); + + if (untrackedByFolder.length > 0) { + const selectedFiles = + await promptAddUntrackedFiles(untrackedByFolder); + + if (selectedFiles.length > 0) { + // modules.jsonc にパターンを追加(メモリ上) + let currentContent = modulesRawContent; + for (const { moduleId, files } of selectedFiles) { + currentContent = addPatternToModulesFile( + currentContent, + moduleId, + files, + ); + } + updatedModulesContent = currentContent; + + const totalAdded = selectedFiles.reduce( + (sum, s) => sum + s.files.length, + 0, + ); + consola.info( + `${totalAdded} 件のパターンを modules.jsonc に追加します(PR に含まれます)`, + ); + } + } + } + consola.start("差分を検出中..."); // 差分検出 @@ -104,12 +163,13 @@ export const pushCommand = defineCommand({ templateDir, moduleIds: config.modules, config, + moduleList, }); // push 対象ファイルを取得 let pushableFiles = getPushableFiles(diff); - if (pushableFiles.length === 0) { + if (pushableFiles.length === 0 && !updatedModulesContent) { consola.info("push するファイルがありません。"); console.log(); console.log(formatDiff(diff, false)); @@ -121,6 +181,9 @@ export const pushCommand = defineCommand({ consola.info("[ドライラン] 以下のファイルが PR として送信されます:"); console.log(); console.log(formatDiff(diff, true)); + if (updatedModulesContent) { + console.log(` [+] ${MODULES_FILE_PATH} (パターン追加)`); + } console.log(); consola.info("[ドライラン] 実際の PR は作成されませんでした。"); return; @@ -129,7 +192,7 @@ export const pushCommand = defineCommand({ // ファイル選択(デフォルト動作) if (args.interactive && !args.force) { pushableFiles = await promptSelectFilesWithDiff(pushableFiles); - if (pushableFiles.length === 0) { + if (pushableFiles.length === 0 && !updatedModulesContent) { consola.info("ファイルが選択されませんでした。キャンセルします。"); return; } @@ -160,6 +223,14 @@ export const pushCommand = defineCommand({ content: f.localContent || "", })); + // modules.jsonc の変更があれば追加 + if (updatedModulesContent) { + files.push({ + path: MODULES_FILE_PATH, + content: updatedModulesContent, + }); + } + consola.start("PR を作成中..."); // PR 作成 diff --git a/packages/create-devenv/src/modules/index.ts b/packages/create-devenv/src/modules/index.ts index 83714f9..d2fce73 100644 --- a/packages/create-devenv/src/modules/index.ts +++ b/packages/create-devenv/src/modules/index.ts @@ -1,76 +1,90 @@ import type { TemplateModule } from "./schemas"; -export const modules: TemplateModule[] = [ +// Re-export loader functions +export { + addPatternToModulesFile, + getModulesFilePath, + loadModulesFile, + modulesFileExists, + saveModulesFile, +} from "./loader"; + +/** + * デフォルトモジュール(modules.jsonc がない場合のフォールバック) + * モジュール ID = ディレクトリパス(ルートは ".") + */ +export const defaultModules: TemplateModule[] = [ + { + id: ".", + name: "ルート設定", + description: "MCP、mise などのルート設定ファイル", + setupDescription: "プロジェクトルートの設定ファイルが適用されます", + patterns: [".mcp.json", ".mise.toml"], + }, { - id: "devcontainer", - name: "DevContainer 設定", - description: "VS Code DevContainer、mise、Docker-in-Docker", + id: ".devcontainer", + name: "DevContainer", + description: "VS Code DevContainer、Docker-in-Docker", setupDescription: "VS Code で DevContainer を開くと自動でセットアップされます", - // ホワイトリスト形式: テンプレートとして配布するファイルのみを明示 patterns: [ ".devcontainer/devcontainer.json", ".devcontainer/.gitignore", ".devcontainer/setup-*.sh", ".devcontainer/test-*.sh", - // 除外: devcontainer.env(秘密情報) + ".devcontainer/.env.devcontainer.example", ], }, { - id: "github-actions", - name: "GitHub Actions", - description: "issue-link、labeler ワークフロー", + id: ".github", + name: "GitHub", + description: "GitHub Actions、labeler ワークフロー", setupDescription: "PR 作成時に自動でラベル付け、Issue リンクが行われます", patterns: [ ".github/workflows/issue-link.yml", ".github/workflows/label.yml", ".github/labeler.yml", - // 除外: ci.yml, release.yml(リポジトリ固有のCI/CD設定) ], }, { - id: "mcp", - name: "MCP サーバー設定", - description: "Context7、Playwright、Chrome DevTools", - setupDescription: "Claude Code で MCP サーバーが自動的に利用可能になります", - patterns: [".mcp.json"], - }, - { - id: "mise", - name: "mise 設定", - description: "Node.js、uv、Claude Code などのツール管理", - setupDescription: - "mise trust && mise install でツールがインストールされます", - patterns: [".mise.toml"], - }, - { - id: "claude", - name: "Claude IDE 設定", + id: ".claude", + name: "Claude", description: "Claude Code のプロジェクト共通設定", setupDescription: "Claude Code のプロジェクト設定が適用されます", - patterns: [ - ".claude/settings.json", - // 除外: settings.local.json(個人設定) - ], + patterns: [".claude/settings.json"], }, ]; -export function getModuleById(id: string): TemplateModule | undefined { - return modules.find((m) => m.id === id); +// 後方互換性のためのエイリアス +export const modules = defaultModules; + +/** + * モジュールリストから ID でモジュールを取得 + */ +export function getModuleById( + id: string, + moduleList: TemplateModule[] = defaultModules, +): TemplateModule | undefined { + return moduleList.find((m) => m.id === id); } /** * 全モジュールのパターンを取得 */ -export function getAllPatterns(): string[] { - return modules.flatMap((m) => m.patterns); +export function getAllPatterns( + moduleList: TemplateModule[] = defaultModules, +): string[] { + return moduleList.flatMap((m) => m.patterns); } /** * 指定モジュールIDのパターンを取得 */ -export function getPatternsByModuleIds(moduleIds: string[]): string[] { - return modules +export function getPatternsByModuleIds( + moduleIds: string[], + moduleList: TemplateModule[] = defaultModules, +): string[] { + return moduleList .filter((m) => moduleIds.includes(m.id)) .flatMap((m) => m.patterns); } diff --git a/packages/create-devenv/src/modules/loader.ts b/packages/create-devenv/src/modules/loader.ts new file mode 100644 index 0000000..7f4884f --- /dev/null +++ b/packages/create-devenv/src/modules/loader.ts @@ -0,0 +1,114 @@ +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { applyEdits, modify, parse } from "jsonc-parser"; +import { join } from "pathe"; +import { z } from "zod"; +import type { TemplateModule } from "./schemas"; +import { moduleSchema } from "./schemas"; + +const MODULES_FILE = ".devenv/modules.jsonc"; + +/** + * modules.jsonc のスキーマ + */ +const modulesFileSchema = z.object({ + $schema: z.string().optional(), + modules: z.array(moduleSchema), +}); + +export type ModulesFile = z.infer; + +/** + * modules.jsonc ファイルを読み込み + */ +export async function loadModulesFile( + baseDir: string, +): Promise<{ modules: TemplateModule[]; rawContent: string }> { + const filePath = join(baseDir, MODULES_FILE); + + if (!existsSync(filePath)) { + throw new Error(`${MODULES_FILE} が見つかりません: ${filePath}`); + } + + const content = await readFile(filePath, "utf-8"); + const parsed = parse(content); + const validated = modulesFileSchema.parse(parsed); + + return { + modules: validated.modules, + rawContent: content, + }; +} + +/** + * モジュール ID からモジュールを取得 + */ +export function getModuleByIdFromList( + modules: TemplateModule[], + id: string, +): TemplateModule | undefined { + return modules.find((m) => m.id === id); +} + +/** + * modules.jsonc にパターンを追加(コメントを保持) + * @returns 更新後の JSONC 文字列 + */ +export function addPatternToModulesFile( + rawContent: string, + moduleId: string, + patterns: string[], +): string { + // 現在のモジュールリストを取得 + const parsed = parse(rawContent) as ModulesFile; + const moduleIndex = parsed.modules.findIndex((m) => m.id === moduleId); + + if (moduleIndex === -1) { + throw new Error(`モジュール ${moduleId} が見つかりません`); + } + + // 既存のパターンと新規パターンをマージ + const existingPatterns = parsed.modules[moduleIndex].patterns; + const newPatterns = patterns.filter((p) => !existingPatterns.includes(p)); + + if (newPatterns.length === 0) { + return rawContent; + } + + const updatedPatterns = [...existingPatterns, ...newPatterns]; + + // JSONC を編集(コメントを保持) + const edits = modify( + rawContent, + ["modules", moduleIndex, "patterns"], + updatedPatterns, + { formattingOptions: { tabSize: 2, insertSpaces: true } }, + ); + + return applyEdits(rawContent, edits); +} + +/** + * modules.jsonc を保存 + */ +export async function saveModulesFile( + baseDir: string, + content: string, +): Promise { + const filePath = join(baseDir, MODULES_FILE); + await writeFile(filePath, content); +} + +/** + * モジュールファイルのパスを取得 + */ +export function getModulesFilePath(baseDir: string): string { + return join(baseDir, MODULES_FILE); +} + +/** + * modules.jsonc が存在するか確認 + */ +export function modulesFileExists(baseDir: string): boolean { + return existsSync(join(baseDir, MODULES_FILE)); +} diff --git a/packages/create-devenv/src/modules/schemas.ts b/packages/create-devenv/src/modules/schemas.ts index dec6739..69cb8c3 100644 --- a/packages/create-devenv/src/modules/schemas.ts +++ b/packages/create-devenv/src/modules/schemas.ts @@ -31,7 +31,7 @@ export const moduleSchema = z.object({ export type TemplateModule = z.infer; -// DevEnvConfig(拡張: カスタムパターン対応) +// DevEnvConfig export const configSchema = z.object({ version: z.string(), installedAt: z.string().datetime({ offset: true }), @@ -41,8 +41,6 @@ export const configSchema = z.object({ repo: z.string(), ref: z.string().optional(), }), - // 追加のカスタマイズ - customPatterns: z.record(z.string(), z.array(z.string())).optional(), // モジュールIDごとの追加パターン excludePatterns: z.array(z.string()).optional(), // グローバル除外パターン }); diff --git a/packages/create-devenv/src/prompts/init.ts b/packages/create-devenv/src/prompts/init.ts index 2d84c27..55d610b 100644 --- a/packages/create-devenv/src/prompts/init.ts +++ b/packages/create-devenv/src/prompts/init.ts @@ -1,11 +1,17 @@ import { checkbox, select } from "@inquirer/prompts"; -import { modules } from "../modules/index"; -import { type Answers, answersSchema } from "../modules/schemas"; +import { defaultModules } from "../modules/index"; +import { + type Answers, + answersSchema, + type TemplateModule, +} from "../modules/schemas"; -export async function promptInit(): Promise { +export async function promptInit( + moduleList: TemplateModule[] = defaultModules, +): Promise { const selectedModules = await checkbox({ message: "適用するテンプレートを選択してください", - choices: modules.map((m) => ({ + choices: moduleList.map((m) => ({ name: `${m.name} - ${m.description}`, value: m.id, checked: true, diff --git a/packages/create-devenv/src/prompts/push.ts b/packages/create-devenv/src/prompts/push.ts index e7c9652..f68f5a0 100644 --- a/packages/create-devenv/src/prompts/push.ts +++ b/packages/create-devenv/src/prompts/push.ts @@ -5,6 +5,12 @@ import { formatDiff, generateUnifiedDiff, } from "../utils/diff"; +import type { UntrackedFile, UntrackedFilesByFolder } from "../utils/untracked"; + +export interface SelectedUntrackedFiles { + moduleId: string; + files: string[]; +} /** * push 実行前の確認プロンプト @@ -114,3 +120,81 @@ export async function promptSelectFilesWithDiff( choices, }); } + +/** + * ホワイトリスト外ファイルの追加確認プロンプト + * 2ステップUI: フォルダ選択 → ファイル選択 + */ +export async function promptAddUntrackedFiles( + untrackedByFolder: UntrackedFilesByFolder[], +): Promise { + if (untrackedByFolder.length === 0) { + return []; + } + + // サマリー表示 + console.log(); + console.log("=== ホワイトリスト外のファイルが見つかりました ==="); + console.log(); + for (const { folder, files } of untrackedByFolder) { + console.log(` ${folder}: ${files.length}件`); + } + console.log(); + + // Step 1: 詳細を確認するフォルダを選択 + const folderChoices = untrackedByFolder.map(({ folder, files }) => ({ + name: `${folder} (${files.length}件)`, + value: folder, + checked: true, // デフォルトで全選択 + })); + + const selectedFolders = await checkbox({ + message: "詳細を確認するフォルダを選択してください", + choices: folderChoices, + }); + + if (selectedFolders.length === 0) { + return []; + } + + // 選択されたフォルダのファイルのみを抽出 + const selectedFolderData = untrackedByFolder.filter((f) => + selectedFolders.includes(f.folder), + ); + + // Step 2: ファイルを選択(フォルダごとにグループ化して一括表示) + const allFileChoices: { name: string; value: UntrackedFile }[] = []; + + for (const { files } of selectedFolderData) { + for (const file of files) { + allFileChoices.push({ + name: file.path, + value: file, + }); + } + } + + const selectedFiles = await checkbox({ + message: "push 対象に追加するファイルを選択してください", + choices: allFileChoices, + }); + + if (selectedFiles.length === 0) { + return []; + } + + // moduleId ごとにグループ化 + const byModuleId = new Map(); + for (const file of selectedFiles) { + const existing = byModuleId.get(file.moduleId) || []; + existing.push(file.path); + byModuleId.set(file.moduleId, existing); + } + + const result: SelectedUntrackedFiles[] = []; + for (const [moduleId, files] of byModuleId) { + result.push({ moduleId, files }); + } + + return result; +} diff --git a/packages/create-devenv/src/utils/config.ts b/packages/create-devenv/src/utils/config.ts new file mode 100644 index 0000000..573f818 --- /dev/null +++ b/packages/create-devenv/src/utils/config.ts @@ -0,0 +1,25 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "pathe"; +import type { DevEnvConfig } from "../modules/schemas"; +import { configSchema } from "../modules/schemas"; + +/** + * .devenv.json を読み込み + */ +export async function loadConfig(targetDir: string): Promise { + const configPath = join(targetDir, ".devenv.json"); + const content = await readFile(configPath, "utf-8"); + const data = JSON.parse(content); + return configSchema.parse(data); +} + +/** + * .devenv.json を保存 + */ +export async function saveConfig( + targetDir: string, + config: DevEnvConfig, +): Promise { + const configPath = join(targetDir, ".devenv.json"); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); +} diff --git a/packages/create-devenv/src/utils/diff.ts b/packages/create-devenv/src/utils/diff.ts index 63bd993..3066a05 100644 --- a/packages/create-devenv/src/utils/diff.ts +++ b/packages/create-devenv/src/utils/diff.ts @@ -3,12 +3,13 @@ import { readFile } from "node:fs/promises"; import consola from "consola"; import { createPatch } from "diff"; import { join } from "pathe"; -import { getModuleById } from "../modules"; +import { defaultModules, getModuleById } from "../modules"; import type { DevEnvConfig, DiffResult, DiffType, FileDiff, + TemplateModule, } from "../modules/schemas"; import { filterByGitignore, loadMergedGitignore } from "./gitignore"; import { getEffectivePatterns, resolvePatterns } from "./patterns"; @@ -18,13 +19,20 @@ export interface DiffOptions { templateDir: string; moduleIds: string[]; config?: DevEnvConfig; + moduleList?: TemplateModule[]; } /** * ローカルとテンプレート間の差分を検出 */ export async function detectDiff(options: DiffOptions): Promise { - const { targetDir, templateDir, moduleIds, config } = options; + const { + targetDir, + templateDir, + moduleIds, + config, + moduleList = defaultModules, + } = options; const files: FileDiff[] = []; let added = 0; @@ -37,13 +45,13 @@ export async function detectDiff(options: DiffOptions): Promise { const gitignore = await loadMergedGitignore([targetDir, templateDir]); for (const moduleId of moduleIds) { - const mod = getModuleById(moduleId); + const mod = getModuleById(moduleId, moduleList); if (!mod) { consola.warn(`モジュール "${moduleId}" が見つかりません`); continue; } - // 有効なパターンを取得(カスタムパターン考慮) + // 有効なパターンを取得 const patterns = getEffectivePatterns(moduleId, mod.patterns, config); // テンプレート側のファイル一覧を取得し、gitignore でフィルタリング diff --git a/packages/create-devenv/src/utils/patterns.ts b/packages/create-devenv/src/utils/patterns.ts index 2b5d651..8df4533 100644 --- a/packages/create-devenv/src/utils/patterns.ts +++ b/packages/create-devenv/src/utils/patterns.ts @@ -45,15 +45,12 @@ function isGlobPattern(pattern: string): boolean { } /** - * モジュールの全パターンを結合(customPatterns 考慮) + * パターン配列を結合(重複排除) */ -export function mergePatterns( - modulePatterns: string[], - customPatterns?: string[], -): string[] { - const merged = [...modulePatterns]; - if (customPatterns) { - merged.push(...customPatterns); +export function mergePatterns(...patternArrays: string[][]): string[] { + const merged: string[] = []; + for (const patterns of patternArrays) { + merged.push(...patterns); } return [...new Set(merged)]; // 重複排除 } @@ -75,14 +72,11 @@ export function filterByExcludePatterns( * 設定からモジュールの有効パターンを取得 */ export function getEffectivePatterns( - moduleId: string, + _moduleId: string, modulePatterns: string[], config?: DevEnvConfig, ): string[] { - const customPatterns = config?.customPatterns?.[moduleId] as - | string[] - | undefined; - let patterns = mergePatterns(modulePatterns, customPatterns); + let patterns = [...modulePatterns]; // グローバル除外パターンを適用 if (config?.excludePatterns) { diff --git a/packages/create-devenv/src/utils/template.ts b/packages/create-devenv/src/utils/template.ts index 9049759..84f82a1 100644 --- a/packages/create-devenv/src/utils/template.ts +++ b/packages/create-devenv/src/utils/template.ts @@ -15,20 +15,49 @@ import type { DevEnvConfig, FileOperationResult, OverwriteStrategy, + TemplateModule, } from "../modules/schemas"; import { filterByGitignore, loadMergedGitignore } from "./gitignore"; import { getEffectivePatterns, resolvePatterns } from "./patterns"; -const TEMPLATE_SOURCE = "gh:tktcorporation/.github"; +export const TEMPLATE_SOURCE = "gh:tktcorporation/.github"; // 後方互換性のためのエイリアス export type CopyResult = FileOperationResult; +/** + * テンプレートをダウンロードして一時ディレクトリのパスを返す + */ +export async function downloadTemplateToTemp( + targetDir: string, +): Promise<{ templateDir: string; cleanup: () => void }> { + const tempDir = join(targetDir, ".devenv-temp"); + + consola.start("テンプレートを取得中..."); + + const { dir: templateDir } = await downloadTemplate(TEMPLATE_SOURCE, { + dir: tempDir, + force: true, + }); + + consola.success("テンプレートを取得しました"); + + const cleanup = () => { + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }; + + return { templateDir, cleanup }; +} + export interface DownloadOptions { targetDir: string; modules: string[]; overwriteStrategy: OverwriteStrategy; config?: DevEnvConfig; + moduleList?: TemplateModule[]; // 外部からロードしたモジュールリスト + templateDir?: string; // 事前にダウンロードしたテンプレートディレクトリ } export interface WriteFileOptions { @@ -86,21 +115,36 @@ export async function writeFileWithStrategy( export async function fetchTemplates( options: DownloadOptions, ): Promise { - const { targetDir, modules, overwriteStrategy, config } = options; + const { + targetDir, + modules, + overwriteStrategy, + config, + moduleList, + templateDir: preDownloadedDir, + } = options; const allResults: FileOperationResult[] = []; - // 一時ディレクトリにテンプレートをダウンロード + // 事前ダウンロード済みか、新規ダウンロードか + const shouldDownload = !preDownloadedDir; const tempDir = join(targetDir, ".devenv-temp"); + let templateDir: string; + try { - consola.start("テンプレートを取得中..."); + if (shouldDownload) { + consola.start("テンプレートを取得中..."); - const { dir: templateDir } = await downloadTemplate(TEMPLATE_SOURCE, { - dir: tempDir, - force: true, - }); + const result = await downloadTemplate(TEMPLATE_SOURCE, { + dir: tempDir, + force: true, + }); + templateDir = result.dir; - consola.success("テンプレートを取得しました"); + consola.success("テンプレートを取得しました"); + } else { + templateDir = preDownloadedDir; + } // ローカルとテンプレート両方の .gitignore をマージして読み込み // クレデンシャル等の機密情報の誤流出を防止 @@ -108,10 +152,13 @@ export async function fetchTemplates( // 選択されたモジュールのファイルをパターンベースでコピー for (const moduleId of modules) { - const moduleDef = getModuleById(moduleId); + // moduleList が指定されていればそちらから、なければデフォルトから取得 + const moduleDef = moduleList + ? moduleList.find((m) => m.id === moduleId) + : getModuleById(moduleId); if (!moduleDef) continue; - // 有効なパターンを取得(カスタムパターン考慮) + // 有効なパターンを取得 const patterns = getEffectivePatterns( moduleId, moduleDef.patterns, @@ -145,8 +192,8 @@ export async function fetchTemplates( } } } finally { - // 一時ディレクトリを削除 - if (existsSync(tempDir)) { + // 新規ダウンロードした場合のみ一時ディレクトリを削除 + if (shouldDownload && existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); } } diff --git a/packages/create-devenv/src/utils/untracked.ts b/packages/create-devenv/src/utils/untracked.ts new file mode 100644 index 0000000..d486ef8 --- /dev/null +++ b/packages/create-devenv/src/utils/untracked.ts @@ -0,0 +1,238 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import ignore, { type Ignore } from "ignore"; +import { join } from "pathe"; +import { globSync } from "tinyglobby"; +import { defaultModules, getModuleById } from "../modules"; +import type { DevEnvConfig, TemplateModule } from "../modules/schemas"; +import { getEffectivePatterns, resolvePatterns } from "./patterns"; + +export interface UntrackedFile { + path: string; + folder: string; + moduleId: string; // modules.jsonc にパターンを追加する際に必要 +} + +export interface UntrackedFilesByFolder { + folder: string; + files: UntrackedFile[]; +} + +/** + * ファイルパスからモジュール ID を取得 + * モジュール ID = ディレクトリパス(ルートは ".") + * + * 例: + * ".devcontainer/file.json" → ".devcontainer" + * ".mcp.json" → "." + * ".github/workflows/ci.yml" → ".github" + */ +export function getModuleIdFromPath(filePath: string): string { + const parts = filePath.split("/"); + if (parts.length === 1) { + return "."; // ルート直下のファイル + } + return parts[0]; // 最初のディレクトリ +} + +/** + * 後方互換性のため: フォルダ名を表示用に取得 + * "." は "root" として表示 + */ +export function getDisplayFolder(moduleId: string): string { + return moduleId === "." ? "root" : moduleId; +} + +/** + * モジュールのベースディレクトリを取得 + * モジュール ID がそのままディレクトリパスになる + */ +export function getModuleBaseDir(moduleId: string): string | null { + if (moduleId === ".") { + return null; // ルートはディレクトリではない + } + return moduleId; +} + +/** + * ディレクトリ内の全ファイルを取得 + */ +export function getAllFilesInDirs(baseDir: string, dirs: string[]): string[] { + if (dirs.length === 0) return []; + + const patterns = dirs.map((d) => `${d}/**/*`); + return globSync(patterns, { + cwd: baseDir, + dot: true, + onlyFiles: true, + }).sort(); +} + +/** + * ルート直下の隠しファイルを取得 + */ +export function getRootDotFiles(baseDir: string): string[] { + return globSync([".*"], { + cwd: baseDir, + dot: true, + onlyFiles: true, + }).sort(); +} + +/** + * 複数ディレクトリの .gitignore をマージして読み込み + * サブディレクトリの .gitignore も含める + */ +export async function loadAllGitignores( + baseDir: string, + dirs: string[], +): Promise { + const ig = ignore(); + + // ルートの .gitignore + const rootGitignore = join(baseDir, ".gitignore"); + if (existsSync(rootGitignore)) { + const content = await readFile(rootGitignore, "utf-8"); + ig.add(content); + } + + // 各ディレクトリの .gitignore + for (const dir of dirs) { + const gitignorePath = join(baseDir, dir, ".gitignore"); + if (existsSync(gitignorePath)) { + const content = await readFile(gitignorePath, "utf-8"); + // ディレクトリ相対のパスを絶対パスに変換するため、各パターンにプレフィックスを追加 + const prefixedContent = content + .split("\n") + .map((line) => { + const trimmed = line.trim(); + // コメント行や空行はそのまま + if (!trimmed || trimmed.startsWith("#")) return line; + // 否定パターンの場合 + if (trimmed.startsWith("!")) { + return `!${dir}/${trimmed.slice(1)}`; + } + return `${dir}/${trimmed}`; + }) + .join("\n"); + ig.add(prefixedContent); + } + } + + return ig; +} + +/** + * ホワイトリスト外のファイルをフォルダごとに検出 + */ +export async function detectUntrackedFiles(options: { + targetDir: string; + moduleIds: string[]; + config?: DevEnvConfig; + moduleList?: TemplateModule[]; +}): Promise { + const { targetDir, moduleIds, config, moduleList = defaultModules } = options; + + // インストール済みモジュール ID のセット + const installedModuleIds = new Set(moduleIds); + + // 全モジュールのベースディレクトリを収集("." 以外) + const allBaseDirs: string[] = []; + // 全モジュールのホワイトリスト済みファイル + const allTrackedFiles = new Set(); + // ルートモジュール(".")がインストールされているか + let hasRootModule = false; + + for (const moduleId of moduleIds) { + const mod = getModuleById(moduleId, moduleList); + if (!mod) continue; + + const baseDir = getModuleBaseDir(moduleId); + if (baseDir) { + allBaseDirs.push(baseDir); + } else { + hasRootModule = true; + } + + // ホワイトリスト済みファイルを収集 + const effectivePatterns = getEffectivePatterns( + moduleId, + mod.patterns, + config, + ); + const trackedFiles = resolvePatterns(targetDir, effectivePatterns); + for (const file of trackedFiles) { + allTrackedFiles.add(file); + } + } + + // gitignore を読み込み + const gitignore = await loadAllGitignores(targetDir, allBaseDirs); + + // ディレクトリ内の全ファイルを取得 + const allDirFiles = getAllFilesInDirs(targetDir, allBaseDirs); + const filteredDirFiles = gitignore.filter(allDirFiles); + + // ルート直下のファイルを取得(ルートモジュールがインストールされている場合のみ) + const filteredRootFiles = hasRootModule + ? gitignore.filter(getRootDotFiles(targetDir)) + : []; + + // 全ファイルをマージ(重複なし) + const allFiles = new Set([...filteredDirFiles, ...filteredRootFiles]); + + // フォルダごとにグループ化 + const filesByFolder = new Map(); + + for (const filePath of allFiles) { + // ホワイトリストに含まれていればスキップ + if (allTrackedFiles.has(filePath)) continue; + + // ファイルパスからモジュール ID を導出 + const moduleId = getModuleIdFromPath(filePath); + + // インストール済みモジュールに属さないファイルはスキップ + if (!installedModuleIds.has(moduleId)) continue; + + const displayFolder = getDisplayFolder(moduleId); + const file: UntrackedFile = { + path: filePath, + folder: displayFolder, + moduleId, + }; + + const existing = filesByFolder.get(displayFolder) || []; + existing.push(file); + filesByFolder.set(displayFolder, existing); + } + + // 結果を配列に変換(フォルダ名でソート) + const result: UntrackedFilesByFolder[] = []; + const sortedFolders = Array.from(filesByFolder.keys()).sort((a, b) => { + // root は最後に + if (a === "root") return 1; + if (b === "root") return -1; + return a.localeCompare(b); + }); + + for (const folder of sortedFolders) { + const files = filesByFolder.get(folder) || []; + if (files.length > 0) { + result.push({ + folder, + files: files.sort((a, b) => a.path.localeCompare(b.path)), + }); + } + } + + return result; +} + +/** + * 全フォルダの未追跡ファイル数を取得 + */ +export function getTotalUntrackedCount( + untrackedByFolder: UntrackedFilesByFolder[], +): number { + return untrackedByFolder.reduce((sum, f) => sum + f.files.length, 0); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d4bc0a..854e9ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: ignore: specifier: ^7.0.5 version: 7.0.5 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -1300,6 +1303,9 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -3159,6 +3165,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsonc-parser@3.3.1: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11