michael / michael/editable

Interactive fly deploy tool

Open
#51 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Svelte
Stars
1.8k
Forks
150
Avg merge
11h 46m
Merged PRs (30d)
8

Description

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 }; 

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with tools/fly-deploy.js and its usage comment, then inspect scripts/validate-env.js and the local SQLite handling around ./data/db.sqlite3. Review the interactive deployment flow and --dry-run path, and define the required adjustments before verifying the tool against its intended Fly.io deployment behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, sqlite
Domain
cli, cloud, databases
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.