@c-time/frelio-cli 1.2.1 → 1.3.11

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Frelio CMS のプロジェクトセットアップと管理を行う CLI ツール。
4
4
 
5
- 対話式のプロンプトに従うだけで、GitHub リポジトリの作成からコンテンツ構造の初期化、Cloudflare のセットアップまで、すべてを自動化する。
5
+ 対話式のプロンプトに従うだけで、GitHub リポジトリの作成からコンテンツ構造の初期化、Cloudflare のセットアップまで、すべてを自動化する。CLI オプションで全パラメータを指定すれば、非対話モードでも実行可能(AI エージェントや CI 向け)。
6
6
 
7
7
  ---
8
8
 
@@ -151,23 +151,61 @@ frelio init
151
151
 
152
152
  #### プロンプトで聞かれること
153
153
 
154
- | 項目 | 説明 | 例 |
155
- |------|------|----|
156
- | リポジトリ名 | `owner/repo` 形式 | `my-org/my-site` |
157
- | サイトタイトル | 管理画面に表示される名前 | `My Website` |
158
- | 本番 URL | 公開サイトの URL(任意) | `https://example.com` |
159
- | プレビュー URL | ステージング確認用 URL(任意) | `https://staging.example.com` |
160
- | R2 バケット名 | ファイルストレージ用バケット | `my-site-files` |
161
- | R2 公開 URL | ファイル配信の URL | `https://storage.example.com` |
162
- | 管理者 GitHub ユーザー名 | 最初の管理者ユーザー | `your-username` |
163
- | OAuth Client ID | GitHub OAuth App の Client ID | `Ov23li...` |
164
- | OAuth Client Secret | GitHub OAuth App の Client Secret | `********` |
154
+ | 項目 | CLI オプション | 説明 | 例 |
155
+ |------|---------------|------|----|
156
+ | リポジトリ名 | `--content-repo` | `owner/repo` 形式 | `my-org/my-site` |
157
+ | サイトタイトル | `--site-title` | 管理画面に表示される名前 | `My Website` |
158
+ | 本番 URL | `--production-url` | 公開サイトの URL(任意) | `https://example.com` |
159
+ | ステージングドメイン | `--staging-domain` | ステージング確認用ドメイン(任意) | `staging-abc123.example.com` |
160
+ | R2 バケット名 | `--r2-bucket-name` | ファイルストレージ用バケット | `my-site-files` |
161
+ | R2 公開 URL | `--r2-public-url` | ファイル配信の URL | `https://storage.example.com` |
162
+ | 管理者 GitHub ユーザー名 | `--owner-username` | 最初の管理者ユーザー | `your-username` |
163
+ | OAuth Client ID | `--client-id` | GitHub OAuth App の Client ID | `Ov23li...` |
164
+ | OAuth Client Secret | `--client-secret` | GitHub OAuth App の Client Secret | `********` |
165
165
 
166
166
  #### オプション
167
167
 
168
168
  ```bash
169
+ # スキップオプション
169
170
  frelio init --skip-github # GitHub 操作をスキップ(手動でリポジトリを用意する場合)
170
171
  frelio init --skip-cloudflare # Cloudflare 操作をスキップ(後から設定する場合)
172
+
173
+ # パラメータ指定(非対話モード用)
174
+ frelio init \
175
+ --content-repo <owner/repo> # リポジトリ名(必須)
176
+ --site-title <title> # サイトタイトル
177
+ --production-url <url> # 本番 URL
178
+ --staging-domain <domain> # ステージングドメイン
179
+ --r2-bucket-name <name> # R2 バケット名
180
+ --r2-public-url <url> # R2 公開 URL
181
+ --owner-username <user> # 管理者 GitHub ユーザー名
182
+ --client-id <id> # OAuth Client ID(必須)
183
+ --client-secret <secret> # OAuth Client Secret(必須)
184
+ ```
185
+
186
+ #### 非対話モード(AI エージェント / CI 向け)
187
+
188
+ `--content-repo`、`--client-id`、`--client-secret` の3つを指定すると、プロンプトなしで実行される。他のパラメータは省略するとデフォルト値が自動算出される。
189
+
190
+ ```bash
191
+ # 最小構成
192
+ frelio init \
193
+ --content-repo my-org/my-site \
194
+ --client-id Ov23liXXX \
195
+ --client-secret ghp_XXX
196
+
197
+ # 全パラメータ指定
198
+ frelio init \
199
+ --content-repo my-org/my-site \
200
+ --site-title "My Site" \
201
+ --production-url https://example.com \
202
+ --staging-domain staging-abc123.example.com \
203
+ --r2-bucket-name my-site-files \
204
+ --r2-public-url https://storage.example.com \
205
+ --owner-username my-username \
206
+ --client-id Ov23liXXX \
207
+ --client-secret ghp_XXX \
208
+ --skip-cloudflare
171
209
  ```
172
210
 
173
211
  #### 生成されるリポジトリ構造
@@ -234,17 +272,34 @@ frelio add-staging
234
272
 
235
273
  #### プロンプトで聞かれること
236
274
 
