create-einja-app 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-einja-app",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "CLI tool to create new projects with Einja Management Template",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,19 @@
1
1
  # Auto detect text files and perform LF normalization
2
2
  * text=auto
3
3
 
4
+ # Binary files - prevent CRLF conversion
5
+ *.ico binary
6
+ *.png binary
7
+ *.jpg binary
8
+ *.jpeg binary
9
+ *.gif binary
10
+ *.webp binary
11
+ *.svg binary
12
+ *.woff binary
13
+ *.woff2 binary
14
+ *.ttf binary
15
+ *.eot binary
16
+ *.pdf binary
17
+
4
18
  # Symlink handling for Windows compatibility
5
19
  docs/templates symlink=auto
@@ -3,101 +3,6 @@
3
3
  Turborepo + Next.js 15 + Auth.js + Prisma 構成のプロジェクトテンプレートと、Claude Code用のATDDワークフロー設定を提供します。
4
4
 
5
5
  ---
6
-
7
- ## パッケージ利用者向け
8
-
9
- ### create-einja-app - 新規プロジェクト作成
10
-
11
- 新しいプロジェクトを作成したい場合に使用します。
12
-
13
- ```bash
14
- npx create-einja-app my-project
15
- ```
16
-
17
- **何が起きるか:**
18
-
19
- 1. `my-project/` ディレクトリが作成される
20
- 2. Turborepo + Next.js 15 + Prisma のモノレポ構成が展開される
21
- 3. `.claude/` ディレクトリ(Claude Code設定)が自動セットアップされる
22
- 4. 依存関係がインストールされる
23
- 5. Gitリポジトリが初期化される
24
-
25
- **作成後の開始手順:**
26
-
27
- ```bash
28
- cd my-project
29
- docker-compose up -d postgres # PostgreSQL起動
30
- pnpm dev # 開発サーバー起動
31
- ```
32
-
33
- ブラウザで http://localhost:3000 にアクセス
34
-
35
- **オプション:**
36
-
37
- | オプション | 説明 |
38
- |-----------|------|
39
- | `--yes` | 対話プロンプトをスキップ(デフォルト値使用) |
40
- | `--skip-git` | Git初期化をスキップ |
41
- | `--skip-install` | 依存関係インストールをスキップ |
42
-
43
- 📖 詳細: [packages/create-einja-app/README.md](./packages/create-einja-app/README.md)
44
-
45
- ---
46
-
47
- ### @einja/dev-cli - 既存プロジェクトにClaude Code設定を追加
48
-
49
- 既存のプロジェクトにClaude Code用のATDDワークフロー設定を追加したい場合に使用します。
50
-
51
- ```bash
52
- cd your-existing-project
53
- npx @einja/dev-cli init
54
- ```
55
-
56
- **何が起きるか:**
57
-
58
- 1. `.claude/` ディレクトリが作成される
59
- - `agents/` - タスク実行、仕様書生成、フロントエンド開発用サブエージェント
60
- - `commands/` - `/spec-create`, `/task-exec` などのスラッシュコマンド
61
- - `skills/` - コーディング規約、コンポーネント設計ガイド
62
- - `hooks/` - Biomeフォーマット、型チェックなどのGit Hooks
63
- - `settings.json` - MCPサーバー設定(GitHub, Playwright, Serena等)
64
- 2. `docs/einja/` ディレクトリが作成される
65
- - `steering/` - コミットルール、テスト戦略、レビューガイドライン
66
- - `templates/` - 仕様書テンプレート
67
- 3. `CLAUDE.md` テンプレートが作成される
68
- 4. `package.json` にスクリプトが追加される
69
-
70
- **追加されるnpm scripts:**
71
-
72
- ```bash
73
- pnpm task:loop 123 # GitHub Issue #123のタスクを自動実行
74
- pnpm einja:sync # テンプレートから最新設定を同期
75
- ```
76
-
77
- **その他のコマンド:**
78
-
79
- ```bash
80
- # テンプレートから設定を同期(更新があった場合)
81
- npx @einja/dev-cli sync
82
-
83
- # 特定カテゴリのみ同期
84
- npx @einja/dev-cli sync --only commands,agents
85
- ```
86
-
87
- 📖 詳細: [packages/cli/README.md](./packages/cli/README.md)
88
-
89
- ---
90
-
91
- ### 使い分けガイド
92
-
93
- | やりたいこと | 使うパッケージ |
94
- |-------------|---------------|
95
- | 新規プロジェクトを作成したい | `npx create-einja-app my-project` |
96
- | 既存プロジェクトにClaude設定を追加したい | `npx @einja/dev-cli init` |
97
- | Claude設定を最新に更新したい | `npx @einja/dev-cli sync` |
98
-
99
- ---
100
-
101
6
  ## パッケージ開発者向け
102
7
 
103
8
  以下は、このリポジトリ自体を開発する場合の情報です。
@@ -318,28 +223,6 @@ pnpm db:studio
318
223
  - **@repo/server-core**: バックエンド共通層(Prismaクライアント・スキーマ、ドメインロジック)
319
224
  - **@repo/ui**: 共通UIコンポーネント(shadcn/ui)
320
225
 
321
- ### CLIパッケージの開発
322
-
323
- #### @einja/dev-cli
324
-
325
- ```bash
326
- cd packages/cli
327
- pnpm build # ビルド
328
- pnpm test # テスト
329
- pnpm typecheck # 型チェック
330
- ```
331
-
332
- 📖 [ビルドプロセス](./packages/cli/docs/BUILD.md) | [NPM公開手順](./packages/cli/docs/PUBLISHING.md) | [リリース手順](./packages/cli/RELEASING.md)
333
-
334
- #### create-einja-app
335
-
336
- ```bash
337
- cd packages/create-einja-app
338
- pnpm build # ビルド(テンプレート更新含む)
339
- pnpm test # テスト
340
- pnpm typecheck # 型チェック
341
- ```
342
-
343
226
  ### 開発ワークフロー
344
227
 
345
228
  1. ブランチを作成
@@ -38,13 +38,11 @@
38
38
  "db:migrate": "turbo run db:migrate",
39
39
  "db:studio": "turbo run db:studio",
40
40
  "dev:setup": "tsx scripts/setup-dev.ts",
41
- "env:prepare": "tsx scripts/worktree/dev.ts --setup-only",
42
- "cli-template:update": "tsx scripts/cli-template-update.ts",
43
- "template:update": "tsx scripts/template-update.ts",
44
41
  "dotenvx": "dotenvx",
45
42
  "env:update": "tsx scripts/env.ts",
46
43
  "env:encrypt": "dotenvx encrypt",
47
44
  "env:show": "tsx scripts/env-show.ts",
45
+ "env:rotate-secrets": "tsx scripts/env-rotate-secrets.ts",
48
46
  "task:loop": "npx @einja/dev-cli task:loop",
49
47
  "einja:sync": "npx @einja/dev-cli sync"
50
48
  },
@@ -0,0 +1,336 @@
1
+ /**
2
+ * 秘密鍵ローテーションスクリプト
3
+ *
4
+ * AUTH_SECRETとDOTENV_PRIVATE_KEY_*のローテーションを可能にする
5
+ * 使用方法: pnpm env:rotate-secrets
6
+ */
7
+
8
+ import { execSync } from "node:child_process";
9
+ import crypto from "node:crypto";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import * as p from "@clack/prompts";
13
+ import {
14
+ type EnvironmentConfig,
15
+ ENVIRONMENTS,
16
+ parseEnvFile,
17
+ getPrivateKey,
18
+ ENV_KEYS_PATH,
19
+ } from "./lib/env-common.js";
20
+
21
+ const cwd = process.cwd();
22
+
23
+ /**
24
+ * ローテーションタイプ
25
+ */
26
+ type RotationType = "auth" | "dotenv" | "both";
27
+
28
+ /**
29
+ * AUTH_SECRETを生成
30
+ *
31
+ * @returns 生成されたAUTH_SECRET(64文字のランダム16進数文字列)
32
+ */
33
+ function generateAuthSecret(): string {
34
+ return crypto.randomBytes(32).toString("hex");
35
+ }
36
+
37
+ /**
38
+ * ローテーションする秘密鍵を選択
39
+ *
40
+ * @returns 選択されたローテーションタイプ
41
+ */
42
+ async function selectRotationType(): Promise<RotationType> {
43
+ const rotationType = await p.select({
44
+ message: "ローテーションする秘密鍵を選択してください",
45
+ options: [
46
+ {
47
+ value: "auth" as const,
48
+ label: "AUTH_SECRET",
49
+ hint: "NextAuth署名鍵",
50
+ },
51
+ {
52
+ value: "dotenv" as const,
53
+ label: "DOTENV_PRIVATE_KEY",
54
+ hint: "dotenvx暗号化鍵",
55
+ },
56
+ {
57
+ value: "both" as const,
58
+ label: "両方",
59
+ hint: "AUTH_SECRET と DOTENV_PRIVATE_KEY",
60
+ },
61
+ ],
62
+ });
63
+
64
+ if (p.isCancel(rotationType)) {
65
+ p.cancel("キャンセルしました");
66
+ process.exit(0);
67
+ }
68
+
69
+ return rotationType;
70
+ }
71
+
72
+ /**
73
+ * 対象環境を選択(複数選択可)
74
+ *
75
+ * @returns 選択された環境の配列
76
+ */
77
+ async function selectTargetEnvironments(): Promise<EnvironmentConfig[]> {
78
+ // 利用可能な環境をチェック
79
+ const availableEnvs = ENVIRONMENTS.filter((env) => {
80
+ const envFilePath = path.join(cwd, env.file);
81
+ const hasFile = fs.existsSync(envFilePath);
82
+ const hasKey = getPrivateKey(env.privateKeyEnv) !== null;
83
+ return hasFile && hasKey;
84
+ });
85
+
86
+ if (availableEnvs.length === 0) {
87
+ p.log.error("ローテーション可能な環境がありません");
88
+ p.log.info("環境ファイルと秘密鍵が必要です");
89
+ process.exit(1);
90
+ }
91
+
92
+ const envOptions = availableEnvs.map((env) => ({
93
+ value: env.name,
94
+ label: env.description,
95
+ hint: env.file,
96
+ }));
97
+
98
+ const selectedEnvs = await p.multiselect({
99
+ message: "対象環境を選択してください(スペースで選択、Enterで確定)",
100
+ options: envOptions,
101
+ required: true,
102
+ });
103
+
104
+ if (p.isCancel(selectedEnvs)) {
105
+ p.cancel("キャンセルしました");
106
+ process.exit(0);
107
+ }
108
+
109
+ const selected = ENVIRONMENTS.filter((env) =>
110
+ (selectedEnvs as string[]).includes(env.name),
111
+ );
112
+
113
+ // 本番環境が含まれる場合は追加確認
114
+ const includesProduction = selected.some((env) => env.name === "production");
115
+ if (includesProduction) {
116
+ p.log.warn("⚠️ 本番環境の秘密鍵を変更します");
117
+ const confirmProd = await p.confirm({
118
+ message: "本当に本番環境の秘密鍵を変更しますか?",
119
+ initialValue: false,
120
+ });
121
+
122
+ if (p.isCancel(confirmProd) || !confirmProd) {
123
+ p.cancel("キャンセルしました");
124
+ process.exit(0);
125
+ }
126
+ }
127
+
128
+ return selected;
129
+ }
130
+
131
+ /**
132
+ * AUTH_SECRETをローテーション
133
+ *
134
+ * @param env - 対象環境
135
+ */
136
+ async function rotateAuthSecret(env: EnvironmentConfig): Promise<void> {
137
+ const envFilePath = path.join(cwd, env.file);
138
+ const privateKey = getPrivateKey(env.privateKeyEnv);
139
+
140
+ if (!privateKey) {
141
+ throw new Error(
142
+ `.env.keys に ${env.privateKeyEnv} が見つかりません`,
143
+ );
144
+ }
145
+
146
+ // dotenvx実行時の環境変数
147
+ const dotenvxEnv = { ...process.env, [env.privateKeyEnv]: privateKey };
148
+
149
+ // 復号
150
+ const decrypted = execSync(`dotenvx decrypt -f ${env.file} --stdout`, {
151
+ cwd,
152
+ encoding: "utf-8",
153
+ env: dotenvxEnv,
154
+ });
155
+
156
+ // 新しいAUTH_SECRETを生成
157
+ const newAuthSecret = generateAuthSecret();
158
+
159
+ // AUTH_SECRET行を置換
160
+ const updatedContent = decrypted.replace(
161
+ /^AUTH_SECRET=.*$/m,
162
+ `AUTH_SECRET=${newAuthSecret}`,
163
+ );
164
+
165
+ // テンポラリファイルに書き込み
166
+ const tmpPath = path.join(cwd, `${env.file}.tmp`);
167
+ fs.writeFileSync(tmpPath, updatedContent);
168
+
169
+ try {
170
+ // 元のファイルを削除してリネーム
171
+ fs.unlinkSync(envFilePath);
172
+ fs.renameSync(tmpPath, envFilePath);
173
+
174
+ // 再暗号化
175
+ execSync(`dotenvx encrypt -f ${env.file}`, {
176
+ cwd,
177
+ stdio: "pipe",
178
+ env: dotenvxEnv,
179
+ });
180
+
181
+ p.log.success(`✅ AUTH_SECRET をローテーションしました (${env.name})`);
182
+ } catch (error) {
183
+ // テンポラリファイルを削除
184
+ if (fs.existsSync(tmpPath)) {
185
+ fs.unlinkSync(tmpPath);
186
+ }
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * DOTENV_PRIVATE_KEYをローテーション
193
+ *
194
+ * @param env - 対象環境
195
+ */
196
+ async function rotateDotenvKey(env: EnvironmentConfig): Promise<void> {
197
+ const envFilePath = path.join(cwd, env.file);
198
+ const privateKey = getPrivateKey(env.privateKeyEnv);
199
+
200
+ if (!privateKey) {
201
+ throw new Error(
202
+ `.env.keys に ${env.privateKeyEnv} が見つかりません`,
203
+ );
204
+ }
205
+
206
+ // dotenvx実行時の環境変数
207
+ const dotenvxEnv = { ...process.env, [env.privateKeyEnv]: privateKey };
208
+
209
+ // dotenvx rotate を実行(新しいキーペアが自動生成され、.env.keysが自動更新される)
210
+ execSync(`dotenvx rotate -f ${env.file}`, {
211
+ cwd,
212
+ stdio: "pipe",
213
+ env: dotenvxEnv,
214
+ });
215
+
216
+ p.log.success(`✅ DOTENV_PRIVATE_KEY をローテーションしました (${env.name})`);
217
+ }
218
+
219
+ /**
220
+ * エラー時復元付きでローテーションを実行
221
+ *
222
+ * @param env - 対象環境
223
+ * @param type - ローテーションタイプ
224
+ */
225
+ async function rotateWithRecovery(
226
+ env: EnvironmentConfig,
227
+ type: RotationType,
228
+ ): Promise<void> {
229
+ const envFilePath = path.join(cwd, env.file);
230
+ const backupPath = path.join(cwd, `${env.file}.bak`);
231
+ const keysBackupPath = path.join(cwd, ".env.keys.bak");
232
+
233
+ // バックアップ作成
234
+ fs.copyFileSync(envFilePath, backupPath);
235
+ fs.copyFileSync(ENV_KEYS_PATH, keysBackupPath);
236
+
237
+ try {
238
+ if (type === "auth" || type === "both") {
239
+ await rotateAuthSecret(env);
240
+ }
241
+
242
+ if (type === "dotenv" || type === "both") {
243
+ await rotateDotenvKey(env);
244
+ }
245
+
246
+ // 成功したらバックアップを削除
247
+ fs.unlinkSync(backupPath);
248
+ if (type === "dotenv" || type === "both") {
249
+ fs.unlinkSync(keysBackupPath);
250
+ }
251
+ } catch (error) {
252
+ // エラー発生時はバックアップから復元
253
+ p.log.error(`❌ エラーが発生しました: ${error}`);
254
+ if (fs.existsSync(backupPath)) {
255
+ fs.copyFileSync(backupPath, envFilePath);
256
+ fs.unlinkSync(backupPath);
257
+ }
258
+ if (fs.existsSync(keysBackupPath)) {
259
+ fs.copyFileSync(keysBackupPath, ENV_KEYS_PATH);
260
+ fs.unlinkSync(keysBackupPath);
261
+ }
262
+ p.log.info("元のファイルを復元しました");
263
+ throw error;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * 次のステップを表示
269
+ *
270
+ * @param envs - ローテーションした環境の配列
271
+ */
272
+ function showNextSteps(envs: EnvironmentConfig[]): void {
273
+ const envFiles = envs.map((env) => env.file).join(" ");
274
+
275
+ p.note(
276
+ [
277
+ `git diff ${envFiles} .env.keys`,
278
+ `git add ${envFiles} .env.keys`,
279
+ 'git commit -m "chore: 秘密鍵をローテーション"',
280
+ "",
281
+ ".env.keys をチームと共有(1Password等)",
282
+ ].join("\n"),
283
+ "💡 次のステップ",
284
+ );
285
+ }
286
+
287
+ /**
288
+ * メイン処理
289
+ */
290
+ async function main(): Promise<void> {
291
+ p.intro("🔐 秘密鍵ローテーション");
292
+
293
+ // ローテーションタイプを選択
294
+ const rotationType = await selectRotationType();
295
+
296
+ // 対象環境を選択
297
+ const targetEnvs = await selectTargetEnvironments();
298
+
299
+ // 確認
300
+ const proceed = await p.confirm({
301
+ message: "ローテーションを実行しますか?",
302
+ initialValue: true,
303
+ });
304
+
305
+ if (p.isCancel(proceed) || !proceed) {
306
+ p.cancel("キャンセルしました");
307
+ process.exit(0);
308
+ }
309
+
310
+ const spinner = p.spinner();
311
+
312
+ // バックアップを作成中
313
+ spinner.start("バックアップを作成中...");
314
+ await new Promise((resolve) => setTimeout(resolve, 500));
315
+ spinner.stop("バックアップを作成しました");
316
+
317
+ // 各環境でローテーション実行
318
+ for (const env of targetEnvs) {
319
+ try {
320
+ await rotateWithRecovery(env, rotationType);
321
+ } catch (error) {
322
+ p.log.error(`${env.name} のローテーションに失敗しました`);
323
+ process.exit(1);
324
+ }
325
+ }
326
+
327
+ // 次のステップを表示
328
+ showNextSteps(targetEnvs);
329
+
330
+ p.outro("✅ 完了");
331
+ }
332
+
333
+ main().catch((error: unknown) => {
334
+ p.log.error(`エラーが発生しました: ${error}`);
335
+ process.exit(1);
336
+ });
@@ -9,6 +9,13 @@ import { execSync } from "node:child_process";
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
11
  import * as p from "@clack/prompts";
12
+ import {
13
+ type EnvironmentConfig,
14
+ ENVIRONMENTS,
15
+ parseEnvFile,
16
+ getPrivateKey,
17
+ ENV_KEYS_PATH,
18
+ } from "./lib/env-common.js";
12
19
 
13
20
  const cwd = process.cwd();
14
21
 
@@ -17,79 +24,6 @@ const ENV_PATH = path.join(cwd, ".env");
17
24
  const ENV_LOCAL_PATH = path.join(cwd, ".env.local");
18
25
  const ENV_PERSONAL_PATH = path.join(cwd, ".env.personal");
19
26
  const ENV_PERSONAL_EXAMPLE_PATH = path.join(cwd, ".env.personal.example");
20
- const ENV_KEYS_PATH = path.join(cwd, ".env.keys");
21
-
22
- // 環境定義
23
- interface EnvironmentConfig {
24
- name: string;
25
- file: string;
26
- privateKeyEnv: string;
27
- description: string;
28
- }
29
-
30
- const ENVIRONMENTS: EnvironmentConfig[] = [
31
- {
32
- name: "local",
33
- file: ".env.local",
34
- privateKeyEnv: "DOTENV_PRIVATE_KEY_LOCAL",
35
- description: "ローカル開発環境",
36
- },
37
- {
38
- name: "development",
39
- file: ".env.development",
40
- privateKeyEnv: "DOTENV_PRIVATE_KEY_DEVELOPMENT",
41
- description: "開発環境",
42
- },
43
- {
44
- name: "staging",
45
- file: ".env.staging",
46
- privateKeyEnv: "DOTENV_PRIVATE_KEY_STAGING",
47
- description: "ステージング環境",
48
- },
49
- {
50
- name: "production",
51
- file: ".env.production",
52
- privateKeyEnv: "DOTENV_PRIVATE_KEY_PRODUCTION",
53
- description: "本番環境",
54
- },
55
- {
56
- name: "ci",
57
- file: ".env.ci",
58
- privateKeyEnv: "DOTENV_PRIVATE_KEY_CI",
59
- description: "CI環境",
60
- },
61
- ];
62
-
63
- /**
64
- * 環境変数ファイルを読み込んでパース
65
- */
66
- function parseEnvFile(filePath: string): Record<string, string> {
67
- if (!fs.existsSync(filePath)) {
68
- return {};
69
- }
70
- const content = fs.readFileSync(filePath, "utf-8");
71
- const result: Record<string, string> = {};
72
-
73
- for (const line of content.split("\n")) {
74
- const trimmed = line.trim();
75
- if (!trimmed || trimmed.startsWith("#")) continue;
76
-
77
- const match = trimmed.match(/^([^=]+)=(.*)$/);
78
- if (match) {
79
- const key = match[1].trim();
80
- let value = match[2].trim();
81
- // クォートを除去
82
- if (
83
- (value.startsWith('"') && value.endsWith('"')) ||
84
- (value.startsWith("'") && value.endsWith("'"))
85
- ) {
86
- value = value.slice(1, -1);
87
- }
88
- result[key] = value;
89
- }
90
- }
91
- return result;
92
- }
93
27
 
94
28
  /**
95
29
  * 環境変数ファイルに値を設定
@@ -253,16 +187,6 @@ async function setupPersonalTokens(): Promise<void> {
253
187
  );
254
188
  }
255
189
 
256
- /**
257
- * .env.keysから指定された環境の秘密鍵を取得
258
- */
259
- function getPrivateKey(privateKeyEnv: string): string | null {
260
- if (!fs.existsSync(ENV_KEYS_PATH)) {
261
- return null;
262
- }
263
- const keys = parseEnvFile(ENV_KEYS_PATH);
264
- return keys[privateKeyEnv] || null;
265
- }
266
190
 
267
191
  /**
268
192
  * 環境設定を変更(汎用)