-
Notifications
You must be signed in to change notification settings - Fork 149
Open
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 }; Metadata
Metadata
Assignees
Labels
No labels