@c-time/frelio-cli 1.4.5 → 1.4.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/README.md CHANGED
@@ -157,7 +157,7 @@ frelio init
157
157
  | 本番 URL | `--production-url` | 公開サイトの URL(任意) | `https://example.com` |
158
158
  | ステージングドメイン | `--staging-domain` | ステージング確認用ドメイン(任意) | `staging-abc123.example.com` |
159
159
  | R2 バケット名 | `--r2-bucket-name` | ファイルストレージ用バケット | `my-site-files` |
160
- | R2 公開 URL | `--r2-public-url` | ファイル配信の URL | `https://storage.example.com` |
160
+ | R2 公開 URL | `--r2-public-url` | アップロードファイル(R2)の配信元 URL(任意)。R2 のカスタムドメイン推奨、`pub-xxxx.r2.dev` も可。空欄なら後から設定可 | `https://files.example.com` |
161
161
  | 管理者 GitHub ユーザー名 | `--owner-username` | 最初の管理者ユーザー | `your-username` |
162
162
  | OAuth Client ID | `--client-id` | GitHub OAuth App の Client ID | `Ov23li...` |
163
163
  | OAuth Client Secret | `--client-secret` | GitHub OAuth App の Client Secret | `********` |
@@ -438,6 +438,7 @@ Pages プロジェクトの設定でプレビューデプロイメントのア
438
438
  - **Application name**: `My Site CMS`(任意)
439
439
  - **Homepage URL**: `https://<pages-project>.pages.dev`
440
440
  - **Authorization callback URL**: `https://<pages-project>.pages.dev/api/auth/callback`
441
+ - **Enable Device Flow**: チェックしない(Disable のまま。Frelio は認可コードフローを使用するため不要)
441
442
  4. **Register application** をクリック
442
443
  5. **Client ID** をメモ
443
444
  6. **Generate a new client secret** → **Client Secret** をメモ
