create-einja-app 0.2.4 → 0.2.8
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/dist/cli.js +6 -3
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/default/.gitattributes +14 -0
- package/templates/default/.github/ISSUE_TEMPLATE/task-implementation.yml +128 -0
- package/templates/default/README.md +0 -117
- package/templates/default/package.json +1 -3
- package/templates/default/scripts/env-rotate-secrets.ts +336 -0
- package/templates/default/scripts/env.ts +7 -83
- package/templates/default/scripts/lib/env-common.ts +108 -0
- package/templates/default/scripts/template-update.ts +41 -1
package/package.json
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
name: タスク実装
|
|
2
|
+
description: 新機能・バグ修正のタスク管理用Issue
|
|
3
|
+
title: "[機能名]"
|
|
4
|
+
labels: ["task"]
|
|
5
|
+
body:
|
|
6
|
+
- type: markdown
|
|
7
|
+
attributes:
|
|
8
|
+
value: |
|
|
9
|
+
## タスク実装用Issue
|
|
10
|
+
|
|
11
|
+
このテンプレートは新機能やバグ修正のタスク管理に使用します。
|
|
12
|
+
|
|
13
|
+
- type: textarea
|
|
14
|
+
id: as-is
|
|
15
|
+
attributes:
|
|
16
|
+
label: AS-IS(現状)
|
|
17
|
+
description: requirements.mdから抽出した現在の状態・問題点
|
|
18
|
+
placeholder: |
|
|
19
|
+
現在の実装状況と課題を記載
|
|
20
|
+
|
|
21
|
+
**現在の実装状況**:
|
|
22
|
+
- 状況1
|
|
23
|
+
- 状況2
|
|
24
|
+
|
|
25
|
+
**現状の課題**:
|
|
26
|
+
- 課題1
|
|
27
|
+
- 課題2
|
|
28
|
+
validations:
|
|
29
|
+
required: true
|
|
30
|
+
|
|
31
|
+
- type: textarea
|
|
32
|
+
id: to-be
|
|
33
|
+
attributes:
|
|
34
|
+
label: TO-BE(目標状態)
|
|
35
|
+
description: requirements.mdから抽出した実装後の期待する状態
|
|
36
|
+
placeholder: |
|
|
37
|
+
実現したい姿と期待される改善を記載
|
|
38
|
+
|
|
39
|
+
**実現したい姿**:
|
|
40
|
+
- 姿1
|
|
41
|
+
- 姿2
|
|
42
|
+
|
|
43
|
+
**期待される改善**:
|
|
44
|
+
- 改善1
|
|
45
|
+
- 改善2
|
|
46
|
+
validations:
|
|
47
|
+
required: true
|
|
48
|
+
|
|
49
|
+
- type: textarea
|
|
50
|
+
id: approach
|
|
51
|
+
attributes:
|
|
52
|
+
label: 対応方針
|
|
53
|
+
description: design.mdから抽出した技術的アプローチ、使用ライブラリ、実装方針
|
|
54
|
+
placeholder: |
|
|
55
|
+
技術選定、アーキテクチャ、実装方針を記載
|
|
56
|
+
|
|
57
|
+
**技術スタック**:
|
|
58
|
+
- フレームワーク: Next.js 15
|
|
59
|
+
- データベース: PostgreSQL
|
|
60
|
+
- 認証: NextAuth.js
|
|
61
|
+
|
|
62
|
+
**主要な実装方針**:
|
|
63
|
+
- 方針1
|
|
64
|
+
- 方針2
|
|
65
|
+
validations:
|
|
66
|
+
required: true
|
|
67
|
+
|
|
68
|
+
- type: textarea
|
|
69
|
+
id: tasks
|
|
70
|
+
attributes:
|
|
71
|
+
label: タスク一覧
|
|
72
|
+
description: 実装するタスクグループのリスト
|
|
73
|
+
placeholder: |
|
|
74
|
+
### Phase 1: [フェーズ名]
|
|
75
|
+
|
|
76
|
+
- [ ] 1.1 [タスクグループ名]
|
|
77
|
+
|
|
78
|
+
- 1.1.1 [タスク名]
|
|
79
|
+
- サブタスク内容
|
|
80
|
+
- **要件**: Story 1
|
|
81
|
+
- **依存関係**: なし
|
|
82
|
+
- **完了条件**: [テスト条件]が通ること(AC1.1を満たす)
|
|
83
|
+
- **対応設計**: design.md「[セクション名]」セクション
|
|
84
|
+
- **シナリオテスト**: なし(基盤構築タスク、UIフロー未実装のため)
|
|
85
|
+
|
|
86
|
+
- 1.1.2 [タスク名]
|
|
87
|
+
- サブタスク内容
|
|
88
|
+
- **要件**: Story 1
|
|
89
|
+
- **依存関係**: 1.1.1
|
|
90
|
+
- **完了条件**: [テスト条件]が通ること(AC1.2を満たす)
|
|
91
|
+
- **対応設計**: design.md「[セクション名]」セクション
|
|
92
|
+
- **シナリオテスト**: シナリオ1 Step 1-3(部分実行)
|
|
93
|
+
|
|
94
|
+
- [ ] 1.2 [タスクグループ名]
|
|
95
|
+
|
|
96
|
+
- 1.2.1 [タスク名]
|
|
97
|
+
- サブタスク内容
|
|
98
|
+
- **要件**: Story 2
|
|
99
|
+
- **依存関係**: 1.1
|
|
100
|
+
- **完了条件**: [テスト条件]が通ること(AC2.1を満たす)
|
|
101
|
+
- **対応設計**: design.md「[セクション名]」セクション
|
|
102
|
+
- **シナリオテスト**: シナリオ1 全Step(フル実行)
|
|
103
|
+
|
|
104
|
+
### Phase 2: [フェーズ名]
|
|
105
|
+
|
|
106
|
+
- [ ] 2.1 [タスクグループ名]
|
|
107
|
+
|
|
108
|
+
- 2.1.1 [タスク名]
|
|
109
|
+
- サブタスク内容
|
|
110
|
+
- **要件**: Story 3
|
|
111
|
+
- **依存関係**: Phase 1完了
|
|
112
|
+
- **完了条件**: [テスト条件]が通ること(AC3.1を満たす)
|
|
113
|
+
- **対応設計**: design.md「[セクション名]」セクション
|
|
114
|
+
- **シナリオテスト**: シナリオ2 全Step
|
|
115
|
+
value: |
|
|
116
|
+
### Phase 1: [フェーズ名]
|
|
117
|
+
|
|
118
|
+
- [ ] 1.1 [タスクグループ名]
|
|
119
|
+
|
|
120
|
+
- 1.1.1 [タスク名]
|
|
121
|
+
- [サブタスク内容]
|
|
122
|
+
- **要件**: Story 1
|
|
123
|
+
- **依存関係**: なし
|
|
124
|
+
- **完了条件**: [テスト条件](AC1.1を満たす)
|
|
125
|
+
- **対応設計**: design.md「[セクション名]」セクション
|
|
126
|
+
- **シナリオテスト**: なし(理由を記載)
|
|
127
|
+
validations:
|
|
128
|
+
required: true
|
|
@@ -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
|
+
});
|