From ec36c474aac4e01b30ad018507f5fe7f9a305da2 Mon Sep 17 00:00:00 2001 From: tktcorporation Date: Fri, 19 Dec 2025 06:07:54 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(create-devenv):=20=E3=83=9B=E3=83=AF?= =?UTF-8?q?=E3=82=A4=E3=83=88=E3=83=AA=E3=82=B9=E3=83=88=E5=A4=96=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E6=A4=9C=E7=9F=A5=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit push コマンドにホワイトリスト(patterns)に含まれていないファイルを 検出する機能を追加しました。 機能: - push 時にホワイトリスト外のファイルをフォルダ単位で検出 - 2ステップUI: フォルダ選択 → ファイル選択 - 選択したファイルを .devenv.json の customPatterns に自動追加 - gitignore されているファイルは自動で除外 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/add-untracked-detection.md | 10 + packages/create-devenv/src/commands/push.ts | 46 +++- packages/create-devenv/src/prompts/push.ts | 84 +++++++ packages/create-devenv/src/utils/config.ts | 62 +++++ packages/create-devenv/src/utils/untracked.ts | 236 ++++++++++++++++++ 5 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 .changeset/add-untracked-detection.md create mode 100644 packages/create-devenv/src/utils/config.ts create mode 100644 packages/create-devenv/src/utils/untracked.ts diff --git a/.changeset/add-untracked-detection.md b/.changeset/add-untracked-detection.md new file mode 100644 index 0000000..80cac91 --- /dev/null +++ b/.changeset/add-untracked-detection.md @@ -0,0 +1,10 @@ +--- +"@tktco/create-devenv": minor +--- + +push コマンドにホワイトリスト外ファイル検知機能を追加 + +- push 時にホワイトリスト(patterns)に含まれていないファイルを検出 +- モジュールごとにグループ化して選択UI を表示 +- 選択したファイルを `.devenv.json` の `customPatterns` に自動追加 +- gitignore されているファイルは自動で除外 diff --git a/packages/create-devenv/src/commands/push.ts b/packages/create-devenv/src/commands/push.ts index 4766d83..71efc5e 100644 --- a/packages/create-devenv/src/commands/push.ts +++ b/packages/create-devenv/src/commands/push.ts @@ -7,14 +7,17 @@ import { join, resolve } from "pathe"; import type { DevEnvConfig } from "../modules/schemas"; import { configSchema } from "../modules/schemas"; import { + promptAddUntrackedFiles, promptGitHubToken, promptPrBody, promptPrTitle, promptPushConfirm, promptSelectFilesWithDiff, } from "../prompts/push"; +import { addMultipleToCustomPatterns, saveConfig } from "../utils/config"; import { detectDiff, formatDiff, getPushableFiles } from "../utils/diff"; import { createPullRequest, getGitHubToken } from "../utils/github"; +import { detectUntrackedFiles } from "../utils/untracked"; const TEMPLATE_SOURCE = "gh:tktcorporation/.github"; @@ -78,7 +81,7 @@ export const pushCommand = defineCommand({ process.exit(1); } - const config: DevEnvConfig = parseResult.data; + let config: DevEnvConfig = parseResult.data; if (config.modules.length === 0) { consola.warn("インストール済みのモジュールがありません。"); @@ -96,9 +99,48 @@ export const pushCommand = defineCommand({ force: true, }); + // ホワイトリスト外ファイルの検出と追加確認 + if (!args.force) { + const untrackedByFolder = await detectUntrackedFiles({ + targetDir, + moduleIds: config.modules, + config, + }); + + if (untrackedByFolder.length > 0) { + const selectedFiles = + await promptAddUntrackedFiles(untrackedByFolder); + + if (selectedFiles.length > 0) { + // customPatterns に追加 + const additions = selectedFiles.map((s) => ({ + moduleId: s.moduleId, + patterns: s.files, + })); + config = addMultipleToCustomPatterns(config, additions); + + // .devenv.json を更新 + if (!args.dryRun) { + await saveConfig(targetDir, config); + const totalAdded = selectedFiles.reduce( + (sum, s) => sum + s.files.length, + 0, + ); + consola.success( + `${totalAdded} 件のパターンを .devenv.json に追加しました`, + ); + } else { + consola.info( + "[ドライラン] .devenv.json への書き込みはスキップされました", + ); + } + } + } + } + consola.start("差分を検出中..."); - // 差分検出 + // 差分検出(更新された config を使用) const diff = await detectDiff({ targetDir, templateDir, 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..e71b789 --- /dev/null +++ b/packages/create-devenv/src/utils/config.ts @@ -0,0 +1,62 @@ +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`); +} + +/** + * customPatterns にパターンを追加 + */ +export function addToCustomPatterns( + config: DevEnvConfig, + moduleId: string, + patterns: string[], +): DevEnvConfig { + const customPatterns = { ...(config.customPatterns || {}) }; + const existing = customPatterns[moduleId] || []; + + // 重複を除いて追加 + const newPatterns = patterns.filter((p) => !existing.includes(p)); + if (newPatterns.length > 0) { + customPatterns[moduleId] = [...existing, ...newPatterns]; + } + + return { + ...config, + customPatterns, + }; +} + +/** + * 複数モジュールのカスタムパターンを一括追加 + */ +export function addMultipleToCustomPatterns( + config: DevEnvConfig, + additions: { moduleId: string; patterns: string[] }[], +): DevEnvConfig { + let updatedConfig = config; + for (const { moduleId, patterns } of additions) { + updatedConfig = addToCustomPatterns(updatedConfig, moduleId, patterns); + } + return updatedConfig; +} diff --git a/packages/create-devenv/src/utils/untracked.ts b/packages/create-devenv/src/utils/untracked.ts new file mode 100644 index 0000000..b88fabd --- /dev/null +++ b/packages/create-devenv/src/utils/untracked.ts @@ -0,0 +1,236 @@ +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 { getModuleById } from "../modules"; +import type { DevEnvConfig } from "../modules/schemas"; +import { getEffectivePatterns, resolvePatterns } from "./patterns"; + +export interface UntrackedFile { + path: string; + folder: string; + moduleId: string; // customPatterns に追加する際に必要 +} + +export interface UntrackedFilesByFolder { + folder: string; + files: UntrackedFile[]; +} + +/** + * パターンからベースディレクトリを抽出 + * 例: ".devcontainer/devcontainer.json" → ".devcontainer" + */ +export function extractBaseDirectories(patterns: string[]): string[] { + const dirs = new Set(); + for (const pattern of patterns) { + const parts = pattern.split("/"); + // ディレクトリを持つパターンのみ(. で始まる隠しディレクトリ) + if (parts.length > 1 && parts[0].startsWith(".")) { + dirs.add(parts[0]); + } + } + return Array.from(dirs); +} + +/** + * ファイルパスからフォルダを取得 + * 例: ".devcontainer/file.json" → ".devcontainer" + * 例: ".gitignore" → "root" + */ +export function getFolderFromPath(filePath: string): string { + const parts = filePath.split("/"); + if (parts.length > 1) { + return parts[0]; + } + return "root"; +} + +/** + * ディレクトリ内の全ファイルを取得 + */ +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; +}): Promise { + const { targetDir, moduleIds, config } = options; + + // 全モジュールのベースディレクトリを収集 + const allBaseDirs = new Set(); + // ファイルパスからモジュールIDを逆引きするためのマップ + // キー: フォルダ名, 値: そのフォルダを管理するモジュールID + const folderToModuleId = new Map(); + // 全モジュールのホワイトリスト済みファイル + const allTrackedFiles = new Set(); + + for (const moduleId of moduleIds) { + const mod = getModuleById(moduleId); + if (!mod) continue; + + const baseDirs = extractBaseDirectories(mod.patterns); + for (const dir of baseDirs) { + allBaseDirs.add(dir); + // 最初にマッチしたモジュールに紐づける + if (!folderToModuleId.has(dir)) { + folderToModuleId.set(dir, moduleId); + } + } + + // ルート直下のパターンを持つモジュールの場合 + const hasRootPatterns = mod.patterns.some( + (p) => !p.includes("/") && p.startsWith("."), + ); + if (hasRootPatterns && !folderToModuleId.has("root")) { + folderToModuleId.set("root", moduleId); + } + + // ホワイトリスト済みファイルを収集 + 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, Array.from(allBaseDirs)); + + // ディレクトリ内の全ファイルを取得 + const allDirFiles = getAllFilesInDirs(targetDir, Array.from(allBaseDirs)); + const filteredDirFiles = gitignore.filter(allDirFiles); + + // ルート直下のファイルを取得 + const rootFiles = getRootDotFiles(targetDir); + const filteredRootFiles = gitignore.filter(rootFiles); + + // 全ファイルをマージ(重複なし) + const allFiles = new Set([...filteredDirFiles, ...filteredRootFiles]); + + // フォルダごとにグループ化 + const filesByFolder = new Map(); + + for (const filePath of allFiles) { + // ホワイトリストに含まれていればスキップ + if (allTrackedFiles.has(filePath)) continue; + + const folder = getFolderFromPath(filePath); + const moduleId = folderToModuleId.get(folder); + + // モジュールに紐づかないフォルダはスキップ + if (!moduleId) continue; + + const file: UntrackedFile = { + path: filePath, + folder, + moduleId, + }; + + const existing = filesByFolder.get(folder) || []; + existing.push(file); + filesByFolder.set(folder, 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); +} From 099b6a385ae58ebe6487ef03696e840c9bad3f2a Mon Sep 17 00:00:00 2001 From: tktcorporation Date: Fri, 19 Dec 2025 11:04:30 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(create-devenv):=20=E3=83=A2=E3=82=B8?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E3=83=AB=E5=AE=9A=E7=BE=A9=E3=82=92=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E5=8C=96=E3=81=97=E3=83=87=E3=82=A3=E3=83=AC=E3=82=AF?= =?UTF-8?q?=E3=83=88=E3=83=AA=E3=83=99=E3=83=BC=E3=82=B9=E8=A8=AD=E8=A8=88?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - modules.jsonc からモジュール定義を読み込むように変更 - モジュール ID をディレクトリパスベースに変更(例: .devcontainer, .github, .) - customPatterns を .devenv.json から削除し modules.jsonc に統合 - ファイル→モジュール対応をシンプル化(パスの最初のセグメント = モジュール ID) - push コマンドで modules.jsonc の変更も PR に含めるように 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/create-devenv/package.json | 1 + packages/create-devenv/src/commands/init.ts | 120 +++++++++++------- packages/create-devenv/src/commands/push.ts | 83 ++++++++---- packages/create-devenv/src/modules/index.ts | 88 +++++++------ packages/create-devenv/src/modules/loader.ts | 114 +++++++++++++++++ packages/create-devenv/src/modules/schemas.ts | 4 +- packages/create-devenv/src/prompts/init.ts | 14 +- packages/create-devenv/src/utils/config.ts | 37 ------ packages/create-devenv/src/utils/diff.ts | 16 ++- packages/create-devenv/src/utils/patterns.ts | 20 +-- packages/create-devenv/src/utils/template.ts | 73 +++++++++-- packages/create-devenv/src/utils/untracked.ts | 114 +++++++++-------- pnpm-lock.yaml | 8 ++ 13 files changed, 451 insertions(+), 241 deletions(-) create mode 100644 packages/create-devenv/src/modules/loader.ts 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 71efc5e..e6af0b3 100644 --- a/packages/create-devenv/src/commands/push.ts +++ b/packages/create-devenv/src/commands/push.ts @@ -4,7 +4,13 @@ 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, @@ -14,12 +20,12 @@ import { promptPushConfirm, promptSelectFilesWithDiff, } from "../prompts/push"; -import { addMultipleToCustomPatterns, saveConfig } from "../utils/config"; 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: { @@ -81,7 +87,7 @@ export const pushCommand = defineCommand({ process.exit(1); } - let config: DevEnvConfig = parseResult.data; + const config: DevEnvConfig = parseResult.data; if (config.modules.length === 0) { consola.warn("インストール済みのモジュールがありません。"); @@ -99,12 +105,27 @@ 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; + } + // ホワイトリスト外ファイルの検出と追加確認 - if (!args.force) { + let updatedModulesContent: string | undefined; + + if (!args.force && modulesRawContent) { const untrackedByFolder = await detectUntrackedFiles({ targetDir, moduleIds: config.modules, config, + moduleList, }); if (untrackedByFolder.length > 0) { @@ -112,46 +133,43 @@ export const pushCommand = defineCommand({ await promptAddUntrackedFiles(untrackedByFolder); if (selectedFiles.length > 0) { - // customPatterns に追加 - const additions = selectedFiles.map((s) => ({ - moduleId: s.moduleId, - patterns: s.files, - })); - config = addMultipleToCustomPatterns(config, additions); - - // .devenv.json を更新 - if (!args.dryRun) { - await saveConfig(targetDir, config); - const totalAdded = selectedFiles.reduce( - (sum, s) => sum + s.files.length, - 0, - ); - consola.success( - `${totalAdded} 件のパターンを .devenv.json に追加しました`, - ); - } else { - consola.info( - "[ドライラン] .devenv.json への書き込みはスキップされました", + // 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("差分を検出中..."); - // 差分検出(更新された config を使用) + // 差分検出 const diff = await detectDiff({ targetDir, 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)); @@ -163,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; @@ -171,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; } @@ -202,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/utils/config.ts b/packages/create-devenv/src/utils/config.ts index e71b789..573f818 100644 --- a/packages/create-devenv/src/utils/config.ts +++ b/packages/create-devenv/src/utils/config.ts @@ -23,40 +23,3 @@ export async function saveConfig( const configPath = join(targetDir, ".devenv.json"); await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); } - -/** - * customPatterns にパターンを追加 - */ -export function addToCustomPatterns( - config: DevEnvConfig, - moduleId: string, - patterns: string[], -): DevEnvConfig { - const customPatterns = { ...(config.customPatterns || {}) }; - const existing = customPatterns[moduleId] || []; - - // 重複を除いて追加 - const newPatterns = patterns.filter((p) => !existing.includes(p)); - if (newPatterns.length > 0) { - customPatterns[moduleId] = [...existing, ...newPatterns]; - } - - return { - ...config, - customPatterns, - }; -} - -/** - * 複数モジュールのカスタムパターンを一括追加 - */ -export function addMultipleToCustomPatterns( - config: DevEnvConfig, - additions: { moduleId: string; patterns: string[] }[], -): DevEnvConfig { - let updatedConfig = config; - for (const { moduleId, patterns } of additions) { - updatedConfig = addToCustomPatterns(updatedConfig, moduleId, patterns); - } - return updatedConfig; -} 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 index b88fabd..d486ef8 100644 --- a/packages/create-devenv/src/utils/untracked.ts +++ b/packages/create-devenv/src/utils/untracked.ts @@ -3,14 +3,14 @@ import { readFile } from "node:fs/promises"; import ignore, { type Ignore } from "ignore"; import { join } from "pathe"; import { globSync } from "tinyglobby"; -import { getModuleById } from "../modules"; -import type { DevEnvConfig } from "../modules/schemas"; +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; // customPatterns に追加する際に必要 + moduleId: string; // modules.jsonc にパターンを追加する際に必要 } export interface UntrackedFilesByFolder { @@ -19,32 +19,39 @@ export interface UntrackedFilesByFolder { } /** - * パターンからベースディレクトリを抽出 - * 例: ".devcontainer/devcontainer.json" → ".devcontainer" + * ファイルパスからモジュール ID を取得 + * モジュール ID = ディレクトリパス(ルートは ".") + * + * 例: + * ".devcontainer/file.json" → ".devcontainer" + * ".mcp.json" → "." + * ".github/workflows/ci.yml" → ".github" */ -export function extractBaseDirectories(patterns: string[]): string[] { - const dirs = new Set(); - for (const pattern of patterns) { - const parts = pattern.split("/"); - // ディレクトリを持つパターンのみ(. で始まる隠しディレクトリ) - if (parts.length > 1 && parts[0].startsWith(".")) { - dirs.add(parts[0]); - } +export function getModuleIdFromPath(filePath: string): string { + const parts = filePath.split("/"); + if (parts.length === 1) { + return "."; // ルート直下のファイル } - return Array.from(dirs); + return parts[0]; // 最初のディレクトリ } /** - * ファイルパスからフォルダを取得 - * 例: ".devcontainer/file.json" → ".devcontainer" - * 例: ".gitignore" → "root" + * 後方互換性のため: フォルダ名を表示用に取得 + * "." は "root" として表示 */ -export function getFolderFromPath(filePath: string): string { - const parts = filePath.split("/"); - if (parts.length > 1) { - return parts[0]; +export function getDisplayFolder(moduleId: string): string { + return moduleId === "." ? "root" : moduleId; +} + +/** + * モジュールのベースディレクトリを取得 + * モジュール ID がそのままディレクトリパスになる + */ +export function getModuleBaseDir(moduleId: string): string | null { + if (moduleId === ".") { + return null; // ルートはディレクトリではない } - return "root"; + return moduleId; } /** @@ -122,36 +129,29 @@ export async function detectUntrackedFiles(options: { targetDir: string; moduleIds: string[]; config?: DevEnvConfig; + moduleList?: TemplateModule[]; }): Promise { - const { targetDir, moduleIds, config } = options; + const { targetDir, moduleIds, config, moduleList = defaultModules } = options; + + // インストール済みモジュール ID のセット + const installedModuleIds = new Set(moduleIds); - // 全モジュールのベースディレクトリを収集 - const allBaseDirs = new Set(); - // ファイルパスからモジュールIDを逆引きするためのマップ - // キー: フォルダ名, 値: そのフォルダを管理するモジュールID - const folderToModuleId = new Map(); + // 全モジュールのベースディレクトリを収集("." 以外) + const allBaseDirs: string[] = []; // 全モジュールのホワイトリスト済みファイル const allTrackedFiles = new Set(); + // ルートモジュール(".")がインストールされているか + let hasRootModule = false; for (const moduleId of moduleIds) { - const mod = getModuleById(moduleId); + const mod = getModuleById(moduleId, moduleList); if (!mod) continue; - const baseDirs = extractBaseDirectories(mod.patterns); - for (const dir of baseDirs) { - allBaseDirs.add(dir); - // 最初にマッチしたモジュールに紐づける - if (!folderToModuleId.has(dir)) { - folderToModuleId.set(dir, moduleId); - } - } - - // ルート直下のパターンを持つモジュールの場合 - const hasRootPatterns = mod.patterns.some( - (p) => !p.includes("/") && p.startsWith("."), - ); - if (hasRootPatterns && !folderToModuleId.has("root")) { - folderToModuleId.set("root", moduleId); + const baseDir = getModuleBaseDir(moduleId); + if (baseDir) { + allBaseDirs.push(baseDir); + } else { + hasRootModule = true; } // ホワイトリスト済みファイルを収集 @@ -167,15 +167,16 @@ export async function detectUntrackedFiles(options: { } // gitignore を読み込み - const gitignore = await loadAllGitignores(targetDir, Array.from(allBaseDirs)); + const gitignore = await loadAllGitignores(targetDir, allBaseDirs); // ディレクトリ内の全ファイルを取得 - const allDirFiles = getAllFilesInDirs(targetDir, Array.from(allBaseDirs)); + const allDirFiles = getAllFilesInDirs(targetDir, allBaseDirs); const filteredDirFiles = gitignore.filter(allDirFiles); - // ルート直下のファイルを取得 - const rootFiles = getRootDotFiles(targetDir); - const filteredRootFiles = gitignore.filter(rootFiles); + // ルート直下のファイルを取得(ルートモジュールがインストールされている場合のみ) + const filteredRootFiles = hasRootModule + ? gitignore.filter(getRootDotFiles(targetDir)) + : []; // 全ファイルをマージ(重複なし) const allFiles = new Set([...filteredDirFiles, ...filteredRootFiles]); @@ -187,21 +188,22 @@ export async function detectUntrackedFiles(options: { // ホワイトリストに含まれていればスキップ if (allTrackedFiles.has(filePath)) continue; - const folder = getFolderFromPath(filePath); - const moduleId = folderToModuleId.get(folder); + // ファイルパスからモジュール ID を導出 + const moduleId = getModuleIdFromPath(filePath); - // モジュールに紐づかないフォルダはスキップ - if (!moduleId) continue; + // インストール済みモジュールに属さないファイルはスキップ + if (!installedModuleIds.has(moduleId)) continue; + const displayFolder = getDisplayFolder(moduleId); const file: UntrackedFile = { path: filePath, - folder, + folder: displayFolder, moduleId, }; - const existing = filesByFolder.get(folder) || []; + const existing = filesByFolder.get(displayFolder) || []; existing.push(file); - filesByFolder.set(folder, existing); + filesByFolder.set(displayFolder, existing); } // 結果を配列に変換(フォルダ名でソート) 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 From 9dd6f415895c37f809fff5f3881ad4cd0a701053 Mon Sep 17 00:00:00 2001 From: tktcorporation Date: Fri, 19 Dec 2025 11:04:58 +0000 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20changeset=20=E3=82=92=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=EF=BC=88=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E5=8C=96=E3=83=BB=E3=83=87=E3=82=A3=E3=83=AC?= =?UTF-8?q?=E3=82=AF=E3=83=88=E3=83=AA=E3=83=99=E3=83=BC=E3=82=B9=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/add-untracked-detection.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.changeset/add-untracked-detection.md b/.changeset/add-untracked-detection.md index 80cac91..7439827 100644 --- a/.changeset/add-untracked-detection.md +++ b/.changeset/add-untracked-detection.md @@ -2,9 +2,20 @@ "@tktco/create-devenv": minor --- -push コマンドにホワイトリスト外ファイル検知機能を追加 +push コマンドにホワイトリスト外ファイル検知機能を追加し、モジュール定義を外部化 +### ホワイトリスト外ファイル検知 - push 時にホワイトリスト(patterns)に含まれていないファイルを検出 - モジュールごとにグループ化して選択UI を表示 -- 選択したファイルを `.devenv.json` の `customPatterns` に自動追加 +- 選択したファイルを modules.jsonc に自動追加(PR に含まれる) - gitignore されているファイルは自動で除外 + +### モジュール定義の外部化 +- モジュール定義をコードから `.devenv/modules.jsonc` に外部化 +- テンプレートリポジトリの modules.jsonc から動的に読み込み +- `customPatterns` を廃止し modules.jsonc に統合 + +### ディレクトリベースのモジュール設計 +- モジュール ID をディレクトリパスベースに変更(例: `.devcontainer`, `.github`, `.`) +- ファイルパスから即座にモジュール ID を導出可能に +- モジュール間のファイル重複を構造的に防止 From 4b58ae40d72d49e7faa0212b68709e095cb8ad29 Mon Sep 17 00:00:00 2001 From: tktcorporation Date: Fri, 19 Dec 2025 11:06:49 +0000 Subject: [PATCH 4/4] =?UTF-8?q?feat(create-devenv):=20.devenv/modules.json?= =?UTF-8?q?c=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit テンプレートリポジトリのモジュール定義ファイル 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .devenv/modules.jsonc | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .devenv/modules.jsonc 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(個人設定) + ] + } + ] +}