@@ -105,7 +105,10 @@ export async function addStagingCommand(options) {
105
105
  log('');
106
106
  log(` ブランチ: ${branchName}`);
107
107
  if (pagesProjectName) {
108
+ // GitHub Actions の wrangler デプロイ(--branch)が生成するブランチエイリアス URL。
109
+ // 初回の Actions デプロイ完了後に有効になる。
108
110
  log(` プレビュー URL: https://${branchName}.${pagesProjectName}.pages.dev`);
111
+ log(' (GitHub Actions の初回デプロイ後に有効。Cloudflare の Git 連携プレビューではない)');
109
112
  }
110
113
  log('');
111
114
  log(' 残りの手動作業:');
@@ -17,6 +17,8 @@ type InitOptions = {
17
17
  ownerUsername?: string;
18
18
  clientId?: string;
19
19
  clientSecret?: string;
20
+ cloudflareAccountId?: string;
21
+ cloudflareApiToken?: string;
20
22
  };
21
23
  export declare function initCommand(options: InitOptions): Promise<void>;
22
24
  export {};
@@ -9,6 +9,7 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { log, logStep, logSuccess, logError } from '../lib/shell.js';
11
11
  import { generateStagingDomain } from '../lib/templates.js';
12
+ import { buildResourceLinks } from '../lib/resource-links.js';
12
13
  import { validateContentRepo, validateR2PublicUrl, validateRequired } from '../lib/validators.js';
13
14
  // core functions
14
15
  import { checkPrerequisitesFor } from '../core/prerequisites.js';
@@ -19,7 +20,7 @@ import { createFullContentStructure } from '../core/content-structure.js';
19
20
  import { regenerateAllConfigFiles } from '../core/file-generators.js';
20
21
  import { generateTerraformFiles } from '../core/terraform.js';
21
22
  import { installBundle } from '../core/bundle.js';
22
- import { initialCommitAndBranches } from '../core/git-operations.js';
23
+ import { initialCommitAndBranches, getGitIdentity, setGitIdentity, } from '../core/git-operations.js';
23
24
  export async function initCommand(options) {
24
25
  log('');
25
26
  log('🚀 Frelio CMS プロジェクトセットアップ');
@@ -65,6 +66,7 @@ export async function initCommand(options) {
65
66
  log(` - Application name: ${config.siteTitle || config.adminPagesProjectName} CMS`);
66
67
  log(` - Homepage URL: https://${config.adminPagesProjectName}.pages.dev`);
67
68
  log(` - Callback URL: https://${config.adminPagesProjectName}.pages.dev/api/auth/callback`);
69
+ log(' - Enable Device Flow: チェックしない(Disable のまま。Frelio は認可コードフローを使用)');
68
70
  log('');
69
71
  const oauthResponse = await prompts([
70
72
  {
@@ -177,7 +179,8 @@ export async function initCommand(options) {
177
179
  step++;
178
180
  logStep(step, totalSteps, 'Cloudflare セットアップ...');
179
181
  const secret = config.githubClientSecret;
180
- const cfResult = setupCloudflareResources(config, secret);
182
+ const cfToken = config.cloudflareApiToken;
183
+ const cfResult = setupCloudflareResources(config, secret, cfToken);
181
184
  if (cfResult.success) {
182
185
  const { r2Bucket, contentPages, adminPages, secrets } = cfResult.data;
183
186
  if (r2Bucket.success) {
@@ -214,6 +217,12 @@ export async function initCommand(options) {
214
217
  }
215
218
  }
216
219
  }
220
+ // git identity 確認(commit 直前 — 未設定だとデフォルト値で commit author が埋まるのを防ぐ)
221
+ if (!options.skipGithub) {
222
+ step++;
223
+ logStep(step, totalSteps, 'git identity 確認...');
224
+ await ensureGitIdentity(projectDir, config.ownerUsername);
225
+ }
217
226
  // ブランチ構造とプッシュ
218
227
  if (!options.skipGithub) {
219
228
  step++;
@@ -231,8 +240,25 @@ export async function initCommand(options) {
231
240
  log('✅ セットアップ完了!');
232
241
  log('');
233
242
  log(` 管理画面: https://${config.adminPagesProjectName}.pages.dev/admin/`);
234
- log(` プレビュー: https://staging.${config.pagesProjectName}.pages.dev`);
243
+ // カスタムドメイン未設定時は staging ブランチのプレビューエイリアス(GitHub Actions の
244
+ // wrangler デプロイで生成)を案内する。Cloudflare の Git 連携ブランチプレビューは使わない。
245
+ log(` プレビュー: ${config.previewUrl || `https://staging.${config.pagesProjectName}.pages.dev`}`);
235
246
  log('');
247
+ // 作成したリソースへのリンク(確認・設定変更の導線)
248
+ const resourceSections = buildResourceLinks(config, {
249
+ github: !options.skipGithub,
250
+ cloudflare: !options.terraform && !options.skipCloudflare,
251
+ });
252
+ if (resourceSections.length > 0) {
253
+ log('🔗 作成したリソース:');
254
+ for (const section of resourceSections) {
255
+ log(` ${section.title}:`);
256
+ for (const link of section.links) {
257
+ log(` ✓ ${link.label}: ${link.url}`);
258
+ }
259
+ }
260
+ log('');
261
+ }
236
262
  if (options.terraform) {
237
263
  log(' Terraform でインフラを構築:');
238
264
  log(` 1. cd ${repoName}/terraform`);
@@ -259,6 +285,54 @@ export async function initCommand(options) {
259
285
  // ---------------------------------------------------------------------------
260
286
  // UI helpers (prompts — commands 層に閉じる)
261
287
  // ---------------------------------------------------------------------------
288
+ /**
289
+ * commit 直前に git identity(user.name / user.email)を確認する。
290
+ * 未設定の場合:
291
+ * - 対話モード: 入力を促し、クローンしたリポジトリのローカルスコープに設定する
292
+ * - 非対話モード: 警告して続行(git のデフォルト値で commit される旨を案内)
293
+ */
294
+ async function ensureGitIdentity(projectDir, ownerUsername) {
295
+ const { name, email } = getGitIdentity(projectDir);
296
+ if (name && email) {
297
+ logSuccess('git identity 設定済み');
298
+ return;
299
+ }
300
+ const isInteractive = process.stdin.isTTY === true;
301
+ if (!isInteractive) {
302
+ log(' ⚠ git user.name / user.email が未設定です。');
303
+ log(' このまま進めると commit author にデフォルト値(ホスト名由来)が使われます。');
304
+ log(' 後で `git config user.name` / `user.email` を設定し直してください。');
305
+ return;
306
+ }
307
+ log(' ⚠ git user.name / user.email が未設定です。commit author を確定するため入力してください。');
308
+ const response = await prompts([
309
+ {
310
+ type: 'text',
311
+ name: 'name',
312
+ message: 'git user.name:',
313
+ initial: name || ownerUsername || '',
314
+ validate: (v) => validateRequired(v, 'user.name'),
315
+ },
316
+ {
317
+ type: 'text',
318
+ name: 'email',
319
+ message: 'git user.email:',
320
+ initial: email || '',
321
+ validate: (v) => validateRequired(v, 'user.email'),
322
+ },
323
+ ], { onCancel: () => process.exit(0) });
324
+ if (!response.name || !response.email) {
325
+ log('セットアップをキャンセルしました。');
326
+ process.exit(0);
327
+ }
328
+ const result = setGitIdentity(projectDir, response.name, response.email);
329
+ if (result.success) {
330
+ logSuccess('git identity を設定しました(このリポジトリのみ)');
331
+ }
332
+ else {
333
+ logError(result.error);
334
+ }
335
+ }
262
336
  async function promptConfig(options) {
263
337
  const isInteractive = process.stdin.isTTY === true;
264
338
  // 必須オプションが揃っている場合 → プロンプトスキップ
@@ -302,10 +376,7 @@ async function promptConfig(options) {
302
376
  type: options.stagingDomain !== undefined ? null : 'text',
303
377
  name: 'stagingDomain',
304
378
  message: 'ステージングのドメイン(推測困難なハッシュ付き推奨):',
305
- initial: (_prev, values) => {
306
- const repo = (options.contentRepo || values.contentRepo)?.split('/')[1] || 'site';
307
- return generateStagingDomain((options.productionUrl || values.productionUrl) || '', repo);
308
- },
379
+ initial: (_prev, values) => generateStagingDomain((options.productionUrl || values.productionUrl) || ''),
309
380
  },
310
381
  {
311
382
  type: options.r2BucketName !== undefined ? null : 'text',
@@ -317,9 +388,19 @@ async function promptConfig(options) {
317
388
  },
318
389
  },
319
390
  {
320
- type: options.r2PublicUrl !== undefined ? null : 'text',
391
+ type: options.r2PublicUrl !== undefined
392
+ ? null
393
+ : () => {
394
+ log('');
395
+ log(' ℹ R2 公開 URL(任意)');
396
+ log(' アップロードした画像・ファイル(Cloudflare R2)を配信する公開 URL です。');
397
+ log(' 推奨: R2 に割り当てたカスタムドメイン 例) https://files.example.com');
398
+ log(' 代替: R2.dev の開発用 URL 例) https://pub-xxxxxxxx.r2.dev');
399
+ log(' 未入力なら空欄のまま Enter(後から admin/config.json で設定可)');
400
+ return 'text';
401
+ },
321
402
  name: 'r2PublicUrl',
322
- message: 'R2 公開 URL:',
403
+ message: 'R2 公開 URL (任意, 例: https://files.example.com):',
323
404
  initial: '',
324
405
  validate: (v) => validateR2PublicUrl(v),
325
406
  },
@@ -332,6 +413,36 @@ async function promptConfig(options) {
332
413
  return userResult.success ? userResult.data.username : '';
333
414
  },
334
415
  },
416
+ {
417
+ type: options.cloudflareAccountId !== undefined
418
+ ? null
419
+ : () => {
420
+ log('');
421
+ log(' ℹ Cloudflare アカウント ID / API トークン(デプロイ管理機能 / Issue #27)');
422
+ log(' 管理画面のデプロイ履歴で Cloudflare Pages のデプロイ一覧・削除を行うために使用します。');
423
+ log(' アカウント ID: Cloudflare ダッシュボードの URL や任意ページの右サイドバーで確認できます。');
424
+ log(' 未入力なら空欄のまま Enter(後から admin/config.json と wrangler.toml で設定可)');
425
+ return 'text';
426
+ },
427
+ name: 'cloudflareAccountId',
428
+ message: 'Cloudflare アカウント ID (任意):',
429
+ initial: '',
430
+ },
431
+ {
432
+ type: options.cloudflareApiToken !== undefined
433
+ ? null
434
+ : () => {
435
+ log('');
436
+ log(' ℹ Cloudflare API トークン(任意・シークレット)');
437
+ log(' スコープ: Account → Cloudflare Pages: Edit(一覧取得+削除に必要)');
438
+ log(' Dashboard → My Profile → API Tokens → Create Token で発行できます。');
439
+ log(' 管理画面 Pages プロジェクトのシークレットとして登録されます(コミットされません)。');
440
+ return 'password';
441
+ },
442
+ name: 'cloudflareApiToken',
443
+ message: 'Cloudflare API トークン (任意):',
444
+ initial: '',
445
+ },
335
446
  ], { onCancel: () => process.exit(0) });
336
447
  const contentRepo = options.contentRepo || response.contentRepo;
337
448
  if (!contentRepo)
@@ -351,10 +462,15 @@ async function promptConfig(options) {
351
462
  adminPagesProjectName: `${repoName}-admin`,
352
463
  ownerUsername: options.ownerUsername ?? response.ownerUsername ?? '',
353
464
  stagingDomain,
465
+ cloudflareAccountId: options.cloudflareAccountId ?? response.cloudflareAccountId ?? '',
354
466
  };
355
467
  if (options.clientSecret) {
356
468
  config.githubClientSecret = options.clientSecret;
357
469
  }
470
+ const cloudflareApiToken = options.cloudflareApiToken ?? response.cloudflareApiToken;
471
+ if (cloudflareApiToken) {
472
+ config.cloudflareApiToken = cloudflareApiToken;
473
+ }
358
474
  return config;
359
475
  }
360
476
  function buildConfigFromOptions(options) {
@@ -367,6 +483,7 @@ function buildConfigFromOptions(options) {
367
483
  r2BucketName: options.r2BucketName,
368
484
  r2PublicUrl: options.r2PublicUrl,
369
485
  ownerUsername: options.ownerUsername,
486
+ cloudflareAccountId: options.cloudflareAccountId,
370
487
  });
371
488
  if (!result.success) {
372
489
  logError(result.error);
@@ -383,12 +500,13 @@ function buildConfigFromOptions(options) {
383
500
  return {
384
501
  ...config,
385
502
  githubClientSecret: options.clientSecret,
503
+ cloudflareApiToken: options.cloudflareApiToken,
386
504
  };
387
505
  }
388
506
  function getTotalSteps(options) {
389
507
  let steps = 3; // content structure (includes workflows), bundle, config files
390
508
  if (!options.skipGithub)
391
- steps += 3; // create repo, clone, commit/push
509
+ steps += 4; // create repo, clone, git identity, commit/push
392
510
  if (options.terraform)
393
511
  steps += 1;
394
512
  else if (!options.skipCloudflare)
@@ -0,0 +1,16 @@
1
+ /**
2
+ * frelio set-domain - 公開後のドメイン / URL 変更(Issue #24)
3
+ *
4
+ * config.json を正本として本番 URL / プレビュー URL / R2 公開 URL を更新し、
5
+ * 派生ファイル(wrangler.toml ×2 等)を一括再生成する薄いオーケストレーター。
6
+ * core/domain の changeDomain を呼ぶだけで、新規ロジックは持たない。
7
+ */
8
+ type SetDomainOptions = {
9
+ productionUrl?: string;
10
+ previewUrl?: string;
11
+ stagingDomain?: string;
12
+ r2PublicUrl?: string;
13
+ regenStagingHash?: boolean;
14
+ };
15
+ export declare function setDomainCommand(options: SetDomainOptions): Promise<void>;
16
+ export {};
@@ -0,0 +1,141 @@
1
+ /**
2
+ * frelio set-domain - 公開後のドメイン / URL 変更(Issue #24)
3
+ *
4
+ * config.json を正本として本番 URL / プレビュー URL / R2 公開 URL を更新し、
5
+ * 派生ファイル(wrangler.toml ×2 等)を一括再生成する薄いオーケストレーター。
6
+ * core/domain の changeDomain を呼ぶだけで、新規ロジックは持たない。
7
+ */
8
+ import prompts from 'prompts';
9
+ import { log, logSuccess, logError } from '../lib/shell.js';
10
+ import { validateProductionUrl, validateR2PublicUrl } from '../lib/validators.js';
11
+ import { readConfig } from '../core/config.js';
12
+ import { changeDomain } from '../core/domain.js';
13
+ export async function setDomainCommand(options) {
14
+ log('');
15
+ log('🌐 ドメイン / URL の変更');
16
+ log('');
17
+ const projectDir = process.cwd();
18
+ // config.json の存在チェック
19
+ const configResult = readConfig(projectDir);
20
+ if (!configResult.success) {
21
+ logError(configResult.error);
22
+ process.exit(1);
23
+ }
24
+ if (!configResult.data.config) {
25
+ logError('admin/config.json が見つかりません。Frelio プロジェクトのルートで実行してください。');
26
+ process.exit(1);
27
+ }
28
+ const current = configResult.data.config;
29
+ const hasFlags = options.productionUrl !== undefined ||
30
+ options.previewUrl !== undefined ||
31
+ options.stagingDomain !== undefined ||
32
+ options.r2PublicUrl !== undefined;
33
+ // フラグ検証(指定されたもののみ)
34
+ if (options.productionUrl !== undefined) {
35
+ const c = validateProductionUrl(options.productionUrl);
36
+ if (c !== true) {
37
+ logError(`--production-url: ${c}`);
38
+ process.exit(1);
39
+ }
40
+ }
41
+ if (options.r2PublicUrl !== undefined) {
42
+ const c = validateR2PublicUrl(options.r2PublicUrl);
43
+ if (c !== true) {
44
+ logError(`--r2-public-url: ${c}`);
45
+ process.exit(1);
46
+ }
47
+ }
48
+ const isInteractive = process.stdin.isTTY === true;
49
+ const change = {};
50
+ if (hasFlags) {
51
+ if (options.productionUrl !== undefined)
52
+ change.productionUrl = options.productionUrl;
53
+ if (options.previewUrl !== undefined)
54
+ change.previewUrl = options.previewUrl;
55
+ if (options.stagingDomain !== undefined)
56
+ change.stagingDomain = options.stagingDomain;
57
+ if (options.r2PublicUrl !== undefined)
58
+ change.r2PublicUrl = options.r2PublicUrl;
59
+ if (options.regenStagingHash)
60
+ change.regenStagingHash = true;
61
+ }
62
+ else {
63
+ if (!isInteractive) {
64
+ logError('非対話モードでは少なくとも 1 つの URL フラグが必要です。');
65
+ logError('使用例: frelio set-domain --production-url https://example.com --r2-public-url https://files.example.com');
66
+ process.exit(1);
67
+ }
68
+ log(' 現在の値(空欄のままで変更なし):');
69
+ log(` 本番 URL : ${current.productionUrl || '(未設定)'}`);
70
+ log(` プレビュー URL: ${current.previewUrl || '(未設定)'}`);
71
+ log(` R2 公開 URL : ${current.r2PublicUrl || '(未設定)'}`);
72
+ log('');
73
+ const res = await prompts([
74
+ {
75
+ type: 'text',
76
+ name: 'productionUrl',
77
+ message: '新しい本番 URL(変更しない場合は空欄):',
78
+ initial: current.productionUrl || '',
79
+ validate: (v) => validateProductionUrl(v),
80
+ },
81
+ {
82
+ type: 'text',
83
+ name: 'r2PublicUrl',
84
+ message: '新しい R2 公開 URL(変更しない場合は空欄):',
85
+ initial: current.r2PublicUrl || '',
86
+ validate: (v) => validateR2PublicUrl(v),
87
+ },
88
+ {
89
+ type: (_, values) => values.productionUrl && values.productionUrl !== current.productionUrl
90
+ ? 'confirm'
91
+ : null,
92
+ name: 'regenStagingHash',
93
+ message: 'ステージングのハッシュを再生成しますか?(既定: 保持してドメインのみ追従)',
94
+ initial: false,
95
+ },
96
+ ], { onCancel: () => process.exit(0) });
97
+ // 入力されたもの かつ 現在値と異なるもののみ変更対象にする
98
+ if (res.productionUrl && res.productionUrl !== current.productionUrl) {
99
+ change.productionUrl = res.productionUrl;
100
+ }
101
+ if (res.r2PublicUrl && res.r2PublicUrl !== current.r2PublicUrl) {
102
+ change.r2PublicUrl = res.r2PublicUrl;
103
+ }
104
+ if (res.regenStagingHash)
105
+ change.regenStagingHash = true;
106
+ }
107
+ if (Object.keys(change).length === 0) {
108
+ log('変更がありません。終了します。');
109
+ process.exit(0);
110
+ }
111
+ const result = changeDomain(projectDir, change);
112
+ if (!result.success) {
113
+ logError(result.error);
114
+ process.exit(1);
115
+ }
116
+ const { config, regeneratedPaths } = result.data;
117
+ log('');
118
+ logSuccess('config.json と派生ファイルを再生成しました');
119
+ log('');
120
+ log(' 更新後の値:');
121
+ log(` 本番 URL : ${config.productionUrl || '(未設定)'}`);
122
+ log(` プレビュー URL: ${config.previewUrl || '(未設定)'}`);
123
+ log(` R2 公開 URL : ${config.r2PublicUrl || '(未設定)'}`);
124
+ log('');
125
+ log(' 再生成したファイル:');
126
+ for (const p of regeneratedPaths) {
127
+ log(` - ${p}`);
128
+ }
129
+ log('');
130
+ log(' 次の手動作業:');
131
+ log(' 1. 差分を確認して develop へ commit / push');
132
+ log(' (wrangler.toml [vars] はデプロイ時に自動適用される / Issue #23)');
133
+ log(' 2. Cloudflare Pages にカスタムドメインを追加(本番 + プレビュー)');
134
+ log(' 3. R2 公開バケットのカスタムドメインを設定(独自ドメイン化した場合)');
135
+ log(' 4. GitHub OAuth App の Homepage / Callback URL を新ドメインに更新');
136
+ log(' 5. file-upload Worker を別運用している場合は再デプロイ:');
137
+ log(' cd workers/file-upload && npx wrangler deploy');
138
+ log(' 6. 本番反映は CMS の「直接デプロイ」フローで実行');
139
+ log(' 7. 必要に応じて旧ドメインからのリダイレクトと Cloudflare Access を再設定');
140
+ log('');
141
+ }
@@ -23,4 +23,4 @@ export type CloudflareSetupResult = {
23
23
  }>;
24
24
  secrets: OperationResult[];
25
25
  };
26
- export declare function setupCloudflareResources(config: ProjectConfig, githubClientSecret?: string): OperationResult<CloudflareSetupResult>;
26
+ export declare function setupCloudflareResources(config: ProjectConfig, githubClientSecret?: string, cloudflareApiToken?: string): OperationResult<CloudflareSetupResult>;
@@ -47,7 +47,7 @@ export function setPagesSecret(projectName, secretName, secretValue) {
47
47
  return fail(`${secretName} の設定に失敗しました(${projectName}): ${e.message}`, 'EXEC_FAILED');
48
48
  }
49
49
  }
50
- export function setupCloudflareResources(config, githubClientSecret) {
50
+ export function setupCloudflareResources(config, githubClientSecret, cloudflareApiToken) {
51
51
  const r2Bucket = createR2Bucket(config.r2BucketName);
52
52
  const contentPages = createPagesProject(config.pagesProjectName, 'main');
53
53
  const adminPages = createPagesProject(config.adminPagesProjectName, 'admin');
@@ -56,5 +56,11 @@ export function setupCloudflareResources(config, githubClientSecret) {
56
56
  secrets.push(setPagesSecret(config.pagesProjectName, 'GITHUB_CLIENT_SECRET', githubClientSecret));
57
57
  secrets.push(setPagesSecret(config.pagesProjectName, 'GITHUB_CLIENT_ID', config.githubClientId));
58
58
  }
59
+ // デプロイ管理機能(Issue #27)の Cloudflare API トークン。
60
+ // functions/api/deployments は管理画面 Pages プロジェクト(deploy-admin.yml の配信先)で
61
+ // 実行されるため、トークンは adminPagesProjectName のシークレットに設定する。
62
+ if (cloudflareApiToken) {
63
+ secrets.push(setPagesSecret(config.adminPagesProjectName, 'CLOUDFLARE_API_TOKEN', cloudflareApiToken));
64
+ }
59
65
  return ok({ r2Bucket, contentPages, adminPages, secrets });
60
66
  }
@@ -101,7 +101,7 @@ export function buildConfig(params) {
101
101
  }
102
102
  }
103
103
  const repoName = params.contentRepo.split('/')[1];
104
- const stagingDomain = params.stagingDomain ?? generateStagingDomain(params.productionUrl || '', repoName);
104
+ const stagingDomain = params.stagingDomain ?? generateStagingDomain(params.productionUrl || '');
105
105
  const previewUrl = stagingDomain ? `https://${stagingDomain}` : '';
106
106
  const config = {
107
107
  contentRepo: params.contentRepo,
@@ -115,6 +115,7 @@ export function buildConfig(params) {
115
115
  adminPagesProjectName: `${repoName}-admin`,
116
116
  ownerUsername: params.ownerUsername || '',
117
117
  stagingDomain,
118
+ cloudflareAccountId: params.cloudflareAccountId || '',
118
119
  };
119
120
  return ok({ config });
120
121
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * core/domain — ドメイン / URL 変更(Issue #24)
3
+ *
4
+ * 公開後に独自ドメインを取得した等で本番 URL が変わったとき、
5
+ * config.json を正本として URL 系設定を更新し、派生ファイル
6
+ * (wrangler.toml ×2 等)を一括再生成する。
7
+ *
8
+ * 重要: on-disk の config.json は generateConfigJson 形状(fileUploadUrl /
9
+ * allowedOrigins を持ち stagingDomain は持たない)のため、ステージングの
10
+ * 推測困難なハッシュは previewUrl のホスト名から復元する。書き込みは
11
+ * regenerateAllConfigFiles(= generateConfigJson)に一本化し、config.json と
12
+ * wrangler.toml [vars] の値が乖離しないようにする。
13
+ */
14
+ import { type ProjectConfig, type OperationResult } from './types.js';
15
+ /** ドメイン / URL の変更指定(指定したフィールドのみ更新) */
16
+ export type DomainChange = {
17
+ productionUrl?: string;
18
+ /** 明示指定時は逐語優先(productionUrl からの自動追従より優先) */
19
+ previewUrl?: string;
20
+ /** 明示指定時は逐語優先 */
21
+ stagingDomain?: string;
22
+ r2PublicUrl?: string;
23
+ siteTitle?: string;
24
+ /** productionUrl 変更時にステージングのハッシュを保持せず再生成する */
25
+ regenStagingHash?: boolean;
26
+ };
27
+ /**
28
+ * config.json の URL 系設定を更新し、派生ファイルを一括再生成する。
29
+ *
30
+ * - productionUrl 変更時、明示指定が無ければ既存 previewUrl のホスト名から
31
+ * ステージングのハッシュラベルを保持しつつ新ホストへ追従する。
32
+ * - allowedOrigins は productionUrl / previewUrl からの派生値として
33
+ * regenerateAllConfigFiles 内で自動的に再計算される。
34
+ */
35
+ export declare function changeDomain(projectDir: string, change: DomainChange): OperationResult<{
36
+ config: ProjectConfig;
37
+ regeneratedPaths: string[];
38
+ }>;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * core/domain — ドメイン / URL 変更(Issue #24)
3
+ *
4
+ * 公開後に独自ドメインを取得した等で本番 URL が変わったとき、
5
+ * config.json を正本として URL 系設定を更新し、派生ファイル
6
+ * (wrangler.toml ×2 等)を一括再生成する。
7
+ *
8
+ * 重要: on-disk の config.json は generateConfigJson 形状(fileUploadUrl /
9
+ * allowedOrigins を持ち stagingDomain は持たない)のため、ステージングの
10
+ * 推測困難なハッシュは previewUrl のホスト名から復元する。書き込みは
11
+ * regenerateAllConfigFiles(= generateConfigJson)に一本化し、config.json と
12
+ * wrangler.toml [vars] の値が乖離しないようにする。
13
+ */
14
+ import { ok, fail, } from './types.js';
15
+ import { readConfig, validateConfig } from './config.js';
16
+ import { regenerateAllConfigFiles } from './file-generators.js';
17
+ import { generateStagingDomain, rehostStagingDomain } from '../lib/templates.js';
18
+ function safeHostname(url) {
19
+ try {
20
+ return new URL(url).hostname;
21
+ }
22
+ catch {
23
+ return '';
24
+ }
25
+ }
26
+ /**
27
+ * config.json の URL 系設定を更新し、派生ファイルを一括再生成する。
28
+ *
29
+ * - productionUrl 変更時、明示指定が無ければ既存 previewUrl のホスト名から
30
+ * ステージングのハッシュラベルを保持しつつ新ホストへ追従する。
31
+ * - allowedOrigins は productionUrl / previewUrl からの派生値として
32
+ * regenerateAllConfigFiles 内で自動的に再計算される。
33
+ */
34
+ export function changeDomain(projectDir, change) {
35
+ const read = readConfig(projectDir);
36
+ if (!read.success)
37
+ return read;
38
+ if (!read.data.config) {
39
+ return fail('admin/config.json が見つかりません。Frelio プロジェクトのルートで実行してください。', 'NOT_FOUND');
40
+ }
41
+ const existing = read.data.config;
42
+ const existingStaging = existing.previewUrl
43
+ ? safeHostname(existing.previewUrl)
44
+ : '';
45
+ const merged = {
46
+ ...existing,
47
+ ownerUsername: existing.ownerUsername ?? '',
48
+ stagingDomain: existing.stagingDomain ?? existingStaging,
49
+ };
50
+ if (change.siteTitle !== undefined)
51
+ merged.siteTitle = change.siteTitle;
52
+ if (change.r2PublicUrl !== undefined)
53
+ merged.r2PublicUrl = change.r2PublicUrl;
54
+ if (change.productionUrl !== undefined) {
55
+ merged.productionUrl = change.productionUrl;
56
+ const newStaging = change.regenStagingHash
57
+ ? generateStagingDomain(change.productionUrl)
58
+ : rehostStagingDomain(existingStaging, change.productionUrl);
59
+ merged.stagingDomain = newStaging;
60
+ merged.previewUrl = newStaging ? `https://${newStaging}` : '';
61
+ }
62
+ // 明示指定は自動追従より優先(逐語)
63
+ if (change.stagingDomain !== undefined) {
64
+ merged.stagingDomain = change.stagingDomain;
65
+ merged.previewUrl = change.stagingDomain
66
+ ? `https://${change.stagingDomain}`
67
+ : '';
68
+ }
69
+ if (change.previewUrl !== undefined) {
70
+ merged.previewUrl = change.previewUrl;
71
+ merged.stagingDomain = change.previewUrl ? safeHostname(change.previewUrl) : '';
72
+ }
73
+ // バリデーション(失敗時は何も書かない)
74
+ const validation = validateConfig(merged);
75
+ if (!validation.success)
76
+ return validation;
77
+ if (validation.data.errors.length > 0) {
78
+ const msg = validation.data.errors
79
+ .map((e) => `${e.field}: ${e.message}`)
80
+ .join('; ');
81
+ return fail(msg, 'VALIDATION_ERROR');
82
+ }
83
+ const regen = regenerateAllConfigFiles(projectDir, merged);
84
+ if (!regen.success)
85
+ return regen;
86
+ return ok({ config: merged, regeneratedPaths: regen.data.paths });
87
+ }
@@ -6,6 +6,20 @@ export declare function initialCommitAndBranches(projectDir: string, contentRepo
6
6
  branches: string[];
7
7
  defaultBranch: string;
8
8
  }>;
9
+ /**
10
+ * commit author に使われる git identity を取得する。
11
+ * projectDir 内で実行するため、git が commit 時に解決する値(local→global→system)と一致する。
12
+ * 未設定の項目は空文字を返す。
13
+ */
14
+ export declare function getGitIdentity(projectDir: string): {
15
+ name: string;
16
+ email: string;
17
+ };
18
+ /**
19
+ * git identity をリポジトリのローカルスコープに設定する(--global は付けない)。
20
+ * ユーザーのグローバル設定を変更せず、当該リポジトリの commit author のみを確定させる。
21
+ */
22
+ export declare function setGitIdentity(projectDir: string, name: string, email: string): OperationResult<void>;
9
23
  export declare function createBranch(projectDir: string, branchName: string, baseBranch: string): OperationResult<{
10
24
  alreadyExisted: boolean;
11
25
  }>;
@@ -2,7 +2,7 @@
2
2
  * core/git-operations — Git コミット・ブランチ・プッシュ操作
3
3
  */
4
4
  import fs from 'node:fs';
5
- import { ok, fail } from './types.js';
5
+ import { ok, okVoid, fail } from './types.js';
6
6
  import { exec } from '../lib/shell.js';
7
7
  // ---------------------------------------------------------------------------
8
8
  // Initial commit + branch creation
@@ -30,6 +30,45 @@ export function initialCommitAndBranches(projectDir, contentRepo) {
30
30
  }
31
31
  }
32
32
  // ---------------------------------------------------------------------------
33
+ // Git identity (user.name / user.email)
34
+ // ---------------------------------------------------------------------------
35
+ /**
36
+ * commit author に使われる git identity を取得する。
37
+ * projectDir 内で実行するため、git が commit 時に解決する値(local→global→system)と一致する。
38
+ * 未設定の項目は空文字を返す。
39
+ */
40
+ export function getGitIdentity(projectDir) {
41
+ let name = '';
42
+ let email = '';
43
+ try {
44
+ name = exec('git config user.name', { cwd: projectDir, silent: true });
45
+ }
46
+ catch {
47
+ // 未設定 → 空文字
48
+ }
49
+ try {
50
+ email = exec('git config user.email', { cwd: projectDir, silent: true });
51
+ }
52
+ catch {
53
+ // 未設定 → 空文字
54
+ }
55
+ return { name, email };
56
+ }
57
+ /**
58
+ * git identity をリポジトリのローカルスコープに設定する(--global は付けない)。
59
+ * ユーザーのグローバル設定を変更せず、当該リポジトリの commit author のみを確定させる。
60
+ */
61
+ export function setGitIdentity(projectDir, name, email) {
62
+ try {
63
+ exec(`git config user.name "${name}"`, { cwd: projectDir, silent: true });
64
+ exec(`git config user.email "${email}"`, { cwd: projectDir, silent: true });
65
+ return okVoid();
66
+ }
67
+ catch (e) {
68
+ return fail(`git identity 設定失敗: ${e.message}`, 'EXEC_FAILED');
69
+ }
70
+ }
71
+ // ---------------------------------------------------------------------------
33
72
  // Single branch creation
34
73
  // ---------------------------------------------------------------------------
35
74
  export function createBranch(projectDir, branchName, baseBranch) {
@@ -45,4 +45,5 @@ export type BuildConfigParams = {
45
45
  r2BucketName?: string;
46
46
  r2PublicUrl?: string;
47
47
  ownerUsername?: string;
48
+ cloudflareAccountId?: string;
48
49
  };
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { Command } from 'commander';
3
3
  import { initCommand } from './commands/init.js';
4
4
  import { updateCommand } from './commands/update.js';
5
5
  import { addStagingCommand } from './commands/add-staging.js';
6
+ import { setDomainCommand } from './commands/set-domain.js';
6
7
  const program = new Command();
7
8
  program
8
9
  .name('frelio')
@@ -19,10 +20,12 @@ program
19
20
  .option('--production-url <url>', 'Production URL')
20
21
  .option('--staging-domain <domain>', 'Staging domain')
21
22
  .option('--r2-bucket-name <name>', 'R2 bucket name')
22
- .option('--r2-public-url <url>', 'R2 public URL')
23
+ .option('--r2-public-url <url>', 'R2 公開 URL(アップロードファイルの配信元・例: https://files.example.com)')
23
24
  .option('--owner-username <user>', 'Admin GitHub username')
24
25
  .option('--client-id <id>', 'GitHub OAuth Client ID')
25
26
  .option('--client-secret <secret>', 'GitHub OAuth Client Secret')
27
+ .option('--cloudflare-account-id <id>', 'Cloudflare Account ID(デプロイ管理機能 / Issue #27)')
28
+ .option('--cloudflare-api-token <token>', 'Cloudflare API Token(Pages:Edit・デプロイ一覧/削除に使用)')
26
29
  .action(initCommand);
27
30
  program
28
31
  .command('update')
@@ -31,7 +34,16 @@ program
31
34
  .action(updateCommand);
32
35
  program
33
36
  .command('add-staging')
34
- .description('Add a staging environment (creates branch, uses Pages branch preview)')
37
+ .description('Add a staging environment (creates a staging-* branch; deployed via GitHub Actions wrangler --branch)')
35
38
  .option('--name <name>', 'Staging name (creates staging-{name} branch)')
36
39
  .action(addStagingCommand);
40
+ program
41
+ .command('set-domain')
42
+ .description('Change production / preview / R2 URLs after init and regenerate config files (config.json + wrangler.toml)')
43
+ .option('--production-url <url>', 'New production URL')
44
+ .option('--preview-url <url>', 'New preview URL (overrides derived staging domain)')
45
+ .option('--staging-domain <domain>', 'New staging domain (verbatim)')
46
+ .option('--r2-public-url <url>', 'New R2 public URL')
47
+ .option('--regen-staging-hash', 'Regenerate the staging subdomain hash instead of preserving it')
48
+ .action(setDomainCommand);
37
49
  program.parse();
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 作成した SaaS リソースへのリンク生成
3
+ *
4
+ * init 完了時に「どこを開けば作成物を確認・設定変更できるか」を案内するための
5
+ * 純粋関数群。URL 文字列を組み立てるだけで副作用は持たない。
6
+ */
7
+ import type { ProjectConfig } from './templates.js';
8
+ export type ResourceLink = {
9
+ label: string;
10
+ url: string;
11
+ };
12
+ export type ResourceLinkSection = {
13
+ title: string;
14
+ links: ResourceLink[];
15
+ };
16
+ export type ResourceLinkOptions = {
17
+ /** GitHub リポジトリを作成したか(--skip-github で false) */
18
+ github: boolean;
19
+ /** Cloudflare リソースを wrangler で作成したか(--skip-cloudflare / --terraform で false) */
20
+ cloudflare: boolean;
21
+ };
22
+ /**
23
+ * 作成済みリソースへのリンクをセクションごとに組み立てる。
24
+ * 作成していないリソース(skip 指定)はセクションごと省く。
25
+ */
26
+ export declare function buildResourceLinks(config: ProjectConfig, options: ResourceLinkOptions): ResourceLinkSection[];
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 作成した SaaS リソースへのリンク生成
3
+ *
4
+ * init 完了時に「どこを開けば作成物を確認・設定変更できるか」を案内するための
5
+ * 純粋関数群。URL 文字列を組み立てるだけで副作用は持たない。
6
+ */
7
+ /**
8
+ * Cloudflare ダッシュボードのディープリンク。
9
+ * `:account` はログイン中のアカウントに dash 側で解決されるため、
10
+ * Account ID を CLI 側で取得しなくてもリンクが機能する。
11
+ */
12
+ function cloudflareDashUrl(path) {
13
+ return `https://dash.cloudflare.com/?to=/:account${path}`;
14
+ }
15
+ /**
16
+ * 作成済みリソースへのリンクをセクションごとに組み立てる。
17
+ * 作成していないリソース(skip 指定)はセクションごと省く。
18
+ */
19
+ export function buildResourceLinks(config, options) {
20
+ const sections = [];
21
+ if (options.github) {
22
+ sections.push({
23
+ title: 'GitHub',
24
+ links: [
25
+ { label: 'リポジトリ', url: `https://github.com/${config.contentRepo}` },
26
+ { label: 'OAuth App 設定', url: 'https://github.com/settings/developers' },
27
+ ],
28
+ });
29
+ }
30
+ if (options.cloudflare) {
31
+ sections.push({
32
+ title: 'Cloudflare',
33
+ links: [
34
+ {
35
+ label: `コンテンツ配信 Pages (${config.pagesProjectName})`,
36
+ url: cloudflareDashUrl(`/pages/view/${config.pagesProjectName}`),
37
+ },
38
+ {
39
+ label: `管理画面 Pages (${config.adminPagesProjectName})`,
40
+ url: cloudflareDashUrl(`/pages/view/${config.adminPagesProjectName}`),
41
+ },
42
+ {
43
+ label: `R2 バケット (${config.r2BucketName})`,
44
+ url: cloudflareDashUrl(`/r2/default/buckets/${config.r2BucketName}`),
45
+ },
46
+ ],
47
+ });
48
+ }
49
+ return sections;
50
+ }
@@ -4,7 +4,7 @@
4
4
  * {{variable}} 形式のプレースホルダーを値に置換する。
5
5
  * GitHub Actions の ${{ expression }} とは衝突しない。
6
6
  */
7
- import type { ProjectConfig } from './templates.js';
7
+ import { type ProjectConfig } from './templates.js';
8
8
  export type TemplateVariables = Record<string, string>;
9
9
  /**
10
10
  * テンプレート文字列内の {{variable}} を置換する
@@ -4,6 +4,7 @@
4
4
  * {{variable}} 形式のプレースホルダーを値に置換する。
5
5
  * GitHub Actions の ${{ expression }} とは衝突しない。
6
6
  */
7
+ import { buildAllowedOrigins } from './templates.js';
7
8
  /**
8
9
  * テンプレート文字列内の {{variable}} を置換する
9
10
  */
@@ -28,5 +29,7 @@ export function projectConfigToVars(config) {
28
29
  r2BucketName: config.r2BucketName,
29
30
  r2PublicUrl: config.r2PublicUrl,
30
31
  ownerUsername: config.ownerUsername,
32
+ allowedOrigins: buildAllowedOrigins(config),
33
+ cloudflareAccountId: config.cloudflareAccountId ?? '',
31
34
  };
32
35
  }
@@ -13,17 +13,51 @@ export type ProjectConfig = {
13
13
  adminPagesProjectName: string;
14
14
  ownerUsername: string;
15
15
  stagingDomain: string;
16
+ cloudflareAccountId: string;
16
17
  };
17
18
  /**
18
19
  * ランダムな8文字のハッシュを生成(URL推測を困難にする)
19
20
  */
20
21
  export declare function generateHash(): string;
21
22
  /**
22
- * ドメインからステージング用のデフォルトサブドメインを生成
23
- * 例: example.com → staging-a3f9c2kp.example.com
23
+ * productionUrl からステージング用の推測困難なカスタムサブドメインを生成する。
24
+ * 例: https://example.com → staging-a3f9c2kp.example.com
25
+ *
26
+ * productionUrl が無い/無効な場合は空文字を返す(カスタムドメイン無し)。
27
+ * その場合プレビューは GitHub Actions の wrangler デプロイが生成する
28
+ * ブランチエイリアス `staging.<project>.pages.dev` で運用する。
29
+ * `*.pages.dev` の任意サブドメインは実在しないため、ここでは生成しない。
24
30
  */
25
- export declare function generateStagingDomain(productionUrl: string, pagesProjectName: string): string;
31
+ export declare function generateStagingDomain(productionUrl: string): string;
32
+ /**
33
+ * 既存の staging サブドメインのハッシュ部分(`staging-<hash>` ラベル)を保持したまま、
34
+ * ホスト名だけ新しい productionUrl のホストへ差し替える。
35
+ * ドメイン移行時にプレビュー URL の推測困難なハッシュを変えずにドメインだけ追従させる。
36
+ *
37
+ * 既存 stagingDomain が空(init 時に本番 URL 未設定だった等)の場合は新規生成する。
38
+ * 新 productionUrl が無効なら空文字を返す(generateStagingDomain と同じ規約)。
39
+ */
40
+ export declare function rehostStagingDomain(existingStagingDomain: string, newProductionUrl: string): string;
26
41
  export declare function generateConfigJson(config: ProjectConfig): string;
42
+ /**
43
+ * CORS 許可オリジンの一覧を生成する(ローカル開発 + 本番/プレビュー)。
44
+ * 管理画面 functions/api と file-upload Worker の双方で使用する。
45
+ */
46
+ export declare function buildAllowedOrigins(config: ProjectConfig): string;
47
+ /**
48
+ * ルートの wrangler.toml(Cloudflare Pages 設定)を生成する。
49
+ *
50
+ * Issue #23: `pages_build_output_dir` が無いと `wrangler pages deploy` が
51
+ * 設定ファイルを丸ごと無視するため、R2 バインディング・環境変数がデプロイに
52
+ * 反映されず、ダッシュボードでの手動設定が必須になっていた。
53
+ *
54
+ * このファイルはコンテンツ配信・管理画面の両 Pages デプロイで共用する:
55
+ * - コンテンツ配信: `wrangler pages deploy public`(pages_build_output_dir と一致)
56
+ * - 管理画面: `wrangler pages deploy .admin-deploy`(コマンドライン引数が優先)
57
+ * デプロイ先ディレクトリと project 名はコマンドライン引数(`--project-name`)が
58
+ * 優先されるため、1 ファイルで両デプロイに R2 バインディング・環境変数を適用できる。
59
+ * いずれのデプロイ対象ディレクトリにも本ファイルは含まれない(公開されない)。
60
+ */
27
61
  export declare function generateWranglerToml(config: ProjectConfig): string;
28
62
  export declare function generateWorkerWranglerToml(config: ProjectConfig): string;
29
63
  export declare function generateRedirects(): string;
@@ -17,19 +17,45 @@ export function generateHash() {
17
17
  return hash;
18
18
  }
19
19
  /**
20
- * ドメインからステージング用のデフォルトサブドメインを生成
21
- * 例: example.com → staging-a3f9c2kp.example.com
20
+ * productionUrl からステージング用の推測困難なカスタムサブドメインを生成する。
21
+ * 例: https://example.com → staging-a3f9c2kp.example.com
22
+ *
23
+ * productionUrl が無い/無効な場合は空文字を返す(カスタムドメイン無し)。
24
+ * その場合プレビューは GitHub Actions の wrangler デプロイが生成する
25
+ * ブランチエイリアス `staging.<project>.pages.dev` で運用する。
26
+ * `*.pages.dev` の任意サブドメインは実在しないため、ここでは生成しない。
22
27
  */
23
- export function generateStagingDomain(productionUrl, pagesProjectName) {
24
- const hash = generateHash();
28
+ export function generateStagingDomain(productionUrl) {
25
29
  try {
26
30
  const url = new URL(productionUrl);
27
- return `staging-${hash}.${url.hostname}`;
31
+ return `staging-${generateHash()}.${url.hostname}`;
28
32
  }
29
33
  catch {
30
- return `${pagesProjectName}-staging-${hash}.pages.dev`;
34
+ return '';
31
35
  }
32
36
  }
37
+ /**
38
+ * 既存の staging サブドメインのハッシュ部分(`staging-<hash>` ラベル)を保持したまま、
39
+ * ホスト名だけ新しい productionUrl のホストへ差し替える。
40
+ * ドメイン移行時にプレビュー URL の推測困難なハッシュを変えずにドメインだけ追従させる。
41
+ *
42
+ * 既存 stagingDomain が空(init 時に本番 URL 未設定だった等)の場合は新規生成する。
43
+ * 新 productionUrl が無効なら空文字を返す(generateStagingDomain と同じ規約)。
44
+ */
45
+ export function rehostStagingDomain(existingStagingDomain, newProductionUrl) {
46
+ if (!existingStagingDomain) {
47
+ return generateStagingDomain(newProductionUrl);
48
+ }
49
+ let hostname;
50
+ try {
51
+ hostname = new URL(newProductionUrl).hostname;
52
+ }
53
+ catch {
54
+ return '';
55
+ }
56
+ const label = existingStagingDomain.split('.')[0]; // staging-<hash>
57
+ return `${label}.${hostname}`;
58
+ }
33
59
  export function generateConfigJson(config) {
34
60
  return JSON.stringify({
35
61
  contentRepo: config.contentRepo,
@@ -42,27 +68,65 @@ export function generateConfigJson(config) {
42
68
  adminPagesProjectName: config.adminPagesProjectName,
43
69
  r2BucketName: config.r2BucketName,
44
70
  r2PublicUrl: config.r2PublicUrl,
71
+ // allowedOrigins は productionUrl / previewUrl からの派生値(単一ソース / Issue #24)。
72
+ // wrangler.toml [vars] の ALLOWED_ORIGINS と同じ buildAllowedOrigins で導出し乖離を防ぐ。
73
+ allowedOrigins: buildAllowedOrigins(config),
74
+ // デプロイ管理機能(Issue #27)の表示用。account_id は非シークレットのため config.json に保持する。
75
+ cloudflareAccountId: config.cloudflareAccountId ?? '',
45
76
  }, null, 2);
46
77
  }
78
+ /**
79
+ * CORS 許可オリジンの一覧を生成する(ローカル開発 + 本番/プレビュー)。
80
+ * 管理画面 functions/api と file-upload Worker の双方で使用する。
81
+ */
82
+ export function buildAllowedOrigins(config) {
83
+ return [
84
+ 'http://localhost:5173',
85
+ 'http://localhost:5174',
86
+ config.productionUrl,
87
+ config.previewUrl,
88
+ ].filter(Boolean).join(',');
89
+ }
90
+ /**
91
+ * ルートの wrangler.toml(Cloudflare Pages 設定)を生成する。
92
+ *
93
+ * Issue #23: `pages_build_output_dir` が無いと `wrangler pages deploy` が
94
+ * 設定ファイルを丸ごと無視するため、R2 バインディング・環境変数がデプロイに
95
+ * 反映されず、ダッシュボードでの手動設定が必須になっていた。
96
+ *
97
+ * このファイルはコンテンツ配信・管理画面の両 Pages デプロイで共用する:
98
+ * - コンテンツ配信: `wrangler pages deploy public`(pages_build_output_dir と一致)
99
+ * - 管理画面: `wrangler pages deploy .admin-deploy`(コマンドライン引数が優先)
100
+ * デプロイ先ディレクトリと project 名はコマンドライン引数(`--project-name`)が
101
+ * 優先されるため、1 ファイルで両デプロイに R2 バインディング・環境変数を適用できる。
102
+ * いずれのデプロイ対象ディレクトリにも本ファイルは含まれない(公開されない)。
103
+ */
47
104
  export function generateWranglerToml(config) {
48
- return `name = "${config.adminPagesProjectName}"
105
+ return `# Cloudflare Pages 設定(コンテンツ配信・管理画面デプロイで共用 / Issue #23)
106
+ # pages_build_output_dir を指定することで wrangler pages deploy が本ファイルを
107
+ # 読み込み、R2 バインディングと環境変数をデプロイ時に自動適用する。
108
+ # デプロイ先ディレクトリ・project 名はコマンドライン引数が優先される。
109
+ name = "${config.pagesProjectName}"
110
+ pages_build_output_dir = "public"
49
111
  compatibility_date = "2024-01-01"
50
112
 
51
113
  [[r2_buckets]]
52
114
  binding = "R2"
53
115
  bucket_name = "${config.r2BucketName}"
54
116
 
117
+ # 環境変数(管理画面 functions/api が参照。コンテンツ配信側では未使用)
55
118
  [vars]
56
119
  R2_PUBLIC_URL = "${config.r2PublicUrl}"
120
+ ALLOWED_ORIGINS = "${buildAllowedOrigins(config)}"
121
+ CONTENT_REPO = "${config.contentRepo}"
122
+ # デプロイ管理機能(Issue #27): functions/api/deployments が Cloudflare Pages API を呼ぶのに使用。
123
+ # CLOUDFLARE_API_TOKEN はシークレットのため wrangler pages secret で管理画面プロジェクトに設定する(ここには置かない)。
124
+ CLOUDFLARE_ACCOUNT_ID = "${config.cloudflareAccountId ?? ''}"
125
+ PAGES_PROJECT_NAME = "${config.pagesProjectName}"
57
126
  `;
58
127
  }
59
128
  export function generateWorkerWranglerToml(config) {
60
- const allowedOrigins = [
61
- 'http://localhost:5173',
62
- 'http://localhost:5174',
63
- config.productionUrl,
64
- config.previewUrl,
65
- ].filter(Boolean).join(',');
129
+ const allowedOrigins = buildAllowedOrigins(config);
66
130
  return `name = "frelio-file-upload"
67
131
  main = "src/index.ts"
68
132
  compatibility_date = "2024-02-08"
@@ -223,7 +287,7 @@ variable "admin_domain" {
223
287
  variable "staging_domain" {
224
288
  type = string
225
289
  default = ""
226
- description = "ステージングカスタムドメイン(空ならブランチプレビュー URL で運用)"
290
+ description = "ステージングカスタムドメイン(空ならブランチエイリアス staging.<project>.pages.dev で運用)"
227
291
  }
228
292
  `;
229
293
  }
@@ -255,9 +319,13 @@ resource "cloudflare_pages_project" "admin" {
255
319
  secrets = {
256
320
  GITHUB_CLIENT_SECRET = var.github_client_secret
257
321
  GITHUB_CLIENT_ID = var.github_client_id
322
+ # デプロイ管理機能(Issue #27): functions/api/deployments が Cloudflare Pages API を呼ぶのに使用
323
+ CLOUDFLARE_API_TOKEN = var.cloudflare_api_token
258
324
  }
259
325
  environment_variables = {
260
- R2_PUBLIC_URL = var.r2_public_url
326
+ R2_PUBLIC_URL = var.r2_public_url
327
+ CLOUDFLARE_ACCOUNT_ID = var.cloudflare_account_id
328
+ PAGES_PROJECT_NAME = var.pages_project_name
261
329
  }
262
330
  }
263
331
  }
@@ -4,5 +4,6 @@
4
4
  */
5
5
  export declare function validateContentRepo(v: string): true | string;
6
6
  export declare function validateR2PublicUrl(v: string): true | string;
7
+ export declare function validateProductionUrl(v: string): true | string;
7
8
  export declare function validateStagingName(v: string): true | string;
8
9
  export declare function validateRequired(v: string, label: string): true | string;
@@ -6,7 +6,20 @@ export function validateContentRepo(v) {
6
6
  return v.includes('/') || 'owner/repo 形式で入力してください';
7
7
  }
8
8
  export function validateR2PublicUrl(v) {
9
- return v === '' || v.startsWith('https://') || 'https:// で始まる URL を入力してください';
9
+ return (v === '' ||
10
+ v.startsWith('https://') ||
11
+ 'https:// で始まる URL を入力してください(例: https://files.example.com)。\n空欄のままにすると後で設定できます。');
12
+ }
13
+ export function validateProductionUrl(v) {
14
+ if (v === '')
15
+ return true;
16
+ try {
17
+ new URL(v);
18
+ return true;
19
+ }
20
+ catch {
21
+ return '有効な URL を入力してください(例: https://example.com)';
22
+ }
10
23
  }
11
24
  export function validateStagingName(v) {
12
25
  if (!v)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c-time/frelio-cli",
3
- "version": "1.4.5",
3
+ "version": "1.4.6",
4
4
  "description": "Frelio CMS setup CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "scripts": {
20
20
  "build": "tsc",
21
21
  "dev": "tsc --watch",
22
+ "test": "vitest run",
22
23
  "prepublishOnly": "npm run build"
23
24
  },
24
25
  "dependencies": {
@@ -29,6 +30,7 @@
29
30
  "devDependencies": {
30
31
  "@types/prompts": "^2.4.9",
31
32
  "@types/node": "^22.0.0",
32
- "typescript": "^5.7.0"
33
+ "typescript": "^5.7.0",
34
+ "vitest": "^4.0.18"
33
35
  }
34
36
  }