237
- | 項目 | 説明 | 例 |
238
- |------|------|----|
239
- | ステージング名 | ブランチ名に使われる識別子 | `design`, `alice` |
240
- | Pages プロジェクト名 | Cloudflare Pages プロジェクト名 | `my-site-staging-design` |
241
- | カスタムドメイン | 任意。空欄なら `<project>.pages.dev` | `staging-design.example.com` |
275
+ | 項目 | CLI オプション | 説明 | 例 |
276
+ |------|---------------|------|----|
277
+ | ステージング名 | `--name` | ブランチ名に使われる識別子 | `design`, `alice` |
278
+ | Pages プロジェクト名 | `--pages-project` | Cloudflare Pages プロジェクト名 | `my-site-staging-design` |
279
+ | カスタムドメイン | `--domain` | 任意。空欄なら `<project>.pages.dev` | `staging-design.example.com` |
242
280
 
243
281
  #### オプション
244
282
 
245
283
  ```bash
246
284
  frelio add-staging --name design # 名前を事前指定
247
285
  frelio add-staging --skip-cloudflare # Pages プロジェクト作成をスキップ
286
+ frelio add-staging --pages-project <name> # Pages プロジェクト名を指定
287
+ frelio add-staging --domain <domain> # カスタムドメインを指定
288
+ ```
289
+
290
+ #### 非対話モード(AI エージェント / CI 向け)
291
+
292
+ `--name` を指定するとプロンプトなしで実行される。`--pages-project` と `--domain` は省略するとデフォルト値が自動算出される。
293
+
294
+ ```bash
295
+ # 最小構成(name のみ、他はデフォルト算出)
296
+ frelio add-staging --name preview1
297
+
298
+ # 全パラメータ指定
299
+ frelio add-staging \
300
+ --name preview1 \
301
+ --pages-project my-site-staging-preview1 \
302
+ --domain preview1-abc123.example.com
248
303
  ```
249
304
 
250
305
  #### 完了後の手動作業
@@ -3,6 +3,8 @@
3
3
  */
4
4
  type AddStagingOptions = {
5
5
  name?: string;
6
+ pagesProject?: string;
7
+ domain?: string;
6
8
  skipCloudflare?: boolean;
7
9
  };
8
10
  export declare function addStagingCommand(options: AddStagingOptions): Promise<void>;
@@ -4,6 +4,7 @@
4
4
  import prompts from 'prompts';
5
5
  import { exec, commandExists, log, logStep, logSuccess, logError } from '../lib/shell.js';
6
6
  import { generateHash } from '../lib/templates.js';
