Skip to content

Interactive fly deploy tool #51

@michael

Description

@michael

Needs evaluation/adjustments for EW:

#!/usr/bin/env node

/**
 * Fly.io Interactive Deploy Tool
 * 
 * Guides users through first-time Fly.io deployment with smart defaults,
 * validation, and the ability to review/edit before executing.
 * 
 * Usage: node tools/fly-deploy.js [--dry-run]
 */

import { execSync, spawn } from 'child_process';
import { createInterface } from 'readline';
import { randomBytes } from 'crypto';
import { existsSync, statSync } from 'fs';
import Database from 'better-sqlite3';
import { ENV_CONFIG } from '../scripts/validate-env.js';

// Default local database path
const LOCAL_DB_PATH = './data/db.sqlite3';

// ============================================================================
// CONSTANTS
// ============================================================================

const FLY_REGIONS = [
  { code: 'ams', name: 'Amsterdam, Netherlands' },
  { code: 'cdg', name: 'Paris, France' },
  { code: 'fra', name: 'Frankfurt, Germany' },
  { code: 'lhr', name: 'London, United Kingdom' },
  { code: 'mad', name: 'Madrid, Spain' },
  { code: 'waw', name: 'Warsaw, Poland' },
  { code: 'iad', name: 'Ashburn, Virginia (US)' },
  { code: 'lax', name: 'Los Angeles, California (US)' },
  { code: 'ord', name: 'Chicago, Illinois (US)' },
  { code: 'sea', name: 'Seattle, Washington (US)' },
  { code: 'sjc', name: 'San Jose, California (US)' },
  { code: 'syd', name: 'Sydney, Australia' },
  { code: 'nrt', name: 'Tokyo, Japan' },
  { code: 'sin', name: 'Singapore' },
];

const LANGUAGES = [
  { code: 'en', name: 'English' },
  { code: 'de', name: 'German' },
  { code: 'fr', name: 'French' },
  { code: 'es', name: 'Spanish' },
  { code: 'it', name: 'Italian' },
  { code: 'nl', name: 'Dutch' },
  { code: 'pt', name: 'Portuguese' },
  { code: 'pl', name: 'Polish' },
];

const TIME_ZONES = [
  { code: 'Europe/London', name: 'London (GMT/BST)' },
  { code: 'Europe/Berlin', name: 'Berlin, Frankfurt, Paris (CET/CEST)' },
  { code: 'Europe/Amsterdam', name: 'Amsterdam (CET/CEST)' },
  { code: 'Europe/Warsaw', name: 'Warsaw (CET/CEST)' },
  { code: 'America/New_York', name: 'New York (EST/EDT)' },
  { code: 'America/Chicago', name: 'Chicago (CST/CDT)' },
  { code: 'America/Los_Angeles', name: 'Los Angeles (PST/PDT)' },
  { code: 'Asia/Tokyo', name: 'Tokyo (JST)' },
  { code: 'Asia/Singapore', name: 'Singapore (SGT)' },
  { code: 'Australia/Sydney', name: 'Sydney (AEST/AEDT)' },
];

const LOCALES = [
  { code: 'en-GB', name: 'English (UK) - dd/mm/yyyy' },
  { code: 'en-US', name: 'English (US) - mm/dd/yyyy' },
  { code: 'de-DE', name: 'German (Germany) - dd.mm.yyyy' },
  { code: 'fr-FR', name: 'French (France) - dd/mm/yyyy' },
  { code: 'es-ES', name: 'Spanish (Spain) - dd/mm/yyyy' },
  { code: 'it-IT', name: 'Italian (Italy) - dd/mm/yyyy' },
  { code: 'nl-NL', name: 'Dutch (Netherlands) - dd-mm-yyyy' },
  { code: 'pt-PT', name: 'Portuguese (Portugal) - dd/mm/yyyy' },
  { code: 'pl-PL', name: 'Polish (Poland) - dd.mm.yyyy' },
];

const WEEK_STARTS = [
  { code: '0', name: 'Sunday' },
  { code: '1', name: 'Monday' },
  { code: '6', name: 'Saturday' },
];

const HOUR_FORMATS = [
  { code: '24', name: '24-hour (14:00)' },
  { code: '12', name: '12-hour (2:00 PM)' },
];

const VM_SIZES = [
  { code: 'shared-cpu-1x', name: 'Shared CPU 1x (cheapest, good for low traffic)' },
  { code: 'shared-cpu-2x', name: 'Shared CPU 2x (moderate traffic)' },
  { code: 'shared-cpu-4x', name: 'Shared CPU 4x (higher traffic)' },
];

const VM_MEMORY = [
  { code: '256', name: '256 MB (minimum)' },
  { code: '512', name: '512 MB (recommended)' },
  { code: '1024', name: '1024 MB (1 GB)' },
  { code: '2048', name: '2048 MB (2 GB)' },
];

// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================

/**
 * Generate a secure random base64 string
 * @param {number} bytes - Number of bytes
 * @returns {string} Base64 encoded string
 */
function generate_secret(bytes = 32) {
  return randomBytes(bytes).toString('base64');
}

/**
 * Escape a value for shell single quotes
 * @param {string} value - Value to escape
 * @returns {string} Escaped value safe for single quotes
 */
