diff --git a/.changeset/readme-auto-generation.md b/.changeset/readme-auto-generation.md new file mode 100644 index 0000000..1105da7 --- /dev/null +++ b/.changeset/readme-auto-generation.md @@ -0,0 +1,10 @@ +--- +"@tktco/create-devenv": minor +--- + +README 自動生成機能を追加 + +- `pnpm run docs` で README のセクション(機能一覧・コマンド・生成ファイル)を自動生成 +- push コマンド実行時に README を自動更新して PR に含める +- デフォルトコマンドをインタラクティブ選択に変更 +- 開発者向けドキュメントを CONTRIBUTING.md に移動 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8078c5..c9b13d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,3 +81,22 @@ jobs: node packages/create-devenv/dist/index.mjs /tmp/test-project --yes ls -la /tmp/test-project cat /tmp/test-project/.devenv.json + + docs: + name: Check README + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check README is up to date + run: pnpm --filter @tktco/create-devenv run docs:check diff --git a/packages/create-devenv/CONTRIBUTING.md b/packages/create-devenv/CONTRIBUTING.md new file mode 100644 index 0000000..bf693d7 --- /dev/null +++ b/packages/create-devenv/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing + +## 開発環境セットアップ + +```bash +cd packages/create-devenv + +# 依存関係のインストール +pnpm install + +# 開発モード(stub) +pnpm run dev + +# ビルド +pnpm run build + +# テスト +pnpm run test + +# 型チェック +pnpm run typecheck + +# リント +pnpm run lint +``` + +## ドキュメント更新 + +README の一部は自動生成されています。コマンドオプションやモジュールを変更した場合は以下を実行してください: + +```bash +pnpm run docs +``` + +## リリース + +[Changesets](https://github.com/changesets/changesets) を使用した自動リリースフローです。 + +### 手順 + +```bash +# 1. changeset 作成(対話式で patch/minor/major を選択) +pnpm changeset + +# 2. コミット & プッシュ +git add . && git commit -m "chore: add changeset" && git push +``` + +これで CI が自動的に: +1. バージョン更新 & CHANGELOG 生成 → コミット +2. npm publish(OIDC Trusted Publishing) + +を実行します。 + +### バージョニング + +- `patch`: バグ修正(0.1.0 → 0.1.1) +- `minor`: 機能追加(0.1.0 → 0.2.0) +- `major`: 破壊的変更(0.1.0 → 1.0.0) diff --git a/packages/create-devenv/README.md b/packages/create-devenv/README.md index 035c832..6a40829 100644 --- a/packages/create-devenv/README.md +++ b/packages/create-devenv/README.md @@ -2,13 +2,16 @@ 開発環境テンプレートをインタラクティブにセットアップする CLI ツール。 + + ## 機能 -- DevContainer 設定(VS Code 拡張、mise、Docker-in-Docker) -- GitHub Actions(issue-link、labeler ワークフロー) -- MCP サーバー設定(Context7、Playwright、Chrome DevTools) -- mise 設定(Node.js、uv、Claude Code など) -- Claude IDE 設定 +- **ルート設定** - MCP、mise などのルート設定ファイル +- **DevContainer** - VS Code DevContainer、Docker-in-Docker +- **GitHub** - GitHub Actions、labeler ワークフロー +- **Claude** - Claude Code のプロジェクト共通設定 + + ## インストール @@ -22,74 +25,117 @@ npx @tktco/create-devenv npx @tktco/create-devenv ./my-project ``` -## CLI オプション + -``` -Usage: create-devenv init [dir] [options] +## コマンド + +### `init` 開発環境テンプレートを適用 -Arguments: - dir プロジェクトディレクトリ (default: ".") +``` +開発環境テンプレートを適用 (create-devenv vdev) + +USAGE `create-devenv [OPTIONS] [DIR]` + +ARGUMENTS + + `DIR="."` プロジェクトディレクトリ -Options: - --force 既存ファイルを強制上書き - -h, --help ヘルプを表示 +OPTIONS + + `--force` 既存ファイルを強制上書き + `-y, --yes` すべてのモジュールを自動選択(非インタラクティブモード) ``` -## 生成されるファイル +### `push` -選択したモジュールに応じて以下のファイルが生成されます: +ローカル変更をテンプレートリポジトリに PR として送信 -- `.devcontainer/` - DevContainer 設定 -- `.github/` - GitHub Actions ワークフロー -- `.mcp.json` - MCP サーバー設定 -- `.mise.toml` - mise ツール設定 -- `.claude/` - Claude IDE 設定 -- `.devenv.json` - このツールの設定(適用したモジュール情報) +``` +ローカル変更をテンプレートリポジトリに PR として送信 (push) -## 開発 +USAGE `push [OPTIONS] [DIR]` -```bash -cd packages/create-devenv +ARGUMENTS -# 依存関係のインストール -npm install + `DIR="."` プロジェクトディレクトリ -# 開発モード(stub) -npm run dev +OPTIONS -# ビルド -npm run build + `-n, --dryRun` 実際の PR を作成せず、プレビューのみ表示 + `-m, --message` PR のタイトル + `-f, --force` 確認プロンプトをスキップ + `--no-i, --no-interactive` 差分を確認しながらファイルを選択(デフォルト有効) ``` -## リリース +### `diff` -[Changesets](https://github.com/changesets/changesets) を使用した自動リリースフローです。 +ローカルとテンプレートの差分を表示 -### 手順 +``` +ローカルとテンプレートの差分を表示 (diff) -```bash -cd packages/create-devenv +USAGE `diff [OPTIONS] [DIR]` + +ARGUMENTS -# 1. changeset 作成(対話式で patch/minor/major を選択) -npm run changeset + `DIR="."` プロジェクトディレクトリ -# 2. コミット & プッシュ -git add . && git commit -m "chore: add changeset" && git push +OPTIONS + + `-v, --verbose` 詳細な差分を表示 ``` -これで CI が自動的に: -1. バージョン更新 & CHANGELOG 生成 → コミット -2. npm publish(OIDC Trusted Publishing) + + + + +## 生成されるファイル + +選択したモジュールに応じて以下のファイルが生成されます: + +### ルート + +MCP、mise などのルート設定ファイル + +- `.mcp.json` +- `.mise.toml` + +### `.devcontainer/` + +VS Code DevContainer、Docker-in-Docker + +- `.devcontainer/devcontainer.json` +- `.devcontainer/.gitignore` +- `.devcontainer/setup-*.sh` (パターン) +- `.devcontainer/test-*.sh` (パターン) +- `.devcontainer/.env.devcontainer.example` +- `.devcontainer/run-chrome-devtools-mcp.sh` + +### `.github/` + +GitHub Actions、labeler ワークフロー + +- `.github/workflows/issue-link.yml` +- `.github/workflows/label.yml` +- `.github/labeler.yml` + +### `.claude/` + +Claude Code のプロジェクト共通設定 + +- `.claude/settings.json` + +### 設定ファイル + +- `.devenv.json` - このツールの設定(適用したモジュール情報) -を実行します。 + -### バージョニング +## 開発・コントリビュート -- `patch`: バグ修正(0.1.0 → 0.1.1) -- `minor`: 機能追加(0.1.0 → 0.2.0) -- `major`: 破壊的変更(0.1.0 → 1.0.0) +[CONTRIBUTING.md](./CONTRIBUTING.md) を参照してください。 ## ライセンス diff --git a/packages/create-devenv/package.json b/packages/create-devenv/package.json index 20dac23..f245378 100644 --- a/packages/create-devenv/package.json +++ b/packages/create-devenv/package.json @@ -19,7 +19,9 @@ "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage", - "prepack": "npm run build" + "prepack": "npm run build", + "docs": "npx tsx scripts/generate-readme.ts", + "docs:check": "npx tsx scripts/generate-readme.ts --check" }, "dependencies": { "@inquirer/prompts": "^8.0.2", diff --git a/packages/create-devenv/scripts/generate-readme.ts b/packages/create-devenv/scripts/generate-readme.ts new file mode 100644 index 0000000..b8fb130 --- /dev/null +++ b/packages/create-devenv/scripts/generate-readme.ts @@ -0,0 +1,254 @@ +#!/usr/bin/env npx tsx +/** + * README.md のセクションを自動生成するスクリプト + * + * 使い方: + * pnpm run docs + * pnpm run docs:check # 差分チェックのみ(CI用) + * + * 自動生成されるセクション: + * - 機能 (modules.jsonc から) + * - コマンド (citty の renderUsage から) + * - 生成されるファイル (modules.jsonc から) + */ + +// 環境によるrenderUsage出力の差異を防ぐ +process.env.NO_COLOR = "1"; +process.env.FORCE_COLOR = "0"; +process.env.COLUMNS = "80"; + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { stripVTControlCharacters } from "node:util"; +import { renderUsage } from "citty"; +import { parse } from "jsonc-parser"; +import { diffCommand } from "../src/commands/diff"; +import { initCommand } from "../src/commands/init"; +import { pushCommand } from "../src/commands/push"; + +const README_PATH = resolve(import.meta.dirname, "../README.md"); +const MODULES_PATH = resolve( + import.meta.dirname, + "../../../.devenv/modules.jsonc", +); + +// マーカー定義 +const MARKERS = { + features: { + start: "", + end: "", + }, + commands: { + start: "", + end: "", + }, + files: { + start: "", + end: "", + }, +} as const; + +interface TemplateModule { + id: string; + name: string; + description: string; + setupDescription?: string; + patterns: string[]; +} + +interface ModulesFile { + modules: TemplateModule[]; +} + +interface CommandInfo { + name: string; + command: Parameters[0]; + description: string; +} + +const commands: CommandInfo[] = [ + { + name: "init", + command: initCommand, + description: "開発環境テンプレートを適用", + }, + { + name: "push", + command: pushCommand, + description: "ローカル変更をテンプレートリポジトリに PR として送信", + }, + { + name: "diff", + command: diffCommand, + description: "ローカルとテンプレートの差分を表示", + }, +]; + +/** + * modules.jsonc を読み込み + */ +async function loadModules(): Promise { + const content = await readFile(MODULES_PATH, "utf-8"); + const parsed = parse(content) as ModulesFile; + return parsed.modules; +} + +/** + * 機能セクションを生成 + */ +function generateFeaturesSection(modules: TemplateModule[]): string { + const lines: string[] = []; + lines.push("## 機能\n"); + + for (const mod of modules) { + lines.push(`- **${mod.name}** - ${mod.description}`); + } + + lines.push(""); + return lines.join("\n"); +} + +/** + * コマンドセクションを生成 + */ +async function generateCommandsSection(): Promise { + const sections: string[] = []; + + sections.push("## コマンド\n"); + + for (const { name, command, description } of commands) { + sections.push(`### \`${name}\`\n`); + sections.push(`${description}\n`); + sections.push("```"); + + const usage = await renderUsage(command); + // ANSIエスケープコードを除去(CI環境との一貫性を保つ) + sections.push(stripVTControlCharacters(usage.trim())); + + sections.push("```\n"); + } + + return sections.join("\n"); +} + +/** + * 生成されるファイルセクションを生成 + */ +function generateFilesSection(modules: TemplateModule[]): string { + const lines: string[] = []; + lines.push("## 生成されるファイル\n"); + lines.push("選択したモジュールに応じて以下のファイルが生成されます:\n"); + + for (const mod of modules) { + // モジュールIDからディレクトリ名を取得 + const dirName = mod.id === "." ? "ルート" : `\`${mod.id}/\``; + lines.push(`### ${dirName}\n`); + lines.push(`${mod.description}\n`); + + for (const pattern of mod.patterns) { + // glob パターンを説明的に表示 + const displayPattern = pattern.includes("*") + ? `\`${pattern}\` (パターン)` + : `\`${pattern}\``; + lines.push(`- ${displayPattern}`); + } + lines.push(""); + } + + // 設定ファイルの説明を追加 + lines.push("### 設定ファイル\n"); + lines.push("- `.devenv.json` - このツールの設定(適用したモジュール情報)\n"); + + return lines.join("\n"); +} + +/** + * README のマーカー間を更新 + */ +function updateSection( + content: string, + startMarker: string, + endMarker: string, + newSection: string, +): string { + const startIndex = content.indexOf(startMarker); + const endIndex = content.indexOf(endMarker); + + if (startIndex === -1 || endIndex === -1) { + throw new Error( + `README.md にマーカーが見つかりません。\n` + + `以下のマーカーを追加してください:\n` + + `${startMarker}\n${endMarker}`, + ); + } + + const before = content.slice(0, startIndex + startMarker.length); + const after = content.slice(endIndex); + + return `${before}\n\n${newSection}\n${after}`; +} + +/** + * メイン処理 + */ +async function main(): Promise { + const isCheck = process.argv.includes("--check"); + + console.log("📝 README ドキュメントを生成中...\n"); + + // modules.jsonc を読み込み + const modules = await loadModules(); + console.log(` 📦 ${modules.length} 個のモジュールを読み込みました`); + + // 各セクションを生成 + const featuresSection = generateFeaturesSection(modules); + const commandsSection = await generateCommandsSection(); + const filesSection = generateFilesSection(modules); + + // README を更新 + let readme = await readFile(README_PATH, "utf-8"); + const originalReadme = readme; + + readme = updateSection( + readme, + MARKERS.features.start, + MARKERS.features.end, + featuresSection, + ); + readme = updateSection( + readme, + MARKERS.commands.start, + MARKERS.commands.end, + commandsSection, + ); + readme = updateSection( + readme, + MARKERS.files.start, + MARKERS.files.end, + filesSection, + ); + + const updated = readme !== originalReadme; + + if (isCheck) { + if (updated) { + console.error("\n❌ README.md が最新ではありません。"); + console.error(" `pnpm run docs` を実行して更新してください。\n"); + process.exit(1); + } + console.log("\n✅ README.md は最新です。\n"); + return; + } + + if (updated) { + await writeFile(README_PATH, readme); + console.log("\n✅ README.md を更新しました。\n"); + } else { + console.log("\n✅ README.md は既に最新です。\n"); + } +} + +main().catch((error) => { + console.error("エラー:", error.message); + process.exit(1); +}); diff --git a/packages/create-devenv/src/commands/push.ts b/packages/create-devenv/src/commands/push.ts index f453631..a4975c1 100644 --- a/packages/create-devenv/src/commands/push.ts +++ b/packages/create-devenv/src/commands/push.ts @@ -23,10 +23,12 @@ import { } from "../prompts/push"; import { detectDiff, formatDiff, getPushableFiles } from "../utils/diff"; import { createPullRequest, getGitHubToken } from "../utils/github"; +import { detectAndUpdateReadme } from "../utils/readme"; +import { TEMPLATE_SOURCE } from "../utils/template"; import { detectUntrackedFiles } from "../utils/untracked"; -const TEMPLATE_SOURCE = "gh:tktcorporation/.github"; const MODULES_FILE_PATH = ".devenv/modules.jsonc"; +const README_PATH = "README.md"; export const pushCommand = defineCommand({ meta: { @@ -225,6 +227,9 @@ export const pushCommand = defineCommand({ // PR 本文取得 const body = await promptPrBody(); + // README を更新(対象の場合のみ) + const readmeResult = await detectAndUpdateReadme(targetDir, templateDir); + // ファイル内容を準備 const files = pushableFiles.map((f) => ({ path: f.path, @@ -239,6 +244,14 @@ export const pushCommand = defineCommand({ }); } + // README の変更があれば追加 + if (readmeResult?.updated) { + files.push({ + path: README_PATH, + content: readmeResult.content, + }); + } + consola.start("PR を作成中..."); // PR 作成 diff --git a/packages/create-devenv/src/index.ts b/packages/create-devenv/src/index.ts index e7ebe0e..75705ff 100644 --- a/packages/create-devenv/src/index.ts +++ b/packages/create-devenv/src/index.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { select } from "@inquirer/prompts"; import { defineCommand, runMain } from "citty"; import { version } from "../package.json"; import { diffCommand } from "./commands/diff"; @@ -18,7 +19,44 @@ const main = defineCommand({ }, }); -// サブコマンドなしで実行された場合は init を実行(後方互換性) +type CommandType = typeof initCommand | typeof pushCommand | typeof diffCommand; + +const commandMap: Record<"init" | "push" | "diff", CommandType> = { + init: initCommand, + push: pushCommand, + diff: diffCommand, +}; + +/** + * コマンド選択プロンプト + */ +async function promptCommand(): Promise { + const command = await select({ + message: "実行するコマンドを選択してください", + choices: [ + { + name: "init - 開発環境テンプレートを適用", + value: "init" as const, + description: "テンプレートをダウンロードしてプロジェクトに適用", + }, + { + name: "push - ローカル変更を PR として送信", + value: "push" as const, + description: "ローカルの変更をテンプレートリポジトリに PR として送信", + }, + { + name: "diff - ローカルとテンプレートの差分を表示", + value: "diff" as const, + description: "現在のファイルとテンプレートの差分を確認", + }, + ], + }); + + const selectedCommand = commandMap[command]; + runMain(selectedCommand as typeof diffCommand); +} + +// サブコマンドなしで実行された場合の処理 const args = process.argv.slice(2); const hasSubCommand = args.length > 0 && @@ -28,8 +66,8 @@ if (!hasSubCommand && args.length > 0 && !args[0].startsWith("-")) { // npx @tktco/create-devenv . のような形式は init コマンドとして実行 runMain(initCommand); } else if (!hasSubCommand && args.length === 0) { - // 引数なしの場合はヘルプを表示 - runMain(main); + // 引数なしの場合はコマンド選択プロンプトを表示 + promptCommand(); } else { runMain(main); } diff --git a/packages/create-devenv/src/utils/readme.ts b/packages/create-devenv/src/utils/readme.ts new file mode 100644 index 0000000..37dc8be --- /dev/null +++ b/packages/create-devenv/src/utils/readme.ts @@ -0,0 +1,235 @@ +/** + * README.md の自動生成ユーティリティ + */ + +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { parse } from "jsonc-parser"; +import { join } from "pathe"; +import type { TemplateModule } from "../modules/schemas"; + +// マーカー定義 +const MARKERS = { + features: { + start: "", + end: "", + }, + commands: { + start: "", + end: "", + }, + files: { + start: "", + end: "", + }, +} as const; + +interface ModulesFile { + modules: TemplateModule[]; +} + +/** + * modules.jsonc を読み込み + */ +async function loadModulesFromFile( + modulesPath: string, +): Promise { + if (!existsSync(modulesPath)) { + return []; + } + const content = await readFile(modulesPath, "utf-8"); + const parsed = parse(content) as ModulesFile; + return parsed.modules; +} + +/** + * 機能セクションを生成 + */ +function generateFeaturesSection(modules: TemplateModule[]): string { + const lines: string[] = []; + lines.push("## 機能\n"); + + for (const mod of modules) { + lines.push(`- **${mod.name}** - ${mod.description}`); + } + + lines.push(""); + return lines.join("\n"); +} + +/** + * 生成されるファイルセクションを生成 + */ +function generateFilesSection(modules: TemplateModule[]): string { + const lines: string[] = []; + lines.push("## 生成されるファイル\n"); + lines.push("選択したモジュールに応じて以下のファイルが生成されます:\n"); + + for (const mod of modules) { + const dirName = mod.id === "." ? "ルート" : `\`${mod.id}/\``; + lines.push(`### ${dirName}\n`); + lines.push(`${mod.description}\n`); + + for (const pattern of mod.patterns) { + const displayPattern = pattern.includes("*") + ? `\`${pattern}\` (パターン)` + : `\`${pattern}\``; + lines.push(`- ${displayPattern}`); + } + lines.push(""); + } + + lines.push("### 設定ファイル\n"); + lines.push("- `.devenv.json` - このツールの設定(適用したモジュール情報)\n"); + + return lines.join("\n"); +} + +/** + * README のマーカー間を更新 + */ +function updateSection( + content: string, + startMarker: string, + endMarker: string, + newSection: string, +): { content: string; updated: boolean } { + const startIndex = content.indexOf(startMarker); + const endIndex = content.indexOf(endMarker); + + if (startIndex === -1 || endIndex === -1) { + // マーカーがない場合はそのまま返す + return { content, updated: false }; + } + + const before = content.slice(0, startIndex + startMarker.length); + const after = content.slice(endIndex); + const newContent = `${before}\n\n${newSection}\n${after}`; + + return { content: newContent, updated: newContent !== content }; +} + +export interface GenerateReadmeOptions { + /** README.md のパス */ + readmePath: string; + /** modules.jsonc のパス */ + modulesPath: string; + /** コマンドセクションを生成する関数(オプション) */ + generateCommandsSection?: () => Promise; +} + +export interface GenerateReadmeResult { + /** README が更新されたかどうか */ + updated: boolean; + /** 更新後の README の内容 */ + content: string; + /** README ファイルのパス */ + readmePath: string; +} + +/** + * README を生成 + */ +export async function generateReadme( + options: GenerateReadmeOptions, +): Promise { + const { readmePath, modulesPath, generateCommandsSection } = options; + + // README が存在しない場合はスキップ + if (!existsSync(readmePath)) { + return { updated: false, content: "", readmePath }; + } + + const modules = await loadModulesFromFile(modulesPath); + + let readme = await readFile(readmePath, "utf-8"); + let anyUpdated = false; + + // 機能セクション + if (modules.length > 0) { + const featuresSection = generateFeaturesSection(modules); + const result = updateSection( + readme, + MARKERS.features.start, + MARKERS.features.end, + featuresSection, + ); + readme = result.content; + anyUpdated = anyUpdated || result.updated; + } + + // コマンドセクション(オプション) + if (generateCommandsSection) { + const commandsSection = await generateCommandsSection(); + const result = updateSection( + readme, + MARKERS.commands.start, + MARKERS.commands.end, + commandsSection, + ); + readme = result.content; + anyUpdated = anyUpdated || result.updated; + } + + // ファイルセクション + if (modules.length > 0) { + const filesSection = generateFilesSection(modules); + const result = updateSection( + readme, + MARKERS.files.start, + MARKERS.files.end, + filesSection, + ); + readme = result.content; + anyUpdated = anyUpdated || result.updated; + } + + return { updated: anyUpdated, content: readme, readmePath }; +} + +/** + * README を更新して保存 + */ +export async function updateReadmeFile( + options: GenerateReadmeOptions, +): Promise { + const result = await generateReadme(options); + + if (result.updated) { + await writeFile(result.readmePath, result.content); + } + + return result; +} + +/** + * プロジェクトディレクトリ内の README を検出して更新 + * @param targetDir プロジェクトのルートディレクトリ + * @param templateDir テンプレートディレクトリ(modules.jsonc の場所) + */ +export async function detectAndUpdateReadme( + targetDir: string, + templateDir: string, +): Promise { + const readmePath = join(targetDir, "README.md"); + const modulesPath = join(templateDir, ".devenv/modules.jsonc"); + + // README にマーカーがあるか確認 + if (!existsSync(readmePath)) { + return null; + } + + const readmeContent = await readFile(readmePath, "utf-8"); + const hasMarkers = + readmeContent.includes(MARKERS.features.start) || + readmeContent.includes(MARKERS.files.start); + + if (!hasMarkers) { + return null; + } + + return updateReadmeFile({ + readmePath, + modulesPath, + }); +}