7
+ import { validateStagingName, validateRequired } from '../lib/validators.js';
7
8
  export async function addStagingCommand(options) {
8
9
  log('');
9
10
  log('🌿 ステージング環境の追加');
@@ -67,54 +68,105 @@ export async function addStagingCommand(options) {
67
68
  catch {
68
69
  // config.json がなくても続行
69
70
  }
70
- // 対話式プロンプト
71
- const response = await prompts([
72
- {
73
- type: options.name ? null : 'text',
74
- name: 'name',
75
- message: 'ステージング名(staging-{name} のブランチが作成されます):',
76
- validate: (v) => {
77
- if (!v)
78
- return 'ステージング名を入力してください';
79
- if (!/^[a-z0-9][a-z0-9-]*$/.test(v))
80
- return '小文字英数字とハイフンのみ使用可能です';
81
- if (v === 'staging')
82
- return '"staging" は予約済みです。別の名前を使用してください';
83
- return true;
71
+ // 非対話 / 対話モード判定
72
+ const isInteractive = process.stdin.isTTY === true;
73
+ let stagingName;
74
+ let pagesProject;
75
+ let stagingDomain;
76
+ if (options.name && options.pagesProject) {
77
+ // 全必須オプション指定 プロンプトスキップ
78
+ const nameCheck = validateStagingName(options.name);
79
+ if (nameCheck !== true) {
80
+ logError(`--name: ${nameCheck}`);
81
+ process.exit(1);
82
+ }
83
+ stagingName = options.name;
84
+ pagesProject = options.pagesProject;
85
+ stagingDomain = options.domain ?? generateDefaultDomain(stagingName, basePagesProject, baseDomain);
86
+ }
87
+ else if (options.name && !options.pagesProject) {
88
+ // --name のみ指定 → pagesProject はデフォルト算出 or プロンプト
89
+ const nameCheck = validateStagingName(options.name);
90
+ if (nameCheck !== true) {
91
+ logError(`--name: ${nameCheck}`);
92
+ process.exit(1);
93
+ }
94
+ stagingName = options.name;
95
+ const defaultPagesProject = basePagesProject ? `${basePagesProject}-staging-${stagingName}` : '';
96
+ if (!isInteractive) {
97
+ // 非 TTY → デフォルト値で続行
98
+ pagesProject = defaultPagesProject;
99
+ if (!pagesProject) {
100
+ logError('--pages-project が必要です(wrangler.toml からプロジェクト名を推定できません)');
101
+ process.exit(1);
102
+ }
103
+ stagingDomain = options.domain ?? generateDefaultDomain(stagingName, basePagesProject, baseDomain);
104
+ }
105
+ else {
106
+ // TTY → 残りをプロンプト
107
+ const response = await prompts([
108
+ {
109
+ type: 'text',
110
+ name: 'pagesProject',
111
+ message: 'ステージング用 Pages プロジェクト名:',
112
+ initial: defaultPagesProject,
113
+ validate: (v) => validateRequired(v, 'プロジェクト名'),
114
+ },
115
+ {
116
+ type: 'text',
117
+ name: 'domain',
118
+ message: 'カスタムドメイン(推測困難なハッシュ付き推奨):',
119
+ initial: generateDefaultDomain(stagingName, basePagesProject, baseDomain),
120
+ },
121
+ ], { onCancel: () => process.exit(0) });
122
+ pagesProject = response.pagesProject;
123
+ stagingDomain = response.domain;
124
+ }
125
+ }
126
+ else {
127
+ // --name 未指定
128
+ if (!isInteractive) {
129
+ logError('非対話モードでは --name が必須です。');
130
+ logError('使用例: frelio add-staging --name preview1 [--pages-project <name>] [--domain <domain>]');
131
+ process.exit(1);
132
+ }
133
+ // 従来通りの対話式プロンプト
134
+ const response = await prompts([
135
+ {
136
+ type: 'text',
137
+ name: 'name',
138
+ message: 'ステージング名(staging-{name} のブランチが作成されます):',
139
+ validate: (v) => validateStagingName(v),
84
140
  },
85
- },
86
- {
87
- type: 'text',
88
- name: 'pagesProject',
89
- message: 'ステージング用 Pages プロジェクト名:',
90
- initial: (_prev, values) => {
91
- const name = options.name || values.name || '';
92
- return basePagesProject ? `${basePagesProject}-staging-${name}` : '';
141
+ {
142
+ type: 'text',
143
+ name: 'pagesProject',
144
+ message: 'ステージング用 Pages プロジェクト名:',
145
+ initial: (_prev, values) => {
146
+ const name = values.name || '';
147
+ return basePagesProject ? `${basePagesProject}-staging-${name}` : '';
148
+ },
149
+ validate: (v) => validateRequired(v, 'プロジェクト名'),
93
150
  },
94
- validate: (v) => v.length > 0 || 'プロジェクト名を入力してください',
95
- },
96
- {
97
- type: 'text',
98
- name: 'domain',
99
- message: 'カスタムドメイン(推測困難なハッシュ付き推奨):',
100
- initial: (_prev, values) => {
101
- const name = options.name || values.name || '';
102
- const hash = generateHash();
103
- if (baseDomain) {
104
- return `${name}-${hash}.${baseDomain}`;
105
- }
106
- const project = basePagesProject || 'site';
107
- return `${project}-${name}-${hash}.pages.dev`;
151
+ {
152
+ type: 'text',
153
+ name: 'domain',
154
+ message: 'カスタムドメイン(推測困難なハッシュ付き推奨):',
155
+ initial: (_prev, values) => {
156
+ const name = values.name || '';
157
+ return generateDefaultDomain(name, basePagesProject, baseDomain);
158
+ },
108
159
  },
109
- },
110
- ], { onCancel: () => process.exit(0) });
111
- const stagingName = options.name || response.name;
112
- if (!stagingName) {
113
- log('キャンセルしました。');
114
- process.exit(0);
160
+ ], { onCancel: () => process.exit(0) });
161
+ if (!response.name) {
162
+ log('キャンセルしました。');
163
+ process.exit(0);
164
+ }
165
+ stagingName = response.name;
166
+ pagesProject = response.pagesProject;
167
+ stagingDomain = response.domain;
115
168
  }
116
169
  const branchName = `staging-${stagingName}`;
117
- const pagesProject = response.pagesProject;
118
170
  log('');
119
171
  const totalSteps = options.skipCloudflare ? 2 : 3;
120
172
  let step = 0;
@@ -187,16 +239,24 @@ export async function addStagingCommand(options) {
187
239
  log('');
188
240
  log(` ブランチ: ${branchName}`);
189
241
  if (!options.skipCloudflare) {
190
- const domain = response.domain || `${pagesProject}.pages.dev`;
242
+ const domain = stagingDomain || `${pagesProject}.pages.dev`;
191
243
  log(` URL: https://${domain}`);
192
244
  }
193
245
  log('');
194
246
  log(' 残りの手動作業:');
195
247
  log(' - Cloudflare Pages でリポジトリを接続(GitHub integration)');
196
- if (response.domain) {
197
- log(` - Pages プロジェクトにカスタムドメイン "${response.domain}" を設定`);
248
+ if (stagingDomain) {
249
+ log(` - Pages プロジェクトにカスタムドメイン "${stagingDomain}" を設定`);
198
250
  }
199
251
  log(' - Cloudflare Access でアクセス制限を設定(推奨)');
200
252
  log(' - CMS 管理画面の /staging ページでステージングブランチを登録');
201
253
  log('');
202
254
  }
255
+ function generateDefaultDomain(name, basePagesProject, baseDomain) {
256
+ const hash = generateHash();
257
+ if (baseDomain) {
258
+ return `${name}-${hash}.${baseDomain}`;
259
+ }
260
+ const project = basePagesProject || 'site';
261
+ return `${project}-${name}-${hash}.pages.dev`;
262
+ }
@@ -4,6 +4,15 @@
4
4
  type InitOptions = {
5
5
  skipGithub?: boolean;
6
6
  skipCloudflare?: boolean;
7
+ contentRepo?: string;
8
+ siteTitle?: string;
9
+ productionUrl?: string;
10
+ stagingDomain?: string;
11
+ r2BucketName?: string;
12
+ r2PublicUrl?: string;
13
+ ownerUsername?: string;
14
+ clientId?: string;
15
+ clientSecret?: string;
7
16
  };
8
17
  export declare function initCommand(options: InitOptions): Promise<void>;
9
18
  export {};
@@ -6,9 +6,10 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import os from 'node:os';
8
8
  import { exec, commandExists, log, logStep, logSuccess, logError } from '../lib/shell.js';
9
- import { getLatestRelease, downloadTarball } from '../lib/github-release.js';
9
+ import { getLatestRelease, downloadTarball, extractNpmTarball } from '../lib/npm-registry.js';
10
10
  import { generateInitialContent } from '../lib/initial-content.js';
11
11
  import { generateConfigJson, generateWranglerToml, generateUsersIndex, generateVersionJson, generateRedirects, generateRoutesJson, generateStorageFunction, generateViteConfig, generatePackageJson, generateTsConfig, generateTsConfigNode, generateStagingDomain, writeFile, ensureDir, } from '../lib/templates.js';
12
+ import { validateContentRepo, validateR2PublicUrl, validateRequired } from '../lib/validators.js';
12
13
  export async function initCommand(options) {
13
14
  log('');
14
15
  log('🚀 Frelio CMS プロジェクトセットアップ');
@@ -31,6 +32,11 @@ export async function initCommand(options) {
31
32
  // OAuth App 案内
32
33
  log('🔑 GitHub OAuth App:');
33
34
  if (!config.githubClientId) {
35
+ const isInteractive = process.stdin.isTTY === true;
36
+ if (!isInteractive) {
37
+ logError('非対話モードでは --client-id と --client-secret が必須です。');
38
+ process.exit(1);
39
+ }
34
40
  log(' ⚠ OAuth App は GitHub の Web UI で作成が必要です。');
35
41
  log(' → https://github.com/settings/developers');
36
42
  log(' → New OAuth App:');
@@ -43,13 +49,13 @@ export async function initCommand(options) {
43
49
  type: 'text',
44
50
  name: 'clientId',
45
51
  message: 'OAuth Client ID を入力:',
46
- validate: (v) => v.length > 0 || 'Client ID は必須です',
52
+ validate: (v) => validateRequired(v, 'Client ID'),
47
53
  },
48
54
  {
49
55
  type: 'password',
50
56
  name: 'clientSecret',
51
57
  message: 'OAuth Client Secret を入力:',
52
- validate: (v) => v.length > 0 || 'Client Secret は必須です',
58
+ validate: (v) => validateRequired(v, 'Client Secret'),
53
59
  },
54
60
  ]);
55
61
  if (!oauthResponse.clientId) {
@@ -292,52 +298,71 @@ function checkPrerequisites(options) {
292
298
  return { ok };
293
299
  }
294
300
  async function promptConfig(options) {
301
+ const isInteractive = process.stdin.isTTY === true;
302
+ // 必須オプションが揃っている場合 → プロンプトスキップ
303
+ if (options.contentRepo && options.clientId && options.clientSecret) {
304
+ return buildConfigFromOptions(options);
305
+ }
306
+ // 非 TTY で必須オプション不足 → エラー終了
307
+ if (!isInteractive) {
308
+ const missing = [];
309
+ if (!options.contentRepo)
310
+ missing.push('--content-repo');
311
+ if (!options.clientId)
312
+ missing.push('--client-id');
313
+ if (!options.clientSecret)
314
+ missing.push('--client-secret');
315
+ logError(`非対話モードでは以下のオプションが必須です: ${missing.join(', ')}`);
316
+ logError('使用例: frelio init --content-repo owner/repo --client-id xxx --client-secret yyy');
317
+ process.exit(1);
318
+ }
319
+ // 対話モード(渡されたオプションはスキップ)
295
320
  const response = await prompts([
296
321
  {
297
- type: 'text',
322
+ type: options.contentRepo ? null : 'text',
298
323
  name: 'contentRepo',
299
324
  message: 'リポジトリ名 (owner/repo):',
300
- validate: (v) => v.includes('/') || 'owner/repo 形式で入力してください',
325
+ validate: (v) => validateContentRepo(v),
301
326
  },
302
327
  {
303
- type: 'text',
328
+ type: options.siteTitle !== undefined ? null : 'text',
304
329
  name: 'siteTitle',
305
330
  message: 'サイトタイトル:',
306
331
  initial: '',
307
332
  },
308
333
  {
309
- type: 'text',
334
+ type: options.productionUrl !== undefined ? null : 'text',
310
335
  name: 'productionUrl',
311
336
  message: '本番 URL (optional):',
312
337
  initial: '',
313
338
  },
314
339
  {
315
- type: 'text',
340
+ type: options.stagingDomain !== undefined ? null : 'text',
316
341
  name: 'stagingDomain',
317
342
  message: 'ステージングのドメイン(推測困難なハッシュ付き推奨):',
318
343
  initial: (_prev, values) => {
319
- const repo = values.contentRepo?.split('/')[1] || 'site';
320
- return generateStagingDomain(values.productionUrl || '', repo);
344
+ const repo = (options.contentRepo || values.contentRepo)?.split('/')[1] || 'site';
345
+ return generateStagingDomain((options.productionUrl || values.productionUrl) || '', repo);
321
346
  },
322
347
  },
323
348
  {
324
- type: 'text',
349
+ type: options.r2BucketName !== undefined ? null : 'text',
325
350
  name: 'r2BucketName',
326
351
  message: 'R2 バケット名:',
327
- initial: (prev, values) => {
328
- const repo = values.contentRepo?.split('/')[1] || 'site';
352
+ initial: (_prev, values) => {
353
+ const repo = (options.contentRepo || values.contentRepo)?.split('/')[1] || 'site';
329
354
  return `${repo}-files`;
330
355
  },
331
356
  },
332
357
  {
333
- type: 'text',
358
+ type: options.r2PublicUrl !== undefined ? null : 'text',
334
359
  name: 'r2PublicUrl',
335
360
  message: 'R2 公開 URL:',
336
361
  initial: '',
337
- validate: (v) => v === '' || v.startsWith('https://') || 'https:// で始まる URL を入力してください',
362
+ validate: (v) => validateR2PublicUrl(v),
338
363
  },
339
364
  {
340
- type: 'text',
365
+ type: options.ownerUsername !== undefined ? null : 'text',
341
366
  name: 'ownerUsername',
342
367
  message: '管理者の GitHub ユーザー名:',
343
368
  initial: () => {
@@ -350,22 +375,70 @@ async function promptConfig(options) {
350
375
  },
351
376
  },
352
377
  ], { onCancel: () => process.exit(0) });
353
- if (!response.contentRepo)
378
+ const contentRepo = options.contentRepo || response.contentRepo;
379
+ if (!contentRepo)
354
380
  return null;
355
- const repoName = response.contentRepo.split('/')[1];
356
- const stagingDomain = response.stagingDomain || '';
381
+ const repoName = contentRepo.split('/')[1];
382
+ const stagingDomain = options.stagingDomain ?? response.stagingDomain ?? '';
357
383
  const previewUrl = stagingDomain ? `https://${stagingDomain}` : '';
384
+ const config = {
385
+ contentRepo,
386
+ githubClientId: options.clientId || '',
387
+ siteTitle: options.siteTitle ?? response.siteTitle ?? '',
388
+ productionUrl: options.productionUrl ?? response.productionUrl ?? '',
389
+ previewUrl,
390
+ r2BucketName: options.r2BucketName ?? response.r2BucketName ?? `${repoName}-files`,
391
+ r2PublicUrl: options.r2PublicUrl ?? response.r2PublicUrl ?? '',
392
+ pagesProjectName: repoName,
393
+ ownerUsername: options.ownerUsername ?? response.ownerUsername ?? '',
394
+ stagingDomain,
395
+ };
396
+ if (options.clientSecret) {
397
+ config.githubClientSecret = options.clientSecret;
398
+ }
399
+ return config;
400
+ }
401
+ function buildConfigFromOptions(options) {
402
+ // バリデーション
403
+ const errors = [];
404
+ const repoCheck = validateContentRepo(options.contentRepo);
405
+ if (repoCheck !== true)
406
+ errors.push(`--content-repo: ${repoCheck}`);
407
+ if (options.r2PublicUrl) {
408
+ const urlCheck = validateR2PublicUrl(options.r2PublicUrl);
409
+ if (urlCheck !== true)
410
+ errors.push(`--r2-public-url: ${urlCheck}`);
411
+ }
412
+ if (errors.length > 0) {
413
+ errors.forEach((e) => logError(e));
414
+ process.exit(1);
415
+ }
416
+ const repoName = options.contentRepo.split('/')[1];
417
+ // デフォルト算出
418
+ const stagingDomain = options.stagingDomain ??
419
+ generateStagingDomain(options.productionUrl || '', repoName);
420
+ const previewUrl = stagingDomain ? `https://${stagingDomain}` : '';
421
+ let ownerUsername = options.ownerUsername ?? '';
422
+ if (!ownerUsername) {
423
+ try {
424
+ ownerUsername = exec('gh api user -q .login', { silent: true });
425
+ }
426
+ catch {
427
+ ownerUsername = '';
428
+ }
429
+ }
358
430
  return {
359
- contentRepo: response.contentRepo,
360
- githubClientId: '',
361
- siteTitle: response.siteTitle || '',
362
- productionUrl: response.productionUrl || '',
431
+ contentRepo: options.contentRepo,
432
+ githubClientId: options.clientId || '',
433
+ siteTitle: options.siteTitle || '',
434
+ productionUrl: options.productionUrl || '',
363
435
  previewUrl,
364
- r2BucketName: response.r2BucketName || `${repoName}-files`,
365
- r2PublicUrl: response.r2PublicUrl || '',
436
+ r2BucketName: options.r2BucketName ?? `${repoName}-files`,
437
+ r2PublicUrl: options.r2PublicUrl || '',
366
438
  pagesProjectName: repoName,
367
- ownerUsername: response.ownerUsername || '',
439
+ ownerUsername,
368
440
  stagingDomain,
441
+ githubClientSecret: options.clientSecret,
369
442
  };
370
443
  }
371
444
  function createContentStructure(projectDir, config) {
@@ -486,35 +559,31 @@ jobs:
486
559
  async function extractBundle(projectDir) {
487
560
  const release = await getLatestRelease();
488
561
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'frelio-'));
489
- const tarPath = await downloadTarball(release, tmpDir);
490
- // tar で展開
491
- const { extract } = await import('tar');
492
- await extract({ file: tarPath, cwd: tmpDir });
493
- // 展開されたディレクトリを特定
494
- const entries = fs.readdirSync(tmpDir).filter((e) => e.startsWith('frelio-cms-'));
495
- const bundleDir = entries.find((e) => fs.statSync(path.join(tmpDir, e)).isDirectory());
496
- if (!bundleDir) {
497
- throw new Error('Bundle directory not found in tarball');
498
- }
499
- const srcDir = path.join(tmpDir, bundleDir);
500
- // admin/ をコピー
501
- if (fs.existsSync(path.join(srcDir, 'admin'))) {
502
- copyDir(path.join(srcDir, 'admin'), path.join(projectDir, 'admin'));
503
- }
504
- // functions/ をコピー
505
- if (fs.existsSync(path.join(srcDir, 'functions'))) {
506
- copyDir(path.join(srcDir, 'functions'), path.join(projectDir, 'functions'));
562
+ try {
563
+ const tarPath = await downloadTarball(release, tmpDir);
564
+ const { adminDir, functionsDir, workersDir } = await extractNpmTarball(tarPath, tmpDir);
565
+ // admin/ をコピー(dist/ の中身から functions/ を除く)
566
+ if (fs.existsSync(adminDir)) {
567
+ copyDir(adminDir, path.join(projectDir, 'admin'), ['functions']);
568
+ }
569
+ // functions/ をコピー(dist/functions/ → functions/)
570
+ if (fs.existsSync(functionsDir)) {
571
+ copyDir(functionsDir, path.join(projectDir, 'functions'));
572
+ }
573
+ // workers/ をコピー(Pages Functions が import する)
574
+ if (fs.existsSync(workersDir)) {
575
+ copyDir(workersDir, path.join(projectDir, 'workers'));
576
+ }
507
577
  }
508
- // workers/ をコピー(Pages Functions が import する)
509
- if (fs.existsSync(path.join(srcDir, 'workers'))) {
510
- copyDir(path.join(srcDir, 'workers'), path.join(projectDir, 'workers'));
578
+ finally {
579
+ fs.rmSync(tmpDir, { recursive: true, force: true });
511
580
  }
512
- // クリーンアップ
513
- fs.rmSync(tmpDir, { recursive: true, force: true });
514
581
  }
515
- function copyDir(src, dest) {
582
+ function copyDir(src, dest, exclude = []) {
516
583
  fs.mkdirSync(dest, { recursive: true });
517
584
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
585
+ if (exclude.includes(entry.name))
586
+ continue;
518
587
  const srcPath = path.join(src, entry.name);
519
588
  const destPath = path.join(dest, entry.name);
520
589
  if (entry.isDirectory()) {
@@ -4,7 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import os from 'node:os';
7
- import { getLatestRelease, getRelease, downloadTarball } from '../lib/github-release.js';
7
+ import { getLatestRelease, getRelease, downloadTarball, extractNpmTarball } from '../lib/npm-registry.js';
8
8
  import { log, logSuccess, logError } from '../lib/shell.js';
9
9
  export async function updateCommand(options) {
10
10
  log('');
@@ -34,37 +34,29 @@ export async function updateCommand(options) {
34
34
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'frelio-update-'));
35
35
  try {
36
36
  const tarPath = await downloadTarball(release, tmpDir);
37
- const { extract } = await import('tar');
38
- await extract({ file: tarPath, cwd: tmpDir });
39
- // 展開されたディレクトリを特定
40
- const entries = fs.readdirSync(tmpDir).filter((e) => e.startsWith('frelio-cms-'));
41
- const bundleDir = entries.find((e) => fs.statSync(path.join(tmpDir, e)).isDirectory());
42
- if (!bundleDir) {
43
- throw new Error('Bundle directory not found in tarball');
44
- }
45
- const srcDir = path.join(tmpDir, bundleDir);
46
- // admin/ を置き換え
47
- if (fs.existsSync(path.join(srcDir, 'admin'))) {
37
+ const { adminDir: srcAdminDir, functionsDir: srcFunctionsDir, workersDir: srcWorkersDir } = await extractNpmTarball(tarPath, tmpDir);
38
+ // admin/ を置き換え(dist/ の中身から functions/ を除く)
39
+ if (fs.existsSync(srcAdminDir)) {
48
40
  fs.rmSync(adminDir, { recursive: true, force: true });
49
- copyDir(path.join(srcDir, 'admin'), adminDir);
41
+ copyDir(srcAdminDir, adminDir, ['functions']);
50
42
  logSuccess('admin/ を更新しました');
51
43
  }
52
44
  // functions/api/ を置き換え(functions/storage/ はユーザー管理なので保持)
53
45
  const functionsApiDir = path.join(projectDir, 'functions', 'api');
54
- if (fs.existsSync(path.join(srcDir, 'functions', 'api'))) {
46
+ if (fs.existsSync(path.join(srcFunctionsDir, 'api'))) {
55
47
  if (fs.existsSync(functionsApiDir)) {
56
48
  fs.rmSync(functionsApiDir, { recursive: true, force: true });
57
49
  }
58
- copyDir(path.join(srcDir, 'functions', 'api'), functionsApiDir);
50
+ copyDir(path.join(srcFunctionsDir, 'api'), functionsApiDir);
59
51
  logSuccess('functions/api/ を更新しました');
60
52
  }
61
53
  // workers/ を置き換え(Pages Functions が import する)
62
- const workersDir = path.join(projectDir, 'workers');
63
- if (fs.existsSync(path.join(srcDir, 'workers'))) {
64
- if (fs.existsSync(workersDir)) {
65
- fs.rmSync(workersDir, { recursive: true, force: true });
54
+ const workersDestDir = path.join(projectDir, 'workers');
55
+ if (fs.existsSync(srcWorkersDir)) {
56
+ if (fs.existsSync(workersDestDir)) {
57
+ fs.rmSync(workersDestDir, { recursive: true, force: true });
66
58
  }
67
- copyDir(path.join(srcDir, 'workers'), workersDir);
59
+ copyDir(srcWorkersDir, workersDestDir);
68
60
  logSuccess('workers/ を更新しました');
69
61
  }
70
62
  // config.json を復元
@@ -80,9 +72,11 @@ export async function updateCommand(options) {
80
72
  fs.rmSync(tmpDir, { recursive: true, force: true });
81
73
  }
82
74
  }
83
- function copyDir(src, dest) {
75
+ function copyDir(src, dest, exclude = []) {
84
76
  fs.mkdirSync(dest, { recursive: true });
85
77
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
78
+ if (exclude.includes(entry.name))
79
+ continue;
86
80
  const srcPath = path.join(src, entry.name);
87
81
  const destPath = path.join(dest, entry.name);
88
82
  if (entry.isDirectory()) {
package/dist/index.js CHANGED
@@ -13,6 +13,15 @@ program
13
13
  .description('Initialize a new Frelio CMS project (creates repo, deploys admin, sets up R2)')
14
14
  .option('--skip-github', 'Skip GitHub repository creation')
15
15
  .option('--skip-cloudflare', 'Skip Cloudflare setup (R2, Pages)')
16
+ .option('--content-repo <repo>', 'Repository name (owner/repo)')
17
+ .option('--site-title <title>', 'Site title')
18
+ .option('--production-url <url>', 'Production URL')
19
+ .option('--staging-domain <domain>', 'Staging domain')
20
+ .option('--r2-bucket-name <name>', 'R2 bucket name')
21
+ .option('--r2-public-url <url>', 'R2 public URL')
22
+ .option('--owner-username <user>', 'Admin GitHub username')
23
+ .option('--client-id <id>', 'GitHub OAuth Client ID')
24
+ .option('--client-secret <secret>', 'GitHub OAuth Client Secret')
16
25
  .action(initCommand);
17
26
  program
18
27
  .command('update')
@@ -24,5 +33,7 @@ program
24
33
  .description('Add a staging environment (preview branch + Cloudflare Pages project)')
25
34
  .option('--name <name>', 'Staging name (creates staging-{name} branch)')
26
35
  .option('--skip-cloudflare', 'Skip Cloudflare Pages project creation')
36
+ .option('--pages-project <name>', 'Cloudflare Pages project name for staging')
37
+ .option('--domain <domain>', 'Custom domain for staging')
27
38
  .action(addStagingCommand);
28
39
  program.parse();
@@ -0,0 +1,26 @@
1
+ /**
2
+ * npm registry からの CMS バンドル取得
3
+ */
4
+ export type Release = {
5
+ tag_name: string;
6
+ tarballUrl: string;
7
+ };
8
+ export declare function getLatestRelease(): Promise<Release>;
9
+ export declare function getRelease(version: string): Promise<Release>;
10
+ export declare function downloadTarball(release: Release, destDir: string): Promise<string>;
11
+ /**
12
+ * npm tarball を展開し、バンドルディレクトリのパスを返す。
13
+ *
14
+ * npm tarball は `package/` ディレクトリに展開される。
15
+ * 中身は:
16
+ * package/dist/ — SPA (index.html, assets/) + bundled functions (functions/)
17
+ * package/workers/ — Worker ソース
18
+ */
19
+ export declare function extractNpmTarball(tarPath: string, tmpDir: string): Promise<{
20
+ /** SPA ファイル (functions/ を除く dist/ の中身) */
21
+ adminDir: string;
22
+ /** バンドル済み Pages Functions */
23
+ functionsDir: string;
24
+ /** Worker ソース */
25
+ workersDir: string;
26
+ }>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * npm registry からの CMS バンドル取得
3
+ */
4
+ import { createWriteStream } from 'node:fs';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { Readable } from 'node:stream';
7
+ import path from 'node:path';
8
+ const PACKAGE_NAME = '@c-time/frelio-cms';
9
+ const REGISTRY = 'https://registry.npmjs.org';
10
+ export async function getLatestRelease() {
11
+ const res = await fetch(`${REGISTRY}/${PACKAGE_NAME}/latest`);
12
+ if (!res.ok) {
13
+ throw new Error(`Failed to fetch latest version from npm: ${res.status} ${res.statusText}`);
14
+ }
15
+ const info = (await res.json());
16
+ return {
17
+ tag_name: `v${info.version}`,
18
+ tarballUrl: info.dist.tarball,
19
+ };
20
+ }
21
+ export async function getRelease(version) {
22
+ const ver = version.startsWith('v') ? version.slice(1) : version;
23
+ const res = await fetch(`${REGISTRY}/${PACKAGE_NAME}/${ver}`);
24
+ if (!res.ok) {
25
+ throw new Error(`Version ${ver} not found on npm: ${res.status}`);
26
+ }
27
+ const info = (await res.json());
28
+ return {
29
+ tag_name: `v${info.version}`,
30
+ tarballUrl: info.dist.tarball,
31
+ };
32
+ }
33
+ export async function downloadTarball(release, destDir) {
34
+ const destPath = path.join(destDir, `frelio-cms-${release.tag_name}.tgz`);
35
+ const res = await fetch(release.tarballUrl);
36
+ if (!res.ok || !res.body) {
37
+ throw new Error(`Failed to download tarball: ${res.status}`);
38
+ }
39
+ const readable = Readable.fromWeb(res.body);
40
+ await pipeline(readable, createWriteStream(destPath));
41
+ return destPath;
42
+ }
43
+ /**
44
+ * npm tarball を展開し、バンドルディレクトリのパスを返す。
45
+ *
46
+ * npm tarball は `package/` ディレクトリに展開される。
47
+ * 中身は:
48
+ * package/dist/ — SPA (index.html, assets/) + bundled functions (functions/)
49
+ * package/workers/ — Worker ソース
50
+ */
51
+ export async function extractNpmTarball(tarPath, tmpDir) {
52
+ const { extract } = await import('tar');
53
+ await extract({ file: tarPath, cwd: tmpDir });
54
+ return {
55
+ /** SPA ファイル (functions/ を除く dist/ の中身) */
56
+ adminDir: path.join(tmpDir, 'package', 'dist'),
57
+ /** バンドル済み Pages Functions */
58
+ functionsDir: path.join(tmpDir, 'package', 'dist', 'functions'),
59
+ /** Worker ソース */
60
+ workersDir: path.join(tmpDir, 'package', 'workers'),
61
+ };
62
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * CLI パラメータのバリデーション関数
3
+ * prompts の validate コールバックと非対話モードの両方で使用
4
+ */
5
+ export declare function validateContentRepo(v: string): true | string;
6
+ export declare function validateR2PublicUrl(v: string): true | string;
7
+ export declare function validateStagingName(v: string): true | string;
8
+ export declare function validateRequired(v: string, label: string): true | string;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * CLI パラメータのバリデーション関数
3
+ * prompts の validate コールバックと非対話モードの両方で使用
4
+ */
5
+ export function validateContentRepo(v) {
6
+ return v.includes('/') || 'owner/repo 形式で入力してください';
7
+ }
8
+ export function validateR2PublicUrl(v) {
9
+ return v === '' || v.startsWith('https://') || 'https:// で始まる URL を入力してください';
10
+ }
11
+ export function validateStagingName(v) {
12
+ if (!v)
13
+ return 'ステージング名を入力してください';
14
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(v))
15
+ return '小文字英数字とハイフンのみ使用可能です';
16
+ if (v === 'staging')
17
+ return '"staging" は予約済みです。別の名前を使用してください';
18
+ return true;
19
+ }
20
+ export function validateRequired(v, label) {
21
+ return v.length > 0 || `${label}は必須です`;
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c-time/frelio-cli",
3
- "version": "1.2.1",
3
+ "version": "1.3.11",
4
4
  "description": "Frelio CMS setup CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,15 +0,0 @@
1
- /**
2
- * GitHub Release からの tarball ダウンロード
3
- */
4
- type ReleaseAsset = {
5
- name: string;
6
- browser_download_url: string;
7
- };
8
- type Release = {
9
- tag_name: string;
10
- assets: ReleaseAsset[];
11
- };
12
- export declare function getLatestRelease(): Promise<Release>;
13
- export declare function getRelease(version: string): Promise<Release>;
14
- export declare function downloadTarball(release: Release, destDir: string): Promise<string>;
15
- export {};
@@ -1,41 +0,0 @@
1
- /**
2
- * GitHub Release からの tarball ダウンロード
3
- */
4
- import { createWriteStream } from 'node:fs';
5
- import { pipeline } from 'node:stream/promises';
6
- import { Readable } from 'node:stream';
7
- import path from 'node:path';
8
- const REPO = 'ctime-projects/frelio';
9
- export async function getLatestRelease() {
10
- const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
11
- headers: { Accept: 'application/vnd.github.v3+json' },
12
- });
13
- if (!res.ok) {
14
- throw new Error(`Failed to fetch latest release: ${res.status} ${res.statusText}`);
15
- }
16
- return res.json();
17
- }
18
- export async function getRelease(version) {
19
- const tag = version.startsWith('v') ? version : `v${version}`;
20
- const res = await fetch(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, {
21
- headers: { Accept: 'application/vnd.github.v3+json' },
22
- });
23
- if (!res.ok) {
24
- throw new Error(`Release ${tag} not found: ${res.status}`);
25
- }
26
- return res.json();
27
- }
28
- export async function downloadTarball(release, destDir) {
29
- const asset = release.assets.find((a) => a.name.endsWith('.tar.gz'));
30
- if (!asset) {
31
- throw new Error('No tarball found in release assets');
32
- }
33
- const destPath = path.join(destDir, asset.name);
34
- const res = await fetch(asset.browser_download_url);
35
- if (!res.ok || !res.body) {
36
- throw new Error(`Failed to download: ${res.status}`);
37
- }
38
- const readable = Readable.fromWeb(res.body);
39
- await pipeline(readable, createWriteStream(destPath));
40
- return destPath;
41
- }