function escape_for_shell(value) {
  // For single quotes, we need to end the quote, add escaped single quote, and restart
  return value.replace(/'/g, "'\\''");
}

/**
 * Create readline interface
 * @returns {readline.Interface}
 */
function create_rl() {
  return createInterface({
    input: process.stdin,
    output: process.stdout,
  });
}

/**
 * Prompt user for input
 * @param {readline.Interface} rl - Readline interface
 * @param {string} question - Question to ask
 * @returns {Promise<string>}
 */
function prompt(rl, question) {
  return new Promise((resolve) => {
    rl.question(question, (answer) => {
      resolve(answer.trim());
    });
  });
}

/**
 * Clear terminal screen
 */
function clear_screen() {
  process.stdout.write('\x1B[2J\x1B[0f');
}

/**
 * Print a header
 * @param {string} title - Header title
 */
function print_header(title) {
  console.log('\n' + '='.repeat(60));
  console.log(`  ${title}`);
  console.log('='.repeat(60) + '\n');
}

/**
 * Print a subheader
 * @param {string} title - Subheader title
 */
function print_subheader(title) {
  console.log('\n' + '-'.repeat(40));
  console.log(`  ${title}`);
  console.log('-'.repeat(40) + '\n');
}

/**
 * Print success message
 * @param {string} message - Message to print
 */
function print_success(message) {
  console.log(`\x1b[32m✓ ${message}\x1b[0m`);
}

/**
 * Print error message
 * @param {string} message - Message to print
 */
function print_error(message) {
  console.log(`\x1b[31m✗ ${message}\x1b[0m`);
}

/**
 * Print warning message
 * @param {string} message - Message to print
 */
function print_warning(message) {
  console.log(`\x1b[33m⚠ ${message}\x1b[0m`);
}

/**
 * Print info message
 * @param {string} message - Message to print
 */
function print_info(message) {
  console.log(`\x1b[36mℹ ${message}\x1b[0m`);
}

/**
 * Execute a shell command and return output
 * @param {string} command - Command to execute
 * @returns {string|null} Output or null on error
 */
function exec_command(command) {
  try {
    return execSync(command, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
  } catch {
    return null;
  }
}

/**
 * Execute a shell command with live output
 * @param {string} command - Command to execute
 * @param {string[]} args - Arguments
 * @returns {Promise<{code: number, output: string}>}
 */
function exec_command_live(command, args) {
  return new Promise((resolve) => {
    let output = '';
    const proc = spawn(command, args, { stdio: ['inherit', 'pipe', 'pipe'] });
    
    proc.stdout.on('data', (data) => {
      const text = data.toString();
      output += text;
      process.stdout.write(text);
    });
    
    proc.stderr.on('data', (data) => {
      const text = data.toString();
      output += text;
      process.stderr.write(text);
    });
    
    proc.on('close', (code) => {
      resolve({ code: code || 0, output });
    });
  });
}

/**
 * Get validator for an environment variable from ENV_CONFIG
 * @param {string} name - Variable name
 * @returns {Function|null} Validator function or null
 */
function get_validator(name) {
  const config = ENV_CONFIG.find(c => c.name === name);
  return config?.validate || null;
}

/**
 * Validate a value using ENV_CONFIG
 * @param {string} name - Variable name
 * @param {string} value - Value to validate
 * @returns {string|null} Error message or null if valid
 */
function validate_value(name, value) {
  const validator = get_validator(name);
  if (validator) {
    return validator(value);
  }
  return null;
}

/**
 * Check if a variable is required in ENV_CONFIG
 * @param {string} name - Variable name
 * @returns {boolean}
 */
function is_required(name) {
  const config = ENV_CONFIG.find(c => c.name === name);
  return config?.required !== false;
}

// ============================================================================
// DATABASE FUNCTIONS
// ============================================================================

/**
 * Check if local database exists
 * @returns {boolean}
 */
function local_db_exists() {
  return existsSync(LOCAL_DB_PATH);
}

/**
 * Get site info from local database
 * @returns {{name: string, description: string, legal_name: string}|null}
 */
function get_local_site_info() {
  if (!local_db_exists()) {
    return null;
  }
  
  try {
    const db = new Database(LOCAL_DB_PATH, { readonly: true });
    const result = db.prepare("SELECT props FROM settings WHERE setting_id = 'site'").get();
    db.close();
    
    if (!result || !result.props) {
      return null;
    }
    
    const site_settings = JSON.parse(result.props);
    return {
      name: site_settings.name || 'Unknown',
      description: site_settings.description || '',
      legal_name: site_settings.legal_name || '',
    };
  } catch (error) {
    print_error(`Failed to read local database: ${error.message}`);
    return null;
  }
}

/**
 * Get local database file size
 * @returns {string} Human-readable file size
 */
function get_local_db_size() {
  try {
    const stats = statSync(LOCAL_DB_PATH);
    const bytes = stats.size;
    
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
    return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
  } catch {
    return 'unknown size';
  }
}

/**
 * Upload local database to production
 * @param {readline.Interface} rl - Readline interface
 * @param {string} app_name - Fly app name
 * @param {boolean} dry_run - If true, only show commands
 * @returns {Promise<boolean>}
 */
async function upload_database(rl, app_name, dry_run) {
  print_header('Upload Local Database to Production');
  
  // Check if local DB exists
  if (!local_db_exists()) {
    print_warning(`Local database not found at ${LOCAL_DB_PATH}`);
    print_info('Skipping database upload. The app will start with an empty database.');
    return true;
  }
  
  // Get site info for confirmation
  const site_info = get_local_site_info();
  
  if (!site_info) {
    print_warning('Could not read site settings from local database.');
    const proceed = await prompt_yes_no(rl, 'Upload database anyway?', false);
    if (!proceed) {
      print_info('Skipping database upload.');
      return true;
    }
  } else {
    console.log('\nLocal database contains:');
    console.log(`  Site Name:    ${site_info.name}`);
    if (site_info.description) {
      console.log(`  Description:  ${site_info.description}`);
    }
    if (site_info.legal_name && site_info.legal_name !== site_info.name) {
      console.log(`  Legal Name:   ${site_info.legal_name}`);
    }
    
    // Get file size
    try {
      const stats = statSync(LOCAL_DB_PATH);
      const size_mb = (stats.size / (1024 * 1024)).toFixed(2);
      console.log(`  File Size:    ${size_mb} MB`);
    } catch {
      // Ignore size errors
    }
    
    console.log('');
    print_warning('This will replace any existing database on the production server!');
    
    const confirm = await prompt_yes_no(
      rl, 
      `Upload database for "${site_info.name}" to ${app_name}?`, 
      false
    );
    
    if (!confirm) {
      print_info('Skipping database upload. The app will use its existing database.');
      return true;
    }
  }
  
  if (dry_run) {
    console.log('\nDry run - would execute:');
    console.log(`  1. Pause the app: fly ssh console -a ${app_name} -C "pkill -STOP -f node"`);
    console.log(`  2. Remove old DB: fly ssh console -a ${app_name} -C "rm -rf /data/db.*"`);
    console.log(`  3. Upload new DB: fly sftp shell -a ${app_name} (put ${LOCAL_DB_PATH} /data/db.sqlite3)`);
    console.log(`  4. Restart app:   fly apps restart ${app_name}`);
    return true;
  }
  
  print_subheader('Step 1: Pause Application');
  console.log('Pausing Node.js process to prevent database writes...\n');
  
  let result = await exec_command_live('fly', [
    'ssh', 'console', '-a', app_name, '-C', 'pkill -STOP -f node || true'
  ]);
  
  if (result.code !== 0 && !result.output.includes('No such process')) {
    print_warning('Could not pause app (may not be running yet). Continuing...');
  } else {
    print_success('Application paused');
  }
  
  print_subheader('Step 2: Remove Existing Database');
  console.log('Removing existing database files...\n');
  
  result = await exec_command_live('fly', [
    'ssh', 'console', '-a', app_name, '-C', 'rm -rf /data/db.* || true'
  ]);
  
  print_success('Existing database removed');
  
  print_subheader('Step 3: Upload Local Database');
  console.log(`Uploading ${LOCAL_DB_PATH} to production...\n`);
  
  // Use sftp to upload - this is tricky with spawn, so we use execSync
  try {
    execSync(
      `echo "cd /data\nput ${LOCAL_DB_PATH} db.sqlite3" | fly sftp shell -a ${app_name}`,
      { stdio: 'inherit' }
    );
    print_success('Database uploaded successfully');
  } catch (error) {
    print_error('Failed to upload database');
    console.log('\nYou can try manually:');
    console.log(`  fly sftp shell -a ${app_name}`);
    console.log('  cd /data');
    console.log(`  put ${LOCAL_DB_PATH} db.sqlite3`);
    
    // Try to restart anyway
    print_subheader('Step 4: Restart Application');
    await exec_command_live('fly', ['apps', 'restart', app_name]);
    return false;
  }
  
  print_subheader('Step 4: Restart Application');
  console.log('Restarting the application...\n');
  
  result = await exec_command_live('fly', ['apps', 'restart', app_name]);
  
  if (result.code !== 0) {
    print_error('Failed to restart app');
    console.log(`\nTry manually: fly apps restart ${app_name}`);
    return false;
  }
  
  print_success('Application restarted with uploaded database');
  return true;
}

// ============================================================================
// PREREQUISITE CHECKS
// ============================================================================

/**
 * Check if fly CLI is installed
 * @returns {boolean}
 */
function check_fly_installed() {
  const result = exec_command('which fly');
  return result !== null;
}

/**
 * Check if user is authenticated with fly
 * @returns {boolean}
 */
function check_fly_auth() {
  const result = exec_command('fly auth whoami');
  return result !== null;
}

/**
 * Run all prerequisite checks
 * @returns {boolean} True if all checks pass
 */
function run_prerequisites() {
  print_header('Prerequisite Checks');
  
  // Check fly CLI
  if (!check_fly_installed()) {
    print_error('Fly CLI is not installed');
    console.log('\n  Install it with: curl -L https://fly.io/install.sh | sh');
    console.log('  Or visit: https://fly.io/docs/hands-on/install-flyctl/\n');
    return false;
  }
  print_success('Fly CLI is installed');
  
  // Check authentication
  if (!check_fly_auth()) {
    print_error('Not authenticated with Fly.io');
    console.log('\n  Run: fly auth login\n');
    return false;
  }
  const user = exec_command('fly auth whoami');
  print_success(`Authenticated as: ${user}`);
  
  return true;
}

// ============================================================================
// PROMPT HELPERS
// ============================================================================

/**
 * Prompt with default value
 * @param {readline.Interface} rl - Readline interface
 * @param {string} label - Field label
 * @param {string} default_value - Default value
 * @param {string} [validator_name] - Name for ENV_CONFIG validation
 * @returns {Promise<string>}
 */
async function prompt_with_default(rl, label, default_value, validator_name = null) {
  while (true) {
    const display_default = default_value ? ` [${default_value}]` : '';
    const answer = await prompt(rl, `${label}${display_default}: `);
    const value = answer || default_value;
    
    if (!value) {
      print_error('This field is required');
      continue;
    }
    
    if (validator_name) {
      const error = validate_value(validator_name, value);
      if (error) {
        print_error(error);
        continue;
      }
    }
    
    return value;
  }
}

/**
 * Prompt for selection from a list
 * @param {readline.Interface} rl - Readline interface
 * @param {string} label - Field label
 * @param {Array<{code: string, name: string}>} options - Options to choose from
 * @param {string} default_code - Default option code
 * @returns {Promise<string>} Selected code
 */
async function prompt_selection(rl, label, options, default_code) {
  console.log(`\n${label}:`);
  
  options.forEach((opt, i) => {
    const marker = opt.code === default_code ? ' (default)' : '';
    console.log(`  ${i + 1}. ${opt.name} [${opt.code}]${marker}`);
  });
  
  while (true) {
    const answer = await prompt(rl, `\nEnter number or code [${default_code}]: `);
    
    if (!answer) {
      return default_code;
    }
    
    // Check if it's a number
    const num = parseInt(answer, 10);
    if (!isNaN(num) && num >= 1 && num <= options.length) {
      return options[num - 1].code;
    }
    
    // Check if it's a code
    const found = options.find(o => o.code.toLowerCase() === answer.toLowerCase());
    if (found) {
      return found.code;
    }
    
    print_error(`Invalid selection. Enter 1-${options.length} or a valid code.`);
  }
}

/**
 * Prompt for yes/no
 * @param {readline.Interface} rl - Readline interface
 * @param {string} question - Question to ask
 * @param {boolean} default_value - Default value
 * @returns {Promise<boolean>}
 */
async function prompt_yes_no(rl, question, default_value = true) {
  const hint = default_value ? '[Y/n]' : '[y/N]';
  const answer = await prompt(rl, `${question} ${hint}: `);
  
  if (!answer) {
    return default_value;
  }
  
  return answer.toLowerCase().startsWith('y');
}

/**
 * Prompt for optional value (can skip with empty)
 * @param {readline.Interface} rl - Readline interface
 * @param {string} label - Field label
 * @param {string} default_value - Default value
 * @param {string} [hint] - Additional hint
 * @returns {Promise<string>}
 */
async function prompt_optional(rl, label, default_value = '', hint = '') {
  const display_default = default_value ? ` [${default_value}]` : ' [skip with Enter]';
  const display_hint = hint ? ` (${hint})` : '';
  const answer = await prompt(rl, `${label}${display_default}${display_hint}: `);
  return answer || default_value;
}

// ============================================================================
// CONFIGURATION COLLECTION
// ============================================================================

/**
 * Collect app setup configuration
 * @param {readline.Interface} rl - Readline interface
 * @returns {Promise<Object>}
 */
async function collect_app_setup(rl) {
  print_header('App Setup');
  
  // App name
  console.log('App name will be used in the URL: https://<app-name>.fly.dev\n');
  const app_name = await prompt_with_default(rl, 'App name', '');
  
  // Validate app name format
  if (!/^[a-z0-9-]+$/.test(app_name)) {
    print_warning('App name should only contain lowercase letters, numbers, and hyphens');
  }
  
  // Region
  const region = await prompt_selection(rl, 'Select region (closest to your users)', FLY_REGIONS, 'fra');
  
  // VM Size
  const vm_size = await prompt_selection(rl, 'Select VM size', VM_SIZES, 'shared-cpu-1x');
  
  // VM Memory
  const vm_memory = await prompt_selection(rl, 'Select VM memory', VM_MEMORY, '512');
  
  // Volume size
  console.log('\nInitial volume size for database and files storage.');
  const volume_size = await prompt_with_default(rl, 'Volume size in GB', '2');
  
  return {
    app_name,
    region,
    vm_size,
    vm_memory,
    volume_size,
  };
}

/**
 * Collect core settings
 * @param {readline.Interface} rl - Readline interface
 * @param {string} app_name - App name for deriving defaults
 * @returns {Promise<Object>}
 */
async function collect_core_settings(rl, app_name) {
  print_header('Core Settings');
  
  // Origin (derived from app name)
  const default_origin = `https://${app_name}.fly.dev`;
  console.log('Origin URL (the full URL where your app will be accessible)\n');
  const origin = await prompt_with_default(rl, 'Origin', default_origin, 'ORIGIN');
  
  // Language
  const lang = await prompt_selection(rl, 'Interface language', LANGUAGES, 'en');
  
  // Timezone
  const timezone = await prompt_selection(rl, 'Business timezone', TIME_ZONES, 'Europe/Berlin');
  
  // Locale
  const locale = await prompt_selection(rl, 'Date/time locale', LOCALES, 'en-GB');
  
  // Hour format
  const hour_format = await prompt_selection(rl, 'Hour format', HOUR_FORMATS, '24');
  
  // Week start
  const week_start = await prompt_selection(rl, 'First day of week', WEEK_STARTS, '1');
  
  // Available modules
  console.log('\nAvailable modules (comma-separated, e.g., "all,calendar")');
  const modules = await prompt_with_default(rl, 'Available modules', 'all,calendar');
  
  // Body size limit
  console.log('\nMaximum upload size for files (videos, images)');
  const body_size_mb = await prompt_with_default(rl, 'Max upload size in MB', '30');
  const body_size_limit = String(parseInt(body_size_mb, 10) * 1000000);
  
  // Technical support email
  console.log('\nEmail shown to users for technical issues');
  const support_email = await prompt_with_default(rl, 'Technical support email', '', 'PUBLIC_TECHNICAL_SUPPORT_EMAIL');
  
  return {
    origin,
    lang,
    timezone,
    locale,
    hour_format,
    week_start,
    modules,
    body_size_limit,
    support_email,
  };
}

/**
 * Collect email settings
 * @param {readline.Interface} rl - Readline interface
 * @param {string} support_email - Support email as default
 * @returns {Promise<Object>}
 */
async function collect_email_settings(rl, support_email) {
  print_header('Email Settings (SMTP)');
  
  console.log('Configure SMTP for sending emails (login links, notifications, etc.)\n');
  
  const smtp_server = await prompt_with_default(rl, 'SMTP server', 'smtp.gmail.com');
  
  console.log('\nCommon ports: 587 (TLS/STARTTLS), 465 (SSL), 25 (unencrypted)');
  const smtp_port = await prompt_with_default(rl, 'SMTP port', '587', 'SMTP_PORT');
  
  const smtp_username = await prompt_with_default(rl, 'SMTP username (usually email)', '');
  
  console.log('\nFor Gmail, use an App Password: https://myaccount.google.com/apppasswords');
  const smtp_password = await prompt_with_default(rl, 'SMTP password', '');
  
  console.log('\nSender information for outgoing emails');
  const sender_name = await prompt_with_default(rl, 'Sender name', '');
  const sender_email = await prompt_with_default(rl, 'Sender email', smtp_username, 'SENDER_EMAIL');
  
  console.log('\nReply-to information (can be same as sender)');
  const reply_to_name = await prompt_with_default(rl, 'Reply-to name', sender_name);
  const reply_to_email = await prompt_with_default(rl, 'Reply-to email', sender_email, 'REPLY_TO_EMAIL');
  
  return {
    smtp_server,
    smtp_port,
    smtp_username,
    smtp_password,
    sender_name,
    sender_email,
    reply_to_name,
    reply_to_email,
  };
}

/**
 * Collect optional backup settings
 * @param {readline.Interface} rl - Readline interface
 * @param {string} app_name - App name for default bucket
 * @returns {Promise<Object|null>}
 */
async function collect_backup_settings(rl, app_name) {
  print_header('Backup Settings (Optional)');
  
  console.log('Configure S3-compatible storage for automatic database backups.');
  console.log('Supports: AWS S3, DigitalOcean Spaces, Backblaze B2, MinIO, etc.\n');
  
  const configure_backup = await prompt_yes_no(rl, 'Configure backup storage now?', false);
  
  if (!configure_backup) {
    print_info('Skipping backup configuration. You can add this later via fly secrets set.');
    return null;
  }
  
  console.log('\nHow to get S3 credentials:');
  console.log('  - DigitalOcean Spaces: https://cloud.digitalocean.com/account/api/spaces');
  console.log('  - AWS S3: https://console.aws.amazon.com/iam/');
  console.log('  - Backblaze B2: https://secure.backblaze.com/app_keys.htm\n');
  
  const s3_access_key = await prompt_optional(rl, 'S3 Access Key');
  
  if (!s3_access_key) {
    print_info('Skipping backup configuration.');
    return null;
  }
  
  const s3_secret_key = await prompt_optional(rl, 'S3 Secret Access Key');
  const s3_endpoint = await prompt_with_default(rl, 'S3 Endpoint', 'https://fra1.digitaloceanspaces.com');
  const s3_bucket = await prompt_with_default(rl, 'S3 Bucket name', app_name);
  const retention_days = await prompt_with_default(rl, 'Backup retention (days)', '30');
  
  return {
    s3_access_key,
    s3_secret_key,
    s3_endpoint,
    s3_bucket,
    retention_days,
  };
}

/**
 * Generate all auto-generated secrets
 * @returns {Object}
 */
function generate_secrets() {
  return {
    heartbeat_secret: generate_secret(32),
    webhook_secret: generate_secret(32),
    analytics_salt: generate_secret(32),
  };
}

// ============================================================================
// BUILD SECRETS MAP
// ============================================================================

/**
 * Build the complete secrets map from collected config
 * @param {Object} config - All collected configuration
 * @returns {Map<string, string>}
 */
function build_secrets_map(config) {
  const secrets = new Map();
  
  // Fixed values for production
  secrets.set('DB_PATH', '/data/db.sqlite3');
  
  // Core settings
  secrets.set('ORIGIN', config.core.origin);
  secrets.set('PUBLIC_LANG', config.core.lang);
  secrets.set('PUBLIC_TIME_ZONE', config.core.timezone);
  secrets.set('PUBLIC_TIME_LOCALE', config.core.locale);
  secrets.set('PUBLIC_TIME_HOUR_FORMAT', config.core.hour_format);
  secrets.set('PUBLIC_TIME_START_OF_WEEK', config.core.week_start);
  secrets.set('AVAILABLE_MODULES', config.core.modules);
  secrets.set('BODY_SIZE_LIMIT', config.core.body_size_limit);
  secrets.set('PUBLIC_TECHNICAL_SUPPORT_EMAIL', config.core.support_email);
  
  // Email settings
  secrets.set('SMTP_SERVER', config.email.smtp_server);
  secrets.set('SMTP_PORT', config.email.smtp_port);
  secrets.set('SMTP_USERNAME', config.email.smtp_username);
  secrets.set('SMTP_PASSWORD', config.email.smtp_password);
  secrets.set('SENDER_NAME', config.email.sender_name);
  secrets.set('SENDER_EMAIL', config.email.sender_email);
  secrets.set('REPLY_TO_NAME', config.email.reply_to_name);
  secrets.set('REPLY_TO_EMAIL', config.email.reply_to_email);
  
  // Backup settings (optional)
  if (config.backup) {
    secrets.set('BACKUP_S3_ACCESS_KEY', config.backup.s3_access_key);
    secrets.set('BACKUP_S3_SECRET_ACCESS_KEY', config.backup.s3_secret_key);
    secrets.set('BACKUP_S3_ENDPOINT', config.backup.s3_endpoint);
    secrets.set('BACKUP_S3_BUCKET', config.backup.s3_bucket);
    secrets.set('BACKUP_RETENTION_DAYS', config.backup.retention_days);
  }
  
  // Auto-generated secrets
  secrets.set('HEARTBEAT_SECRET', config.secrets.heartbeat_secret);
  secrets.set('WEBHOOK_SECRET', config.secrets.webhook_secret);
  secrets.set('ANALYTICS_SALT', config.secrets.analytics_salt);
  
  return secrets;
}

// ============================================================================
// REVIEW AND EDIT
// ============================================================================

/**
 * Display all configuration for review
 * @param {Object} config - All collected configuration
 * @param {Map<string, string>} secrets - Secrets map
 */
function display_review(config, secrets) {
  print_header('Configuration Review');
  
  console.log('App Setup:');
  console.log(`  1. App Name:       ${config.app.app_name}`);
  console.log(`  2. Region:         ${config.app.region}`);
  console.log(`  3. VM Size:        ${config.app.vm_size}`);
  console.log(`  4. VM Memory:      ${config.app.vm_memory} MB`);
  console.log(`  5. Volume Size:    ${config.app.volume_size} GB`);
  
  console.log('\nCore Settings:');
  console.log(`  6. Origin:         ${config.core.origin}`);
  console.log(`  7. Language:       ${config.core.lang}`);
  console.log(`  8. Timezone:       ${config.core.timezone}`);
  console.log(`  9. Locale:         ${config.core.locale}`);
  console.log(`  10. Hour Format:   ${config.core.hour_format}`);
  console.log(`  11. Week Start:    ${config.core.week_start} (${WEEK_STARTS.find(w => w.code === config.core.week_start)?.name})`);
  console.log(`  12. Modules:       ${config.core.modules}`);
  console.log(`  13. Max Upload:    ${parseInt(config.core.body_size_limit, 10) / 1000000} MB`);
  console.log(`  14. Support Email: ${config.core.support_email}`);
  
  console.log('\nEmail Settings:');
  console.log(`  15. SMTP Server:   ${config.email.smtp_server}`);
  console.log(`  16. SMTP Port:     ${config.email.smtp_port}`);
  console.log(`  17. SMTP Username: ${config.email.smtp_username}`);
  console.log(`  18. SMTP Password: ${'*'.repeat(Math.min(config.email.smtp_password.length, 16))}`);
  console.log(`  19. Sender Name:   ${config.email.sender_name}`);
  console.log(`  20. Sender Email:  ${config.email.sender_email}`);
  console.log(`  21. Reply-To Name: ${config.email.reply_to_name}`);
  console.log(`  22. Reply-To Email: ${config.email.reply_to_email}`);
  
  if (config.backup) {
    console.log('\nBackup Settings:');
    console.log(`  23. S3 Access Key: ${config.backup.s3_access_key.substring(0, 8)}...`);
    console.log(`  24. S3 Secret Key: ${'*'.repeat(16)}`);
    console.log(`  25. S3 Endpoint:   ${config.backup.s3_endpoint}`);
    console.log(`  26. S3 Bucket:     ${config.backup.s3_bucket}`);
    console.log(`  27. Retention:     ${config.backup.retention_days} days`);
  } else {
    console.log('\nBackup Settings: Not configured');
  }
  
  console.log('\nAuto-Generated Secrets:');
  console.log(`  HEARTBEAT_SECRET:  ${config.secrets.heartbeat_secret.substring(0, 16)}...`);
  console.log(`  WEBHOOK_SECRET:    ${config.secrets.webhook_secret.substring(0, 16)}...`);
  console.log(`  ANALYTICS_SALT:    ${config.secrets.analytics_salt.substring(0, 16)}...`);
}

/**
 * Edit a specific field
 * @param {readline.Interface} rl - Readline interface
 * @param {Object} config - Configuration object
 * @param {number} field_num - Field number to edit
 * @returns {Promise<boolean>} True if edited successfully
 */
async function edit_field(rl, config, field_num) {
  const edit_map = {
    1: { obj: config.app, key: 'app_name', label: 'App name' },
    2: { obj: config.app, key: 'region', label: 'Region', options: FLY_REGIONS },
    3: { obj: config.app, key: 'vm_size', label: 'VM Size', options: VM_SIZES },
    4: { obj: config.app, key: 'vm_memory', label: 'VM Memory', options: VM_MEMORY },
    5: { obj: config.app, key: 'volume_size', label: 'Volume size (GB)' },
    6: { obj: config.core, key: 'origin', label: 'Origin', validator: 'ORIGIN' },
    7: { obj: config.core, key: 'lang', label: 'Language', options: LANGUAGES },
    8: { obj: config.core, key: 'timezone', label: 'Timezone', options: TIME_ZONES },
    9: { obj: config.core, key: 'locale', label: 'Locale', options: LOCALES },
    10: { obj: config.core, key: 'hour_format', label: 'Hour format', options: HOUR_FORMATS },
    11: { obj: config.core, key: 'week_start', label: 'Week start', options: WEEK_STARTS },
    12: { obj: config.core, key: 'modules', label: 'Available modules' },
    13: { obj: config.core, key: 'body_size_limit', label: 'Max upload (bytes)' },
    14: { obj: config.core, key: 'support_email', label: 'Support email', validator: 'PUBLIC_TECHNICAL_SUPPORT_EMAIL' },
    15: { obj: config.email, key: 'smtp_server', label: 'SMTP server' },
    16: { obj: config.email, key: 'smtp_port', label: 'SMTP port', validator: 'SMTP_PORT' },
    17: { obj: config.email, key: 'smtp_username', label: 'SMTP username' },
    18: { obj: config.email, key: 'smtp_password', label: 'SMTP password' },
    19: { obj: config.email, key: 'sender_name', label: 'Sender name' },
    20: { obj: config.email, key: 'sender_email', label: 'Sender email', validator: 'SENDER_EMAIL' },
    21: { obj: config.email, key: 'reply_to_name', label: 'Reply-to name' },
    22: { obj: config.email, key: 'reply_to_email', label: 'Reply-to email', validator: 'REPLY_TO_EMAIL' },
  };
  
  // Add backup fields if configured
  if (config.backup) {
    edit_map[23] = { obj: config.backup, key: 's3_access_key', label: 'S3 Access Key' };
    edit_map[24] = { obj: config.backup, key: 's3_secret_key', label: 'S3 Secret Key' };
    edit_map[25] = { obj: config.backup, key: 's3_endpoint', label: 'S3 Endpoint' };
    edit_map[26] = { obj: config.backup, key: 's3_bucket', label: 'S3 Bucket' };
    edit_map[27] = { obj: config.backup, key: 'retention_days', label: 'Retention days' };
  }
  
  const field = edit_map[field_num];
  if (!field) {
    print_error(`Invalid field number: ${field_num}`);
    return false;
  }
  
  console.log(`\nCurrent value: ${field.obj[field.key]}`);
  
  let new_value;
  if (field.options) {
    new_value = await prompt_selection(rl, `Select new ${field.label}`, field.options, field.obj[field.key]);
  } else {
    new_value = await prompt_with_default(rl, `New ${field.label}`, field.obj[field.key], field.validator);
  }
  
  field.obj[field.key] = new_value;
  
  // Update derived values if app_name changed
  if (field_num === 1) {
    const new_origin = `https://${new_value}.fly.dev`;
    const update_origin = await prompt_yes_no(rl, `Update ORIGIN to ${new_origin}?`, true);
    if (update_origin) {
      config.core.origin = new_origin;
    }
  }
  
  return true;
}

/**
 * Review and edit loop
 * @param {readline.Interface} rl - Readline interface
 * @param {Object} config - Configuration object
 * @param {Map<string, string>} secrets - Secrets map
 * @returns {Promise<boolean>} True if user confirms, false if cancelled
 */
async function review_and_edit(rl, config, secrets) {
  while (true) {
    clear_screen();
    display_review(config, secrets);
    
    console.log('\nOptions:');
    console.log('  - Enter a number (1-27) to edit that field');
    console.log('  - Enter "c" or "confirm" to proceed with deployment');
    console.log('  - Enter "q" or "quit" to cancel');
    console.log('  - Enter "s" or "save" to save secrets command to file');
    console.log('  - Enter "r" or "regenerate" to regenerate auto-generated secrets');
    
    const action = await prompt(rl, '\nAction: ');
    
    if (!action || action.toLowerCase() === 'c' || action.toLowerCase() === 'confirm') {
      return true;
    }
    
    if (action.toLowerCase() === 'q' || action.toLowerCase() === 'quit') {
      return false;
    }
    
    if (action.toLowerCase() === 's' || action.toLowerCase() === 'save') {
      await save_secrets_command(rl, config, secrets);
      continue;
    }
    
    if (action.toLowerCase() === 'r' || action.toLowerCase() === 'regenerate') {
      config.secrets = generate_secrets();
      print_success('Auto-generated secrets have been regenerated');
      await prompt(rl, 'Press Enter to continue...');
      continue;
    }
    
    const field_num = parseInt(action, 10);
    if (!isNaN(field_num)) {
      await edit_field(rl, config, field_num);
    } else {
      print_error('Invalid action');
      await prompt(rl, 'Press Enter to continue...');
    }
  }
}

// ============================================================================
// COMMAND GENERATION
// ============================================================================

/**
 * Generate the fly secrets set command
 * @param {string} app_name - App name
 * @param {Map<string, string>} secrets - Secrets map
 * @returns {string}
 */
function generate_secrets_command(app_name, secrets) {
  let command = `fly secrets set -a ${app_name}`;
  
  for (const [key, value] of secrets) {
    const escaped_value = escape_for_shell(value);
    command += ` \\\n  ${key}='${escaped_value}'`;
  }
  
  return command;
}

/**
 * Generate the fly deploy command
 * @param {Object} app_config - App configuration
 * @returns {string}
 */
function generate_deploy_command(app_config) {
  return `fly deploy -a ${app_config.app_name} --primary-region ${app_config.region} --vm-size ${app_config.vm_size} --vm-memory ${app_config.vm_memory} --volume-initial-size ${app_config.volume_size}`;
}

/**
 * Save secrets command to file
 * @param {readline.Interface} rl - Readline interface
 * @param {Object} config - Configuration object
 * @param {Map<string, string>} secrets - Secrets map
 */
async function save_secrets_command(rl, config, secrets) {
  const secrets_map = build_secrets_map(config);
  const command = generate_secrets_command(config.app.app_name, secrets_map);
  
  const filename = `fly-secrets-${config.app.app_name}-${Date.now()}.sh`;
  
  const { writeFileSync } = await import('fs');
  
  const file_content = `#!/bin/bash
# Fly.io secrets for: ${config.app.app_name}
# Generated: ${new Date().toISOString()}
#
# IMPORTANT: Keep this file secure and do not commit to version control!
# You can run this script or copy the command below to set secrets.

${command}
`;

  writeFileSync(filename, file_content, { mode: 0o600 });
  print_success(`Secrets command saved to: ${filename}`);
  print_warning('Keep this file secure! It contains sensitive credentials.');
  await prompt(rl, 'Press Enter to continue...');
}

// ============================================================================
// DEPLOYMENT EXECUTION
// ============================================================================

/**
 * Execute deployment commands
 * @param {Object} config - Configuration object
 * @param {Map<string, string>} secrets - Secrets map
 * @param {boolean} dry_run - If true, only show commands without executing
 * @returns {Promise<boolean>}
 */
async function execute_deployment(config, secrets, dry_run) {
  const secrets_map = build_secrets_map(config);
  const app_name = config.app.app_name;
  
  print_header(dry_run ? 'Dry Run - Commands to Execute' : 'Executing Deployment');
  
  // Step 1: Create app
  print_subheader('Step 1: Create Fly App');
  const create_command = `fly apps create ${app_name}`;
  console.log(`$ ${create_command}\n`);
  
  if (!dry_run) {
    const result = await exec_command_live('fly', ['apps', 'create', app_name]);
    if (result.code !== 0) {
      // Check if app already exists
      if (result.output.includes('already exists')) {
        print_warning(`App "${app_name}" already exists. Continuing...`);
      } else {
        print_error('Failed to create app');
        console.log('\nYou can try manually: ' + create_command);
        return false;
      }
    } else {
      print_success('App created successfully');
    }
  }
  
  // Step 2: Set secrets
  print_subheader('Step 2: Set Secrets');
  const secrets_command = generate_secrets_command(app_name, secrets_map);
  console.log(secrets_command + '\n');
  
  if (!dry_run) {
    // Build secrets as key=value pairs for the command
    const secrets_args = ['secrets', 'set', '-a', app_name];
    for (const [key, value] of secrets_map) {
      secrets_args.push(`${key}=${value}`);
    }
    
    const result = await exec_command_live('fly', secrets_args);
    if (result.code !== 0) {
      print_error('Failed to set secrets');
      console.log('\nYou can try manually with the command above.');
      return false;
    }
    print_success('Secrets set successfully');
  }
  
  // Step 3: Deploy
  print_subheader('Step 3: Deploy Application');
  const deploy_command = generate_deploy_command(config.app);
  console.log(`$ ${deploy_command}\n`);
  
  if (!dry_run) {
    const deploy_args = [
      'deploy',
      '-a', app_name,
      '--primary-region', config.app.region,
      '--vm-size', config.app.vm_size,
      '--vm-memory', config.app.vm_memory,
      '--volume-initial-size', config.app.volume_size,
    ];
    
    const result = await exec_command_live('fly', deploy_args);
    if (result.code !== 0) {
      print_error('Deployment failed');
      console.log('\nCheck the logs with: fly logs -a ' + app_name);
      console.log('You can retry deployment with: ' + deploy_command);
      return false;
    }
    print_success('Deployment completed successfully');
  }
  
  return true;
}

// ============================================================================
// MAIN
// ============================================================================

async function main() {
  const dry_run = process.argv.includes('--dry-run');
  
  clear_screen();
  console.log('\n');
  console.log('  ╔═══════════════════════════════════════════════════════╗');
  console.log('  ║                                                       ║');
  console.log('  ║          Fly.io Interactive Deploy Tool               ║');
  console.log('  ║                                                       ║');
  console.log('  ╚═══════════════════════════════════════════════════════╝');
  console.log('\n');
  
  if (dry_run) {
    print_warning('DRY RUN MODE - No changes will be made');
  }
  
  console.log('This tool will guide you through deploying your app to Fly.io.');
  console.log('Press Enter to accept default values shown in [brackets].\n');
  
  // Run prerequisites
  if (!run_prerequisites()) {
    process.exit(1);
  }
  
  const rl = create_rl();
  
  try {
    // Collect configuration
    const app_config = await collect_app_setup(rl);
    const core_config = await collect_core_settings(rl, app_config.app_name);
    const email_config = await collect_email_settings(rl, core_config.support_email);
    const backup_config = await collect_backup_settings(rl, app_config.app_name);
    const auto_secrets = generate_secrets();
    
    const config = {
      app: app_config,
      core: core_config,
      email: email_config,
      backup: backup_config,
      secrets: auto_secrets,
    };
    
    const secrets_map = build_secrets_map(config);
    
    // Review and edit loop
    const confirmed = await review_and_edit(rl, config, secrets_map);
    
    if (!confirmed) {
      print_warning('Deployment cancelled');
      rl.close();
      process.exit(0);
    }
    
    // Rebuild secrets map in case of edits
    const final_secrets = build_secrets_map(config);
    
    // Execute deployment
    const success = await execute_deployment(config, final_secrets, dry_run);
    
    if (success) {
      // Offer to upload local database
      if (local_db_exists()) {
        const site_info = get_local_site_info();
        if (site_info) {
          console.log('\n');
          print_info(`Local database found: "${site_info.name}"`);
        } else {
          console.log('\n');
          print_info('Local database found at ' + LOCAL_DB_PATH);
        }
        
        const upload_db = await prompt_yes_no(rl, 'Upload local database to production?', false);
        if (upload_db) {
          await upload_database(rl, config.app.app_name, dry_run);
        } else {
          print_info('The app will start with an empty database. You can upload later using tools/restore_production_db.js');
        }
      }
      
      print_header('Deployment Complete');
      console.log(`Your app is now available at: ${config.core.origin}\n`);
      console.log('Useful commands:');
      console.log(`  fly logs -a ${config.app.app_name}        - View logs`);
      console.log(`  fly status -a ${config.app.app_name}      - Check status`);
      console.log(`  fly ssh console -a ${config.app.app_name} - SSH into machine`);
      console.log(`  fly secrets list -a ${config.app.app_name} - List secrets`);
      console.log(`  fly deploy -a ${config.app.app_name}      - Redeploy\n`);
      
      const save_secrets = await prompt_yes_no(rl, 'Save secrets command to file for future reference?', true);
      if (save_secrets) {
        await save_secrets_command(rl, config, final_secrets);
      }
    }
    
    rl.close();
    process.exit(success ? 0 : 1);
    
  } catch (error) {
    print_error(`Unexpected error: ${error.message}`);
    console.error(error.stack);
    rl.close();
    process.exit(1);
  }
}

main();
#!/usr/bin/env node

/**
 * Environment variable validation configuration
 * Each entry can be:
 * - A string: required variable name
 * - An object with properties:
 *   - name: variable name
 *   - required: boolean (default true)
 *   - validate: function to validate the value
 *   - description: description of the variable
 */
const ENV_CONFIG = [
  // Core application variables
  {
    name: 'ORIGIN',
    required: true,
    validate: (value) => {
      if (!value) return 'ORIGIN is required';
      if (value.endsWith('/')) return 'ORIGIN must not end with a trailing slash';
      try {
        new URL(value);
        return null; // valid
      } catch {
        return 'ORIGIN must be a valid URL';
      }
    },
    description: 'Base URL of the application (e.g., https://example.com, http://localhost:5173)'
  },
  
  // Database
  {
    name: 'DB_PATH',
    required: true,
    validate: (value) => {
      if (!value) return 'DB_PATH is required';
      if (!value.endsWith('.sqlite3') && !value.endsWith('.db')) {
        return 'DB_PATH should point to a SQLite database file (.sqlite3 or .db)';
      }
      return null;
    },
    description: 'Path to the SQLite database file (e.g. DB_PATH="./data/db.sqlite3")'
  },

  // Email configuration
  {
    name: 'SMTP_SERVER',
    required: true,
    description: 'SMTP server hostname'
  },
  {
    name: 'SMTP_PORT',
    required: true,
    validate: (value) => {
      const port = parseInt(value, 10);
      if (isNaN(port) || port < 1 || port > 65535) {
        return 'SMTP_PORT must be a valid port number (1-65535)';
      }
      return null;
    },
    description: 'SMTP server port number'
  },
  {
    name: 'SMTP_USERNAME',
    required: true,
    description: 'SMTP authentication username'
  },
  {
    name: 'SMTP_PASSWORD',
    required: true,
    validate: (value) => {
      if (!value || value.length < 8) return 'SMTP_PASSWORD is required and should be at least 8 characters long for security';
      return null;
    },
    description: 'SMTP authentication password'
  },
  {
    name: 'SENDER_NAME',
    required: true,
    description: 'Display name for outgoing emails'
  },
  {
    name: 'SENDER_EMAIL',
    required: true,
    validate: (value) => {
      const email_regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!email_regex.test(value)) {
        return 'SENDER_EMAIL must be a valid email address';
      }
      return null;
    },
    description: 'Email address for outgoing emails'
  },
  {
    name: 'REPLY_TO_NAME',
    required: true,
    description: 'Display name for reply-to emails'
  },
  {
    name: 'REPLY_TO_EMAIL',
    required: true,
    validate: (value) => {
      const email_regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!email_regex.test(value)) {
        return 'REPLY_TO_EMAIL must be a valid email address';
      }
      return null;
    },
    description: 'Email address for reply-to emails'
  },
  {
    name: 'AVAILABLE_MODULES',
    required: true,
    description: 'Comma-separated list of available modules'
  },
  {
    name: 'HEARTBEAT_SECRET',
    required: true,
    description: 'Secret key for endpoints with hearbeat URL parameter'
  },
  {
    name: 'WEBHOOK_SECRET',
    required: false,
    validate: (value) => {
      if (value !== undefined && value.length < 32) {
        return 'WEBHOOK_SECRET should be at least 32 characters long for security (generate with: openssl rand -base64 32)';
      }
      return null;
    },
    description: 'Secret for signing webhook payloads with HMAC SHA-256 (optional, but required to enable webhooks in site settings)'
  },
  {
    name: 'ANALYTICS_SALT',
    required: true,
    validate: (value) => {
      if (!value || value.length < 32) {
        return 'ANALYTICS_SALT must be at least 32 characters long for security (generate with: openssl rand -base64 32)';
      }
      return null;
    },
    description: 'Secret salt for privacy-preserving analytics session hashing (required for security)'
  },

  // Hosting/runtime configuration
  {
    name: 'BODY_SIZE_LIMIT',
    required: true,
    validate: (value) => {
      const bytes = parseInt(value, 10);
      if (isNaN(bytes) || bytes < 1) {
        return 'BODY_SIZE_LIMIT must be a positive integer (bytes)';
      }
      return null;
    },
    description: 'Maximum request body size in bytes (e.g., 30000000 for ~30MB)'
  },

  // Public environment variables (these are exposed to the client)
  {
    name: 'PUBLIC_LANG',
    required: true,
    validate: (value) => {
      if (!/^[a-z]{2}$/.test(value)) {
        return 'PUBLIC_LANG must be a valid language code (e.g., "en", "de")';
      }
      return null;
    },
    description: 'Language code for the application'
  },
  {
    name: 'PUBLIC_TIME_ZONE',
    required: true,
    validate: (value) => {
      if (!value) {
        return 'PUBLIC_TIME_ZONE is required and should be a valid timezone (e.g., "Europe/Berlin", "America/New_York")';
      }
      return null;
    },
    description: 'Time zone for the application (e.g., "Europe/Berlin")'
  },
  {
    name: 'PUBLIC_TIME_LOCALE',
    required: true,
    validate: (value) => {
      const locale_regex = /^[a-z]{2}-[A-Z]{2}$/;
      if (!locale_regex.test(value)) {
        return 'PUBLIC_TIME_LOCALE must be in language-country format (e.g., "de-DE", "en-US")';
      }
      return null;
    },
    description: 'Locale for time formatting (e.g., "de-DE")'
  },
  {
    name: 'PUBLIC_TIME_HOUR_FORMAT',
    required: true,
    validate: (value) => {
      if (value !== '24' && value !== '12') {
        return 'PUBLIC_TIME_HOUR_FORMAT must be either "24" or "12"';
      }
      return null;
    },
    description: 'Hour format for time display (either "24" or "12")'
  },
  {
    name: 'PUBLIC_TIME_START_OF_WEEK',
    required: true,
    validate: (value) => {
      const day = parseInt(value, 10);
      if (isNaN(day) || day < 0 || day > 6) {
        return 'PUBLIC_TIME_START_OF_WEEK must be a number between 0 and 6 (0=Sunday, 1=Monday, ... 6=Saturday)';
      }
      return null;
    },
    description: 'First day of week (0=Sunday, 1=Monday, ... 6=Saturday)'
  },
  {
    name: 'PUBLIC_TECHNICAL_SUPPORT_EMAIL',
    required: true,
    validate: (value) => {
      const email_regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!email_regex.test(value)) {
        return 'PUBLIC_TECHNICAL_SUPPORT_EMAIL must be a valid email address';
      }
      return null;
    },
    description: 'Technical support email address'
  },
  
  // Optional media configuration
  {
    name: 'PUBLIC_MAX_IMAGE_DIMENSION',
    required: false,
    validate: (value) => {
      if (value !== undefined) {
        const n = parseInt(value, 10);
        if (isNaN(n) || n <= 0) {
          return 'PUBLIC_MAX_IMAGE_DIMENSION must be a positive integer (pixels)';
        }
      }
      return null;
    },
    description: 'Maximum long-edge image dimension in pixels (optional, default: 4000)'
  },
  {
    name: 'PUBLIC_IMAGE_QUALITY',
    required: false,
    validate: (value) => {
      if (value !== undefined) {
        const q = parseFloat(value);
        if (isNaN(q) || q <= 0 || q > 1) {
          return 'PUBLIC_IMAGE_QUALITY must be a number between 0 and 1';
        }
      }
      return null;
    },
    description: 'Image quality factor between 0 and 1 (optional, default: 0.92)'
  },
  {
    name: 'PUBLIC_MAX_IMAGE_FILE_SIZE',
    required: false,
    validate: (value) => {
      if (value !== undefined) {
        const bytes = parseInt(value, 10);
        if (isNaN(bytes) || bytes <= 0) {
          return 'PUBLIC_MAX_IMAGE_FILE_SIZE must be a positive integer (bytes)';
        }
      }
      return null;
    },
    description: 'Maximum allowed image file size in bytes (optional, default: 10000000)'
  },
  {
    name: 'PUBLIC_MAX_VIDEO_FILE_SIZE',
    required: false,
    validate: (value) => {
      if (value !== undefined) {
        const bytes = parseInt(value, 10);
        if (isNaN(bytes) || bytes <= 0) {
          return 'PUBLIC_MAX_VIDEO_FILE_SIZE must be a positive integer (bytes)';
        }
      }
      return null;
    },
    description: 'Maximum allowed video file size in bytes (optional, default: 30000000)'
  },
  {
    name: 'PUBLIC_MAX_VIDEO_DIMENSION',
    required: false,
    validate: (value) => {
      if (value !== undefined) {
        const n = parseInt(value, 10);
        if (isNaN(n) || n <= 0) {
          return 'PUBLIC_MAX_VIDEO_DIMENSION must be a positive integer (pixels)';
        }
      }
      return null;
    },
    description: 'Maximum video dimension (long edge) in pixels (optional, default: 1920)'
  },
  {
    name: 'PUBLIC_MAX_VIDEO_DURATION',
    required: false,
    validate: (value) => {
      if (value !== undefined) {
        const seconds = parseInt(value, 10);
        if (isNaN(seconds) || seconds <= 0) {
          return 'PUBLIC_MAX_VIDEO_DURATION must be a positive integer (seconds)';
        }
      }
      return null;
    },
    description: 'Maximum video duration in seconds (optional, default: 180)'
  },
  {
    name: 'PUBLIC_SHOW_BLOCK_GRID_LINES',
    required: false,
    validate: (value) => {
      if (value !== undefined && value !== 'true' && value !== 'false') {
        return 'PUBLIC_SHOW_BLOCK_GRID_LINES must be "true" or "false"';
      }
      return null;
    },
    description: 'Show block grid lines overlay in development (optional, default: false)'
  },
  {
    name: 'PUBLIC_SHOW_DEBUG_STYLES',
    required: false,
    validate: (value) => {
      if (value !== undefined && value !== 'true' && value !== 'false') {
        return 'PUBLIC_SHOW_DEBUG_STYLES must be "true" or "false"';
      }
      return null;
    },
    description: 'Show debug styles in development (optional, default: false)'
  },

  // Image variant processing
  {
    name: 'PUBLIC_IMAGE_AVIF_EFFORT',
    required: false,
    validate: (value) => {
      if (value !== undefined) {
        const n = parseInt(value, 10);
        if (isNaN(n) || n < 0 || n > 9) {
          return 'PUBLIC_IMAGE_AVIF_EFFORT must be an integer between 0 and 9';
        }
      }
      return null;
    },
    description: 'AVIF encoding effort (0-9, higher = slower + smaller) (optional, default: 4)'
  },
  {
    name: 'PUBLIC_IMAGE_VARIANT_ACCESS_THROTTLE_HOURS',
    required: false,
    validate: (value) => {
      if (value !== undefined) {
        const n = parseInt(value, 10);
        if (isNaN(n) || n < 0) {
          return 'PUBLIC_IMAGE_VARIANT_ACCESS_THROTTLE_HOURS must be a non-negative integer';
        }
      }
      return null;
    },
    description: 'Minimum hours between updating last_accessed_at per variant (optional, default: 24)'
  },
  {
    name: 'IMAGE_VARIANT_TTL_DAYS',
    required: false,
    validate: (value) => {
      if (value !== undefined) {
        const n = parseInt(value, 10);
        if (isNaN(n) || n < 1) {
          return 'IMAGE_VARIANT_TTL_DAYS must be a positive integer';
        }
      }
      return null;
    },
    description: 'Days before unused content variants are purged (optional, default: 90)'
  },
  {
    name: 'IMAGE_VARIANT_JOB_BATCH_SIZE',
    required: false,
    validate: (value) => {
      if (value !== undefined) {
        const n = parseInt(value, 10);
        if (isNaN(n) || n < 1) {
          return 'IMAGE_VARIANT_JOB_BATCH_SIZE must be a positive integer';
        }
      }
      return null;
    },
    description: 'Number of variant jobs to process per worker run (optional, default: 2)'
  },

  // Optional backup configuration
  {
    name: 'BACKUP_S3_ACCESS_KEY',
    required: false,
    description: 'S3 access key for database backups (optional)'
  },
  {
    name: 'BACKUP_S3_SECRET_ACCESS_KEY',
    required: false,
    description: 'S3 secret access key for database backups (optional)'
  },
  {
    name: 'BACKUP_S3_ENDPOINT',
    required: false,
    description: 'S3 endpoint for database backups (optional)'
  },
  {
    name: 'BACKUP_S3_BUCKET',
    required: false,
    description: 'S3 bucket for database backups (optional)'
  },
  {
    name: 'BACKUP_RETENTION_DAYS',
    required: false,
    validate: (value) => {
      if (value !== undefined) {
        const days = parseInt(value, 10);
        if (isNaN(days) || days < 1) {
          return 'BACKUP_RETENTION_DAYS must be a positive number';
        }
      }
      return null;
    },
    description: 'Number of days to retain backups (optional)'
  },
];

