import { spawnSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { stdin as input, stdout as output } from 'node:process'; import { createInterface } from 'node:readline/promises'; const root = process.cwd(); const workerConfigPath = path.join(root, 'wrangler.jsonc'); const wranglerCli = path.join(root, 'node_modules', 'wrangler', 'bin', 'wrangler.js'); const viteCli = path.join(root, 'node_modules', 'vite', 'bin', 'vite.js'); const requiredSecrets = [ 'APP_URL', 'ADMIN_EMAIL', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'SESSION_SECRET', 'TOKEN_ENCRYPTION_KEY', 'STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET', ]; function fail(message) { console.error(`\nSetup failed: ${message}`); process.exit(1); } function ensureProjectRoot() { if (!fs.existsSync(workerConfigPath)) { fail('Run npm run setup from the Slot project root.'); } if (!fs.existsSync(wranglerCli) || !fs.existsSync(viteCli)) { fail('Dependencies are missing. Run npm install, then npm run setup again.'); } } function runNodeCli(label, scriptPath, args, options = {}) { const { allowFailure = false, capture = false } = options; const result = spawnSync(process.execPath, [scriptPath, ...args], { cwd: root, shell: false, stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', encoding: capture ? 'utf8' : undefined, env: { ...process.env, NO_COLOR: process.env.NO_COLOR ?? '1' }, }); if (result.error) { if (allowFailure) return result; throw result.error; } if ((result.status ?? 1) !== 0 && !allowFailure) { process.exit(result.status ?? 1); } return result; } function wrangler(args, options) { return runNodeCli('wrangler', wranglerCli, args, options); } function build() { runNodeCli('vite', viteCli, ['build']); } function parseJsonOutput(result, fallback) { const text = `${result.stdout ?? ''}`.trim(); if (!text) return fallback; try { return JSON.parse(text); } catch { return fallback; } } function readConfig() { return JSON.parse(fs.readFileSync(workerConfigPath, 'utf8')); } function writeConfig(config) { fs.writeFileSync(workerConfigPath, `${JSON.stringify(config, null, 2)}\n`); } function dbNameFromConfig(config) { return config.d1_databases?.find((db) => db.binding === 'DB')?.database_name ?? `${config.name}-db`; } function updateD1Config(database) { const config = readConfig(); const databases = Array.isArray(config.d1_databases) ? config.d1_databases : []; const index = databases.findIndex((db) => db.binding === 'DB'); const current = index >= 0 ? databases[index] : { binding: 'DB' }; const next = { ...current, binding: 'DB', database_name: database.name, database_id: database.uuid, migrations_dir: current.migrations_dir ?? 'migrations', }; if (index >= 0) databases[index] = next; else databases.push(next); writeConfig({ ...config, d1_databases: databases }); } function ensureD1Database() { const config = readConfig(); const configured = config.d1_databases?.find((db) => db.binding === 'DB'); const databaseName = configured?.database_name ?? dbNameFromConfig(config); if (configured?.database_id) { console.log(`D1 database configured: ${databaseName}`); return databaseName; } const listResult = wrangler(['d1', 'list', '--json'], { capture: true }); const databases = parseJsonOutput(listResult, []); const existing = Array.isArray(databases) ? databases.find((db) => db.name === databaseName && db.uuid) : null; if (existing) { updateD1Config(existing); console.log(`Reusing D1 database: ${databaseName}`); return databaseName; } console.log(`Creating D1 database: ${databaseName}`); const createResult = wrangler(['d1', 'create', databaseName], { capture: true }); const outputText = `${createResult.stdout ?? ''}\n${createResult.stderr ?? ''}`; const id = outputText.match(/database_id\s*=\s*"([^"]+)"/)?.[1] ?? outputText.match(/"database_id"\s*:\s*"([^"]+)"/)?.[1] ?? outputText.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i)?.[1]; if (!id) { console.log(outputText.trim()); fail('Could not read the new D1 database ID from Wrangler output.'); } updateD1Config({ name: databaseName, uuid: id }); return databaseName; } function listExistingSecrets() { const result = wrangler(['secret', 'list', '--format', 'json'], { allowFailure: true, capture: true, }); if ((result.status ?? 1) !== 0) return null; const parsed = parseJsonOutput(result, []); if (!Array.isArray(parsed)) return new Set(); return new Set(parsed.map((secret) => secret.name).filter(Boolean)); } async function promptForConfiguration() { const rl = createInterface({ input, output }); try { console.log('\nGoogle + app + Stripe configuration\n'); const appUrl = (await rl.question('Public URL, for example https://book.example.com: ')).trim().replace(/\/+$/, ''); const adminEmail = (await rl.question('Google account allowed to administer this calendar: ')).trim(); const clientId = (await rl.question('Google OAuth Client ID: ')).trim(); const clientSecret = (await rl.question('Google OAuth Client Secret: ')).trim(); console.log('\nStripe configuration'); console.log('Use Stripe test-mode values while testing.'); const stripeSecretKey = (await rl.question('Stripe Secret Key (sk_test_... or sk_live_...): ')).trim(); const stripeWebhookSecret = (await rl.question('Stripe Webhook Signing Secret (whsec_...): ')).trim(); if (!/^https?:\/\//.test(appUrl)) { throw new Error('Public URL must begin with https:// (or http:// for local development).'); } if (!adminEmail || !clientId || !clientSecret) { throw new Error('Admin email and Google OAuth credentials are required.'); } if (!/^sk_(test|live)_/.test(stripeSecretKey)) { throw new Error('Stripe Secret Key must begin with sk_test_ or sk_live_.'); } if (!/^whsec_/.test(stripeWebhookSecret)) { throw new Error('Stripe Webhook Signing Secret must begin with whsec_.'); } console.log(`\nGoogle Authorized redirect URI (required):\n ${appUrl}/auth/google/callback`); console.log(`Google Authorized JavaScript origin (optional here):\n ${appUrl}\n`); return { appUrl, adminEmail, clientId, clientSecret, stripeSecretKey, stripeWebhookSecret }; } finally { rl.close(); } } function uploadSecrets(answers, existingSecrets) { const secrets = { APP_URL: answers.appUrl, ADMIN_EMAIL: answers.adminEmail, GOOGLE_CLIENT_ID: answers.clientId, GOOGLE_CLIENT_SECRET: answers.clientSecret, STRIPE_SECRET_KEY: answers.stripeSecretKey, STRIPE_WEBHOOK_SECRET: answers.stripeWebhookSecret, }; if (!existingSecrets.has('SESSION_SECRET')) { secrets.SESSION_SECRET = randomBytes(48).toString('base64url'); } if (!existingSecrets.has('TOKEN_ENCRYPTION_KEY')) { secrets.TOKEN_ENCRYPTION_KEY = randomBytes(32).toString('base64'); } const secretFile = path.join(root, `.meet-secrets-${process.pid}.json`); try { fs.writeFileSync(secretFile, JSON.stringify(secrets), { mode: 0o600 }); const result = wrangler(['secret', 'bulk', secretFile], { allowFailure: true, capture: true }); if ((result.status ?? 1) === 0) { process.stdout.write(result.stdout ?? ''); process.stderr.write(result.stderr ?? ''); return; } const outputText = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; if (!/Worker ".+" not found|run `wrangler deploy` first/i.test(outputText)) { process.stdout.write(result.stdout ?? ''); process.stderr.write(result.stderr ?? ''); process.exit(result.status ?? 1); } console.log('Creating the Worker shell so secrets can be attached...'); wrangler(['deploy']); wrangler(['secret', 'bulk', secretFile]); } finally { fs.rmSync(secretFile, { force: true }); } } function assertSecrets(existingSecrets) { const missing = requiredSecrets.filter((key) => !existingSecrets.has(key)); if (missing.length) { fail(`Missing Worker secrets after upload: ${missing.join(', ')}`); } } ensureProjectRoot(); console.log('\nSlot setup\n=========='); console.log('Cloudflare + Google Calendar + Stripe. No GitHub Actions, no Pages project, no API token.\n'); if ((wrangler(['whoami'], { allowFailure: true, capture: true }).status ?? 1) !== 0) { console.log('Opening Cloudflare login...'); wrangler(['login']); } console.log('\n1/6 Building the Worker and static assets...'); build(); console.log('\n2/6 Provisioning or reusing the D1 database...'); const databaseName = ensureD1Database(); console.log('\n3/6 Preparing Worker secrets...'); const existingSecrets = listExistingSecrets() ?? new Set(); const answers = await promptForConfiguration(); uploadSecrets(answers, existingSecrets); console.log('\n4/6 Applying D1 migrations...'); wrangler(['d1', 'migrations', 'apply', databaseName, '--remote']); console.log('\n5/6 Verifying Worker secrets...'); assertSecrets(listExistingSecrets() ?? new Set()); console.log('\n6/6 Deploying the Worker and static assets...'); wrangler(['deploy']); console.log('\nDone.\n'); console.log(`Booking page: ${answers.appUrl}`); console.log(`Admin: ${answers.appUrl}/admin`); console.log(`OAuth route: ${answers.appUrl}/auth/google/callback`); console.log('\nAttach a custom domain in Cloudflare Workers & Pages -> your Worker -> Settings -> Domains & Routes if you have not already done so.'); console.log('Then open /admin and connect the configured Google account.\n');