create-minpaku-os 0.1.0

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 ADDED
@@ -0,0 +1,74 @@
1
+ # create-minpaku-os
2
+
3
+ Minpaku-OS を Cloudflare に **5〜10分**でセットアップする対話型ウィザード。
4
+
5
+ ## 使い方
6
+
7
+ ```bash
8
+ npx create-minpaku-os@latest
9
+ ```
10
+
11
+ プロンプトに従って入力するだけで、以下が自動で実行されます:
12
+
13
+ 1. **リポジトリクローン**([traveler0215/minpaku-os](https://github.com/traveler0215/minpaku-os))
14
+ 2. **`npm install`** の実行
15
+ 3. **Cloudflare ログイン**(wrangler login、未ログイン時のみブラウザが開きます)
16
+ 4. **D1 データベース作成**
17
+ 5. **KV 名前空間作成**
18
+ 6. **`wrangler.toml`** の自動更新
19
+ 7. **マイグレーション適用**
20
+ 8. **シークレット登録**(Channel Secret / Access Token / JWT Secret 等)
21
+ 9. **Worker デプロイ**
22
+ 10. **管理画面ビルド + Pages デプロイ**
23
+ 11. **初回管理ユーザー登録**
24
+
25
+ ## 事前準備
26
+
27
+ 実行前に以下を用意してください:
28
+
29
+ - **Cloudflare アカウント**(無料枠で OK)
30
+ - **LINE Developers アカウント** + 以下のチャネルを作成済み
31
+ - Messaging API チャネル(スタッフ用ボット)
32
+ - LINEログインチャネル + LIFF アプリ(カレンダー入力用、サイズは Tall)
33
+ - **Node.js 20以上**
34
+ - **Git**
35
+
36
+ LINE チャネル作成の詳細は [https://minpaku-os-admin.pages.dev/setup](https://minpaku-os-admin.pages.dev/setup) を参照。
37
+
38
+ ## 入力する値
39
+
40
+ ウィザードから以下の値を聞かれます:
41
+
42
+ | 項目 | 例 |
43
+ |------|-----|
44
+ | プロジェクトのディレクトリ名 | `my-minpaku` |
45
+ | オーナー名(管理画面に表示) | `山田太郎` |
46
+ | 管理者メールアドレス | `owner@example.com` |
47
+ | Messaging API Channel Secret | `******` |
48
+ | Messaging API Channel Access Token | `******` |
49
+ | LIFF ID | `2009755679-D2HguPG1` |
50
+
51
+ ## 自動化されないこと
52
+
53
+ - **LINE Developers Console での設定**:チャネル作成・LIFF 作成・Webhook URL 設定などは LINE の仕様上 API では自動化できないため、ウィザード完了後に手動で行う必要があります(案内が表示されます)。
54
+
55
+ ## トラブルシューティング
56
+
57
+ ### wrangler コマンドが見つからない
58
+ `npm install` が完了していない可能性があります。ターゲットディレクトリ内で再実行してください。
59
+
60
+ ### D1 / KV の作成で「already exists」エラー
61
+ 同じ名前のリソースが既に存在します。別のプロジェクト名を指定するか、Cloudflare Dashboard で古いリソースを削除してください。
62
+
63
+ ### マイグレーションが途中で失敗
64
+ ネットワーク問題の可能性があります。ターゲットディレクトリで以下を手動実行して継続できます:
65
+
66
+ ```bash
67
+ for f in packages/db/migrations/*.sql; do
68
+ npx wrangler d1 execute <database_name> --remote --file=$f
69
+ done
70
+ ```
71
+
72
+ ## ライセンス
73
+
74
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Entrypoint shim for `npx create-minpaku-os`
4
+ // Delegates to the compiled ESM module in dist/
5
+
6
+ import { main } from '../dist/index.js'
7
+
8
+ main().catch((error) => {
9
+ console.error('\n❌ セットアップに失敗しました:')
10
+ console.error(error?.stack ?? error?.message ?? error)
11
+ process.exit(1)
12
+ })
@@ -0,0 +1,105 @@
1
+ import { run, runInteractive, extractD1Id, extractKvId, info, success } from './utils.js';
2
+ /**
3
+ * wrangler がインストールされているか確認。無ければ一時 npx で代用できるか検証。
4
+ */
5
+ export async function ensureWrangler(cwd) {
6
+ try {
7
+ await run('npx', ['wrangler', '--version'], { cwd });
8
+ }
9
+ catch {
10
+ throw new Error('wrangler が見つかりません。npm install が完了しているか確認してください。');
11
+ }
12
+ }
13
+ /**
14
+ * Cloudflare にログインしているか確認。未ログインなら wrangler login を走らせる。
15
+ */
16
+ export async function ensureLoggedIn(cwd) {
17
+ try {
18
+ const { stdout } = await run('npx', ['wrangler', 'whoami'], { cwd });
19
+ if (stdout.toLowerCase().includes('not logged in') || stdout.includes('You are not authenticated')) {
20
+ throw new Error('not logged in');
21
+ }
22
+ success('Cloudflare にログイン済み');
23
+ }
24
+ catch {
25
+ info('Cloudflare にログインします。ブラウザが開きます…');
26
+ await runInteractive('npx', ['wrangler', 'login'], { cwd });
27
+ success('Cloudflare ログイン完了');
28
+ }
29
+ }
30
+ /**
31
+ * D1 データベースを作成して ID を返す。既に同名があれば既存 ID を取得する。
32
+ */
33
+ export async function createD1Database(cwd, name) {
34
+ try {
35
+ const { stdout } = await run('npx', ['wrangler', 'd1', 'create', name], { cwd });
36
+ const id = extractD1Id(stdout);
37
+ if (!id)
38
+ throw new Error(`D1 ID を抽出できませんでした。\n${stdout}`);
39
+ return id;
40
+ }
41
+ catch (err) {
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ if (message.includes('already exists')) {
44
+ // フォールバック: 一覧から ID を取得
45
+ const { stdout } = await run('npx', ['wrangler', 'd1', 'list', '--json'], { cwd });
46
+ try {
47
+ const databases = JSON.parse(stdout);
48
+ const existing = databases.find((db) => db.name === name);
49
+ if (existing)
50
+ return existing.uuid;
51
+ }
52
+ catch {
53
+ /* ignore */
54
+ }
55
+ }
56
+ throw err;
57
+ }
58
+ }
59
+ /**
60
+ * KV 名前空間を作成して ID を返す。既に同名があれば既存 ID を取得する。
61
+ */
62
+ export async function createKvNamespace(cwd, name = 'KV') {
63
+ try {
64
+ const { stdout } = await run('npx', ['wrangler', 'kv', 'namespace', 'create', name], { cwd });
65
+ const id = extractKvId(stdout);
66
+ if (!id)
67
+ throw new Error(`KV ID を抽出できませんでした。\n${stdout}`);
68
+ return id;
69
+ }
70
+ catch (err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ if (message.includes('already exists') || message.includes('duplicate')) {
73
+ // フォールバック: 一覧から取得
74
+ const { stdout } = await run('npx', ['wrangler', 'kv', 'namespace', 'list'], { cwd });
75
+ try {
76
+ const namespaces = JSON.parse(stdout);
77
+ const existing = namespaces.find((ns) => ns.title.endsWith(name) || ns.title === name);
78
+ if (existing)
79
+ return existing.id;
80
+ }
81
+ catch {
82
+ /* ignore */
83
+ }
84
+ }
85
+ throw err;
86
+ }
87
+ }
88
+ /**
89
+ * マイグレーション SQL ファイルを全件適用(ファイル名順)
90
+ */
91
+ export async function runMigrations(cwd, databaseName, files) {
92
+ for (const file of files) {
93
+ info(`適用中: ${file}`);
94
+ await run('npx', ['wrangler', 'd1', 'execute', databaseName, '--remote', '--file', file], { cwd });
95
+ }
96
+ }
97
+ /**
98
+ * wrangler secret put を stdin 入力で実行
99
+ */
100
+ export async function putSecret(cwd, key, value) {
101
+ await run('npx', ['wrangler', 'secret', 'put', key], {
102
+ cwd,
103
+ input: value,
104
+ });
105
+ }
package/dist/deploy.js ADDED
@@ -0,0 +1,61 @@
1
+ import { run, extractHttpsUrl, info } from './utils.js';
2
+ /**
3
+ * Worker をデプロイしてその URL を返す
4
+ */
5
+ export async function deployWorker(cwd) {
6
+ const { stdout, stderr } = await run('npx', ['wrangler', 'deploy'], { cwd });
7
+ const combined = stdout + '\n' + stderr;
8
+ const url = extractHttpsUrl(combined, 'workers.dev');
9
+ if (!url)
10
+ throw new Error(`Worker デプロイ後の URL を抽出できませんでした。\n${combined}`);
11
+ return url;
12
+ }
13
+ /**
14
+ * 管理画面をビルド(apps/admin/.env.production を事前に書き込んでから呼ぶ)
15
+ */
16
+ export async function buildAdmin(cwd) {
17
+ info('npm install (workspace) 実行中...');
18
+ await run('npm', ['install'], { cwd, timeout: 300_000 });
19
+ info('vite build 実行中...');
20
+ const adminCwd = `${cwd}/apps/admin`;
21
+ await run('npm', ['run', 'build'], { cwd: adminCwd, timeout: 300_000 });
22
+ }
23
+ /**
24
+ * Cloudflare Pages にデプロイしてその URL を返す
25
+ */
26
+ export async function deployAdminPages(cwd, projectName) {
27
+ // Pages プロジェクト名は lowercase で英数字+ハイフンのみ
28
+ const sanitized = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 58);
29
+ const pagesProjectName = `${sanitized}-admin`;
30
+ // プロジェクトがまだ無ければ作成
31
+ try {
32
+ await run('npx', ['wrangler', 'pages', 'project', 'create', pagesProjectName, '--production-branch', 'main'], { cwd });
33
+ }
34
+ catch {
35
+ // 既存の場合はそのまま続行
36
+ }
37
+ const { stdout, stderr } = await run('npx', [
38
+ 'wrangler',
39
+ 'pages',
40
+ 'deploy',
41
+ 'apps/admin/dist',
42
+ '--project-name',
43
+ pagesProjectName,
44
+ '--commit-dirty=true',
45
+ ], { cwd });
46
+ const combined = stdout + '\n' + stderr;
47
+ const url = extractHttpsUrl(combined, 'pages.dev');
48
+ if (!url)
49
+ throw new Error(`Pages デプロイ後の URL を抽出できませんでした。\n${combined}`);
50
+ return url;
51
+ }
52
+ /**
53
+ * 管理ユーザーを D1 に登録
54
+ */
55
+ export async function createAdminUser(cwd, databaseName, email, name) {
56
+ // SQL インジェクション対策: シングルクォートをエスケープ
57
+ const safeEmail = email.replace(/'/g, "''");
58
+ const safeName = name.replace(/'/g, "''");
59
+ const sql = `INSERT INTO admin_users (email, name, role) VALUES ('${safeEmail}', '${safeName}', 'owner') ON CONFLICT(email) DO UPDATE SET name=excluded.name, role='owner', is_active=1`;
60
+ await run('npx', ['wrangler', 'd1', 'execute', databaseName, '--remote', '--command', sql], { cwd });
61
+ }
package/dist/index.js ADDED
@@ -0,0 +1,177 @@
1
+ import path from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import chalk from 'chalk';
4
+ import tiged from 'tiged';
5
+ import { collectAnswers } from './prompts.js';
6
+ import { ensureWrangler, ensureLoggedIn, createD1Database, createKvNamespace, runMigrations, putSecret, } from './cloudflare.js';
7
+ import { updateWranglerToml, writeAdminEnvProduction, listMigrations, } from './templates.js';
8
+ import { deployWorker, buildAdmin, deployAdminPages, createAdminUser, } from './deploy.js';
9
+ import { header, info, success, warn, fail, startSpinner, randomHex, run, } from './utils.js';
10
+ const TEMPLATE_REPO = 'traveler0215/minpaku-os';
11
+ export async function main() {
12
+ printBanner();
13
+ const answers = await collectAnswers();
14
+ const targetDir = path.resolve(process.cwd(), answers.projectName);
15
+ if (existsSync(targetDir)) {
16
+ fail(`ディレクトリ "${answers.projectName}" は既に存在します。別の名前を選んでください。`);
17
+ process.exit(1);
18
+ }
19
+ // ───────────────────────────────────────────────
20
+ header('[1/8] リポジトリをクローン');
21
+ const cloneSpinner = startSpinner(`${TEMPLATE_REPO} をクローン中...`);
22
+ try {
23
+ const emitter = tiged(TEMPLATE_REPO, { cache: false, force: true, verbose: false });
24
+ await emitter.clone(targetDir);
25
+ cloneSpinner.succeed('リポジトリをクローンしました');
26
+ }
27
+ catch (error) {
28
+ cloneSpinner.fail('クローンに失敗しました');
29
+ throw error;
30
+ }
31
+ // ───────────────────────────────────────────────
32
+ header('[2/8] 依存関係をインストール');
33
+ const installSpinner = startSpinner('npm install 実行中 (数分かかる場合があります)...');
34
+ try {
35
+ await run('npm', ['install'], { cwd: targetDir, timeout: 600_000 });
36
+ installSpinner.succeed('npm install 完了');
37
+ }
38
+ catch (error) {
39
+ installSpinner.fail('npm install に失敗しました');
40
+ throw error;
41
+ }
42
+ // ───────────────────────────────────────────────
43
+ header('[3/8] Cloudflare にログイン');
44
+ await ensureWrangler(targetDir);
45
+ await ensureLoggedIn(targetDir);
46
+ // ───────────────────────────────────────────────
47
+ header('[4/8] Cloudflare リソースを作成');
48
+ const databaseName = answers.projectName;
49
+ const workerName = `${answers.projectName}-worker`;
50
+ const d1Spinner = startSpinner('D1 データベースを作成中...');
51
+ const d1Id = await createD1Database(targetDir, databaseName);
52
+ d1Spinner.succeed(`D1 データベース作成: ${chalk.dim(d1Id)}`);
53
+ const kvSpinner = startSpinner('KV 名前空間を作成中...');
54
+ const kvId = await createKvNamespace(targetDir, 'KV');
55
+ kvSpinner.succeed(`KV 名前空間作成: ${chalk.dim(kvId)}`);
56
+ info('wrangler.toml を自動更新...');
57
+ await updateWranglerToml(targetDir, {
58
+ workerName,
59
+ databaseName,
60
+ d1Id,
61
+ kvId,
62
+ liffId: answers.liffId,
63
+ });
64
+ success('wrangler.toml を更新しました');
65
+ // ───────────────────────────────────────────────
66
+ header('[5/8] DB マイグレーションを適用');
67
+ const migrations = await listMigrations(targetDir);
68
+ info(`${migrations.length} 件のマイグレーションを実行します`);
69
+ await runMigrations(targetDir, databaseName, migrations);
70
+ success('マイグレーション完了');
71
+ // ───────────────────────────────────────────────
72
+ header('[6/8] シークレットを登録');
73
+ const jwtSecret = randomHex(32);
74
+ const secretMap = {
75
+ ADMIN_JWT_SECRET: jwtSecret,
76
+ LINE_CHANNEL_SECRET: answers.lineChannelSecret,
77
+ LINE_CHANNEL_ACCESS_TOKEN: answers.lineChannelAccessToken,
78
+ LINE_STAFF_CHANNEL_SECRET: answers.lineChannelSecret,
79
+ LINE_STAFF_ACCESS_TOKEN: answers.lineChannelAccessToken,
80
+ };
81
+ for (const [key, value] of Object.entries(secretMap)) {
82
+ const spinner = startSpinner(`${key} を登録中...`);
83
+ try {
84
+ await putSecret(targetDir, key, value);
85
+ spinner.succeed(`${key} を登録しました`);
86
+ }
87
+ catch (error) {
88
+ spinner.fail(`${key} の登録に失敗しました`);
89
+ throw error;
90
+ }
91
+ }
92
+ // ───────────────────────────────────────────────
93
+ header('[7/8] デプロイ');
94
+ const deploySpinner = startSpinner('Worker をデプロイ中...');
95
+ const workerUrl = await deployWorker(targetDir);
96
+ deploySpinner.succeed(`Worker デプロイ完了: ${chalk.underline(workerUrl)}`);
97
+ info('apps/admin/.env.production を生成...');
98
+ await writeAdminEnvProduction(targetDir, { workerUrl, liffId: answers.liffId });
99
+ success('.env.production 生成完了');
100
+ const buildSpinner = startSpinner('管理画面をビルド中...');
101
+ try {
102
+ await buildAdmin(targetDir);
103
+ buildSpinner.succeed('管理画面ビルド完了');
104
+ }
105
+ catch (error) {
106
+ buildSpinner.fail('管理画面のビルドに失敗しました');
107
+ throw error;
108
+ }
109
+ const pagesSpinner = startSpinner('Cloudflare Pages にデプロイ中...');
110
+ const pagesUrl = await deployAdminPages(targetDir, answers.projectName);
111
+ pagesSpinner.succeed(`管理画面デプロイ完了: ${chalk.underline(pagesUrl)}`);
112
+ // ───────────────────────────────────────────────
113
+ header('[8/8] 最初の管理ユーザーを登録');
114
+ await createAdminUser(targetDir, databaseName, answers.ownerEmail, answers.ownerName);
115
+ success(`管理ユーザー登録: ${answers.ownerEmail}`);
116
+ // ───────────────────────────────────────────────
117
+ printSuccess({
118
+ projectName: answers.projectName,
119
+ workerUrl,
120
+ pagesUrl,
121
+ ownerEmail: answers.ownerEmail,
122
+ liffId: answers.liffId,
123
+ });
124
+ }
125
+ function printBanner() {
126
+ console.log();
127
+ console.log(chalk.hex('#06C755').bold(' ╔═══════════════════════════════════════╗'));
128
+ console.log(chalk.hex('#06C755').bold(' ║ Minpaku-OS セットアップウィザード ║'));
129
+ console.log(chalk.hex('#06C755').bold(' ╚═══════════════════════════════════════╝'));
130
+ console.log();
131
+ console.log(chalk.dim(' LINE × Cloudflare で動く無料 PMS のセットアップを自動化します'));
132
+ console.log();
133
+ console.log(chalk.dim(' このウィザードは以下を自動で実行します:'));
134
+ console.log(chalk.dim(' • リポジトリクローン + npm install'));
135
+ console.log(chalk.dim(' • Cloudflare D1 / KV の作成'));
136
+ console.log(chalk.dim(' • マイグレーション 9件の適用'));
137
+ console.log(chalk.dim(' • シークレット登録'));
138
+ console.log(chalk.dim(' • Worker + 管理画面のデプロイ'));
139
+ console.log(chalk.dim(' • 初回管理ユーザーの登録'));
140
+ console.log();
141
+ console.log(chalk.dim(' 所要時間: 5〜10分(npm install / デプロイ時間による)'));
142
+ console.log();
143
+ }
144
+ function printSuccess(params) {
145
+ console.log();
146
+ console.log(chalk.green.bold(' ╔═══════════════════════════════════════╗'));
147
+ console.log(chalk.green.bold(' ║ 🎉 セットアップ完了! ║'));
148
+ console.log(chalk.green.bold(' ╚═══════════════════════════════════════╝'));
149
+ console.log();
150
+ console.log(chalk.bold(' 📍 デプロイ URL:'));
151
+ console.log(` 管理画面: ${chalk.underline(params.pagesUrl)}`);
152
+ console.log(` Worker: ${chalk.underline(params.workerUrl)}`);
153
+ console.log();
154
+ console.log(chalk.bold(' 🔐 ログイン:'));
155
+ console.log(` ${params.pagesUrl}/login で以下のメールを入力:`);
156
+ console.log(` ${chalk.green(params.ownerEmail)}`);
157
+ console.log();
158
+ console.log(chalk.bold(' ⚠️ 最後の手動ステップ(LINE Developers Console):'));
159
+ console.log();
160
+ console.log(` 1. Messaging API チャネル → Webhook URL を以下に設定:`);
161
+ console.log(` ${chalk.cyan(params.workerUrl + '/webhook/line')}`);
162
+ console.log(` 「Webhook の利用」を ON / 「応答メッセージ」を OFF`);
163
+ console.log();
164
+ console.log(` 2. LIFF アプリ (ID: ${chalk.cyan(params.liffId)}) の`);
165
+ console.log(` エンドポイント URL を以下に設定:`);
166
+ console.log(` ${chalk.cyan(params.pagesUrl + '/shift-picker')}`);
167
+ console.log();
168
+ console.log(chalk.bold(' 📖 次のステップ:'));
169
+ console.log(` • 物件を登録: ${params.pagesUrl}/properties`);
170
+ console.log(` • スタッフを招待: ${params.pagesUrl}/staff`);
171
+ console.log(` • iCal URL 設定で Airbnb / Booking.com と同期`);
172
+ console.log();
173
+ console.log(chalk.dim(` 問題があれば: https://github.com/${TEMPLATE_REPO}/issues`));
174
+ console.log();
175
+ warn(`プロジェクトディレクトリ: ${chalk.bold('./' + params.projectName)}`);
176
+ console.log();
177
+ }
@@ -0,0 +1,76 @@
1
+ import { input, password, confirm } from '@inquirer/prompts';
2
+ export async function collectAnswers() {
3
+ const projectName = await input({
4
+ message: 'プロジェクトのディレクトリ名',
5
+ default: 'my-minpaku',
6
+ validate: (value) => {
7
+ if (!value.trim())
8
+ return 'ディレクトリ名は必須です';
9
+ if (!/^[a-z0-9][a-z0-9-_]*$/i.test(value))
10
+ return '英数字・ハイフン・アンダースコアのみ使えます';
11
+ return true;
12
+ },
13
+ });
14
+ const ownerName = await input({
15
+ message: 'オーナー名(管理画面に表示されます)',
16
+ default: 'オーナー',
17
+ validate: (v) => v.trim().length > 0 || 'オーナー名は必須です',
18
+ });
19
+ const ownerEmail = await input({
20
+ message: '管理者メールアドレス(ログインに使用)',
21
+ validate: (v) => {
22
+ if (!v.trim())
23
+ return 'メールアドレスは必須です';
24
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v))
25
+ return '有効なメールアドレスを入力してください';
26
+ return true;
27
+ },
28
+ });
29
+ console.log();
30
+ console.log('LINE 設定の前に以下を準備してください:');
31
+ console.log(' 1. LINE Developers Console でプロバイダー作成');
32
+ console.log(' 2. Messaging API チャネル作成 (スタッフ用ボット)');
33
+ console.log(' 3. LINEログインチャネル作成 + LIFFアプリ追加 (カレンダー入力用)');
34
+ console.log(' ※ ボットリンク機能は「On (Aggressive)」にしてください');
35
+ console.log();
36
+ console.log('詳細: https://minpaku-os-admin.pages.dev/setup');
37
+ console.log();
38
+ const ready = await confirm({
39
+ message: 'LINE チャネル作成は済んでいますか?',
40
+ default: true,
41
+ });
42
+ if (!ready) {
43
+ console.log();
44
+ console.log('チャネルを作ってから再度実行してください。');
45
+ process.exit(0);
46
+ }
47
+ const lineChannelSecret = await password({
48
+ message: 'Messaging API の Channel Secret',
49
+ mask: '*',
50
+ validate: (v) => v.trim().length > 0 || 'Channel Secret は必須です',
51
+ });
52
+ const lineChannelAccessToken = await password({
53
+ message: 'Messaging API の Channel Access Token',
54
+ mask: '*',
55
+ validate: (v) => v.trim().length > 0 || 'Channel Access Token は必須です',
56
+ });
57
+ const liffId = await input({
58
+ message: 'LIFF ID (例: 2009755679-D2HguPG1)',
59
+ validate: (v) => {
60
+ if (!v.trim())
61
+ return 'LIFF ID は必須です';
62
+ if (!/^\d+-\w+$/.test(v.trim()))
63
+ return '形式が正しくありません (例: 2009755679-D2HguPG1)';
64
+ return true;
65
+ },
66
+ });
67
+ return {
68
+ projectName: projectName.trim(),
69
+ ownerName: ownerName.trim(),
70
+ ownerEmail: ownerEmail.trim().toLowerCase(),
71
+ lineChannelSecret: lineChannelSecret.trim(),
72
+ lineChannelAccessToken: lineChannelAccessToken.trim(),
73
+ liffId: liffId.trim(),
74
+ useSameChannelForStaff: true,
75
+ };
76
+ }
@@ -0,0 +1,41 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * wrangler.toml の D1 ID / KV ID / LIFF ID / name を書き換える。
5
+ * TOML ライブラリを使わずシンプルな正規表現で処理(@iarna/toml のコメント保持問題回避)。
6
+ */
7
+ export async function updateWranglerToml(cwd, values) {
8
+ const filePath = path.join(cwd, 'wrangler.toml');
9
+ let content = await readFile(filePath, 'utf-8');
10
+ content = content.replace(/^name\s*=\s*"[^"]*"/m, `name = "${values.workerName}"`);
11
+ content = content.replace(/LIFF_ID\s*=\s*"[^"]*"/, `LIFF_ID = "${values.liffId}"`);
12
+ content = content.replace(/database_name\s*=\s*"[^"]*"/, `database_name = "${values.databaseName}"`);
13
+ content = content.replace(/database_id\s*=\s*"[^"]*"/, `database_id = "${values.d1Id}"`);
14
+ // KV の id(他の id と衝突しないように [[kv_namespaces]] ブロック内だけを狙う)
15
+ content = content.replace(/(\[\[kv_namespaces\]\][\s\S]*?)id\s*=\s*"[^"]*"/, `$1id = "${values.kvId}"`);
16
+ await writeFile(filePath, content, 'utf-8');
17
+ }
18
+ /**
19
+ * apps/admin/.env.production を書き込む
20
+ */
21
+ export async function writeAdminEnvProduction(cwd, values) {
22
+ const filePath = path.join(cwd, 'apps', 'admin', '.env.production');
23
+ const content = [
24
+ `VITE_API_BASE_URL=${values.workerUrl}`,
25
+ `VITE_LIFF_ID=${values.liffId}`,
26
+ '',
27
+ ].join('\n');
28
+ await writeFile(filePath, content, 'utf-8');
29
+ }
30
+ /**
31
+ * マイグレーション SQL のパス一覧を取得
32
+ */
33
+ export async function listMigrations(cwd) {
34
+ const { readdir } = await import('node:fs/promises');
35
+ const dir = path.join(cwd, 'packages', 'db', 'migrations');
36
+ const files = await readdir(dir);
37
+ return files
38
+ .filter((f) => f.endsWith('.sql'))
39
+ .sort()
40
+ .map((f) => path.join('packages', 'db', 'migrations', f));
41
+ }
package/dist/utils.js ADDED
@@ -0,0 +1,97 @@
1
+ import { execa } from 'execa';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ export function header(text) {
5
+ console.log();
6
+ console.log(chalk.bold.hex('#06C755')('━━━ ' + text + ' ━━━'));
7
+ }
8
+ export function info(text) {
9
+ console.log(chalk.dim(' ' + text));
10
+ }
11
+ export function success(text) {
12
+ console.log(chalk.green(' ✓ ') + text);
13
+ }
14
+ export function warn(text) {
15
+ console.log(chalk.yellow(' ⚠ ') + text);
16
+ }
17
+ export function fail(text) {
18
+ console.log(chalk.red(' ✗ ') + text);
19
+ }
20
+ export function startSpinner(text) {
21
+ return ora({ text, color: 'green' }).start();
22
+ }
23
+ /**
24
+ * 子プロセスを実行。stdout/stderr は黙殺し、エラー時だけまとめて throw。
25
+ */
26
+ export async function run(command, args, options = {}) {
27
+ const result = await execa(command, args, {
28
+ stdio: 'pipe',
29
+ ...options,
30
+ });
31
+ return {
32
+ stdout: typeof result.stdout === 'string' ? result.stdout : String(result.stdout ?? ''),
33
+ stderr: typeof result.stderr === 'string' ? result.stderr : String(result.stderr ?? ''),
34
+ };
35
+ }
36
+ /**
37
+ * 対話モードで子プロセスを実行(ブラウザログイン等で必要)
38
+ */
39
+ export async function runInteractive(command, args, options = {}) {
40
+ await execa(command, args, {
41
+ stdio: 'inherit',
42
+ ...options,
43
+ });
44
+ }
45
+ /**
46
+ * wrangler の出力から UUID 形式の ID を抽出
47
+ */
48
+ export function extractUuid(output) {
49
+ const match = output.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
50
+ return match ? match[0] : null;
51
+ }
52
+ /**
53
+ * wrangler d1 create の出力から database_id を抽出
54
+ */
55
+ export function extractD1Id(output) {
56
+ // 例: database_id = "e30c1827-a8f5-4d45-8dd5-625abf3ec385"
57
+ const match = output.match(/database_id\s*=\s*"([0-9a-f-]+)"/i);
58
+ if (match)
59
+ return match[1];
60
+ return extractUuid(output);
61
+ }
62
+ /**
63
+ * wrangler kv namespace create の出力から id を抽出(32桁の hex 文字列)
64
+ */
65
+ export function extractKvId(output) {
66
+ // 例: id = "241afecb3a344700a2489c0ab0c8d541"
67
+ const match = output.match(/id\s*=\s*"([0-9a-f]{32})"/i);
68
+ if (match)
69
+ return match[1];
70
+ const hex = output.match(/[0-9a-f]{32}/i);
71
+ return hex ? hex[0] : null;
72
+ }
73
+ /**
74
+ * wrangler deploy / pages deploy の出力から https URL を抽出
75
+ */
76
+ export function extractHttpsUrl(output, hint) {
77
+ const matches = output.match(/https:\/\/[a-zA-Z0-9.\-_/]+/g);
78
+ if (!matches)
79
+ return null;
80
+ if (hint) {
81
+ const filtered = matches.find((u) => u.includes(hint));
82
+ if (filtered)
83
+ return filtered.replace(/[\s)].*$/, '');
84
+ }
85
+ return matches[0].replace(/[\s)].*$/, '');
86
+ }
87
+ export function randomHex(bytes = 32) {
88
+ const chars = '0123456789abcdef';
89
+ let result = '';
90
+ for (let i = 0; i < bytes * 2; i++) {
91
+ result += chars[Math.floor(Math.random() * 16)];
92
+ }
93
+ return result;
94
+ }
95
+ export function sleep(ms) {
96
+ return new Promise((resolve) => setTimeout(resolve, ms));
97
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "create-minpaku-os",
3
+ "version": "0.1.0",
4
+ "description": "Interactive wizard to set up Minpaku-OS on Cloudflare in 5 minutes",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-minpaku-os": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsc --watch",
17
+ "start": "node bin/cli.js",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "dependencies": {
21
+ "@inquirer/prompts": "^5.3.8",
22
+ "@iarna/toml": "^2.2.5",
23
+ "chalk": "^5.3.0",
24
+ "execa": "^9.3.0",
25
+ "ora": "^8.0.1",
26
+ "tiged": "^2.12.7"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20.11.0",
30
+ "typescript": "^5.4.0"
31
+ },
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "keywords": [
36
+ "minpaku",
37
+ "pms",
38
+ "cloudflare",
39
+ "line",
40
+ "cli",
41
+ "wizard",
42
+ "create"
43
+ ],
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/traveler0215/minpaku-os.git",
48
+ "directory": "packages/create-minpaku-os"
49
+ }
50
+ }