function validate_environment() {
  const errors = [];
  const warnings = [];

  console.log('🔍 Validating environment variables...\n');

  for (const config of ENV_CONFIG) {
    const { name, required = true, validate, description } = config;
    const value = process.env[name];

    if (required && !value) {
      errors.push(`❌ ${name}: Required but not set`);
      if (description) {
        errors.push(`   → ${description}`);
      }
      continue;
    }

    if (validate && value !== undefined) {
      const validation_error = validate(value);
      if (validation_error) {
        errors.push(`❌ ${name}: ${validation_error}`);
        if (description) {
          errors.push(`   → ${description}`);
        }
        continue;
      }
    }

    if (value) {
      console.log(`✅ ${name}: OK`);
    } else if (!required) {
      console.log(`⚪ ${name}: Not set (optional)`);
    }
  }

  console.log('');

  if (errors.length > 0) {
    console.error('🚨 Environment validation failed!\n');
    console.error('Errors:');
    errors.forEach(error => console.error(`  ${error}`));
    console.error('\nPlease fix these issues before building the application.');
    console.error('Check your environment variables (e.g. secrets manager in fly.io).');
    process.exit(1);
  }

  if (warnings.length > 0) {
    console.warn('⚠️  Warnings:');
    warnings.forEach(warning => console.warn(`  ${warning}`));
    console.warn('');
  }

  console.log('✅ All environment variables are valid!');
  console.log('🚀 Proceeding with build...\n');
}

// Run validation if this script is executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
  validate_environment();
}

export { validate_environment, ENV_CONFIG }; 

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions