NobGit
public imalexnord read

Slot

Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.

Languages

Repository composition by tracked source files.

TypeScript
TypeScript 79% CSS 19% SQL 2% HTML 0%
Create file Wiki Documentation
Clone
https://nobgit.com/user/imalexnord/slot.git
ssh://[email protected]:2222/user/imalexnord/slot.git
setup.mjs
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');