/** * Greenlight → Redlight Migration Script * * Migrates users, rooms (including settings and shared accesses), * site settings (branding, registration), logo, and OAuth/OIDC configuration * from a Greenlight v3 PostgreSQL database into a Redlight instance * (SQLite or PostgreSQL). * * Usage: * node migrate-from-greenlight.mjs [options] * * Options: * --dry-run Show what would be migrated without writing anything * --skip-rooms Only migrate users, not rooms * --skip-shares Do not migrate room share (shared_accesses) * --skip-settings Do not migrate site_settings / branding * --skip-oauth Do not migrate OAuth / OIDC configuration * --verbose Print every imported row * * Environment variables (can also be in .env): * * Source (Greenlight DB — PostgreSQL): * GL_DATABASE_URL Full Postgres connection string * e.g. postgres://gl_user:pass@localhost/greenlight_db * * Target (Redlight DB — auto-detected): * DATABASE_URL Set for PostgreSQL target (same format as GL_DATABASE_URL) * SQLITE_PATH Set (or leave empty) for SQLite target * Default: ./redlight.db (relative to this script) * * OAuth (from Greenlight .env — optional, only if --skip-oauth is NOT set): * GL_OIDC_ISSUER OIDC issuer URL (e.g. https://keycloak.example.com/realms/myrealm) * GL_OIDC_CLIENT_ID OIDC client ID * GL_OIDC_CLIENT_SECRET OIDC client secret * GL_OIDC_DISPLAY_NAME Button label on the login page (default: "SSO") * * Password hashes: * Both Greenlight and Redlight use bcrypt, so password_digest is * copied as-is — users can log in with their existing passwords. * * Site-settings mapping (GL site_settings → Redlight settings): * BrandingImage → downloads file → uploads/branding/logo.* * PrimaryColor → (logged, not mapped — Redlight uses themes) * RegistrationMethod → registration_mode (open / invite) * Terms → imprint_url * PrivacyPolicy → privacy_url * * Meeting-option → Redlight field mapping: * record → record_meeting * muteOnStart → mute_on_join * guestPolicy → require_approval (ASK_MODERATOR = true) * glAnyoneCanStart → anyone_can_start * glAnyoneJoinAsModerator → all_join_moderator * glViewerAccessCode → access_code * glModeratorAccessCode → moderator_code */ import 'dotenv/config'; import pg from 'pg'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // ── helpers ──────────────────────────────────────────────────────────────── const args = process.argv.slice(2); const DRY_RUN = args.includes('--dry-run'); const SKIP_ROOMS = args.includes('--skip-rooms'); const SKIP_SHARES = args.includes('--skip-shares'); const SKIP_SETTINGS = args.includes('--skip-settings'); const SKIP_OAUTH = args.includes('--skip-oauth'); const VERBOSE = args.includes('--verbose'); const c = { reset: '\x1b[0m', bold: '\x1b[1m', green: '\x1b[32m', yellow:'\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m', dim: '\x1b[2m', }; const log = (...a) => console.log(...a); const ok = (msg) => log(` ${c.green}✓${c.reset} ${msg}`); const warn = (msg) => log(` ${c.yellow}⚠${c.reset} ${msg}`); const skip = (msg) => log(` ${c.dim}– ${msg}${c.reset}`); const err = (msg) => log(` ${c.red}✗${c.reset} ${msg}`); const info = (msg) => log(` ${c.cyan}i${c.reset} ${msg}`); const loud = (msg) => { if (VERBOSE) log(` ${c.dim}${msg}${c.reset}`); }; // ── Source DB (Greenlight PostgreSQL) ───────────────────────────────────── const GL_URL = process.env.GL_DATABASE_URL; if (!GL_URL) { err('GL_DATABASE_URL is not set. Please set it in your .env file or environment.'); err(' Example: GL_DATABASE_URL=postgres://gl_user:pass@localhost/greenlight_production'); process.exit(1); } // ── Target DB (Redlight) ─────────────────────────────────────────────────── const RL_URL = process.env.DATABASE_URL; const isPostgresTarget = !!(RL_URL && RL_URL.startsWith('postgres')); // ── SQLite adapter (only loaded when needed) ─────────────────────────────── async function openSqlite() { const require = createRequire(import.meta.url); let Database; try { Database = require('better-sqlite3'); } catch { err('better-sqlite3 is not installed. Run: npm install better-sqlite3'); process.exit(1); } const dbPath = process.env.SQLITE_PATH || './redlight.db'; const db = Database(dbPath); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); return { async get(sql, params = []) { return db.prepare(sql).get(...params); }, async all(sql, params = []) { return db.prepare(sql).all(...params); }, async run(sql, params = []) { const r = db.prepare(sql).run(...params); return { lastInsertRowid: Number(r.lastInsertRowid), changes: r.changes }; }, close() { db.close(); }, type: 'sqlite', path: dbPath, }; } // ── PostgreSQL adapter ───────────────────────────────────────────────────── async function openPostgres(url, label) { const pool = new pg.Pool({ connectionString: url }); let index = 0; const convert = (sql) => sql.replace(/\?/g, () => `$${++index}`); return { async get(sql, params = []) { index = 0; const r = await pool.query(convert(sql), params); return r.rows[0]; }, async all(sql, params = []) { index = 0; const r = await pool.query(convert(sql), params); return r.rows; }, async run(sql, params = []) { index = 0; let q = convert(sql); if (/^\s*INSERT/i.test(q) && !/RETURNING/i.test(q)) q += ' RETURNING id'; const r = await pool.query(q, params); return { lastInsertRowid: r.rows[0]?.id, changes: r.rowCount }; }, async end() { await pool.end(); }, type: 'postgres', }; } // ── meeting option → redlight field map ─────────────────────────────────── const OPTION_MAP = { record: 'record_meeting', muteOnStart: 'mute_on_join', guestPolicy: 'require_approval', // special: "ASK_MODERATOR" glAnyoneCanStart: 'anyone_can_start', glAnyoneJoinAsModerator: 'all_join_moderator', glViewerAccessCode: 'access_code', glModeratorAccessCode: 'moderator_code', }; function boolOption(val) { return val === 'true' || val === '1' ? 1 : 0; } // ── AES-256-GCM encryption (matching Redlight's oauth.js) ───────────────── const ENCRYPTION_KEY = crypto .createHash('sha256') .update(process.env.JWT_SECRET || '') .digest(); function encryptSecret(plaintext) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); let encrypted = cipher.update(plaintext, 'utf8', 'hex'); encrypted += cipher.final('hex'); const authTag = cipher.getAuthTag().toString('hex'); return `${iv.toString('hex')}:${authTag}:${encrypted}`; } // ── Helper: upsert a setting into the Redlight settings table ───────────── async function upsertSetting(db, key, value, isPostgres) { const existing = await db.get('SELECT key FROM settings WHERE key = ?', [key]); if (existing) { await db.run('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?', [value, key]); } else { await db.run('INSERT INTO settings (key, value) VALUES (?, ?) RETURNING key', [key, value]); } } // ── main ─────────────────────────────────────────────────────────────────── async function main() { log(); log(`${c.bold}Greenlight → Redlight Migration${c.reset}`); if (DRY_RUN) log(`${c.yellow}${c.bold} DRY RUN — nothing will be written${c.reset}`); log(); // Connect to source let glDb; try { glDb = await openPostgres(GL_URL, 'Greenlight'); await glDb.get('SELECT 1'); info(`Connected to Greenlight DB (PostgreSQL)`); } catch (e) { err(`Cannot connect to Greenlight DB: ${e.message}`); process.exit(1); } // Connect to target let rlDb; try { if (isPostgresTarget) { rlDb = await openPostgres(RL_URL, 'Redlight'); await rlDb.get('SELECT 1'); info(`Connected to Redlight DB (PostgreSQL)`); } else { rlDb = await openSqlite(); info(`Connected to Redlight DB (SQLite: ${rlDb.path})`); } } catch (e) { err(`Cannot connect to Redlight DB: ${e.message}`); process.exit(1); } log(); // ── 1. Load Greenlight roles ─────────────────────────────────────────── const roles = await glDb.all('SELECT id, name FROM roles'); const adminRoleIds = new Set( roles.filter(r => /admin|administrator/i.test(r.name)).map(r => r.id) ); info(`Found ${roles.length} roles (${adminRoleIds.size} admin role(s))`); // ── 2. Load Greenlight users (local + OIDC) ──────────────────────────── const glUsers = await glDb.all(` SELECT id, name, email, password_digest, language, role_id, verified, status, provider, external_id FROM users ORDER BY created_at ASC `); const localUsers = glUsers.filter(u => u.provider === 'greenlight'); const oidcUsers = glUsers.filter(u => u.provider !== 'greenlight' && u.provider !== null); info(`Found ${localUsers.length} local user(s), ${oidcUsers.length} OIDC/SSO user(s)`); log(); // ── 3. Migrate local users ───────────────────────────────────────────── log(`${c.bold}Local Users${c.reset}`); const userIdMap = new Map(); // gl user id → rl user id let usersCreated = 0, usersSkipped = 0; for (const u of localUsers) { if (!u.password_digest) { warn(`${u.email} — no password, skipping`); usersSkipped++; continue; } const role = adminRoleIds.has(u.role_id) ? 'admin' : 'user'; const emailVerified = u.verified ? 1 : 0; const lang = u.language || 'de'; const displayName = u.name || ''; const existing = await rlDb.get('SELECT id FROM users WHERE email = ?', [u.email]); if (existing) { userIdMap.set(u.id, existing.id); skip(`${u.email} — already exists (id=${existing.id}), skipping`); usersSkipped++; continue; } loud(`INSERT user ${u.email} (role=${role})`); if (!DRY_RUN) { const result = await rlDb.run( `INSERT INTO users (name, display_name, email, password_hash, role, language, email_verified) VALUES (?, ?, ?, ?, ?, ?, ?)`, [displayName, displayName, u.email, u.password_digest, role, lang, emailVerified] ); userIdMap.set(u.id, result.lastInsertRowid); ok(`${u.email} (${role})`); } else { ok(`[dry] ${u.email} (${role})`); } usersCreated++; } log(); log(` Created: ${c.green}${usersCreated}${c.reset} Skipped: ${usersSkipped}`); log(); // ── 3b. Migrate OIDC / SSO users ─────────────────────────────────────── log(`${c.bold}OIDC / SSO Users${c.reset}`); let oidcCreated = 0, oidcSkipped = 0; for (const u of oidcUsers) { const role = adminRoleIds.has(u.role_id) ? 'admin' : 'user'; const lang = u.language || 'de'; const displayName = u.name || ''; const email = (u.email || '').toLowerCase().trim(); if (!email) { warn(`OIDC user id=${u.id} — no email, skipping`); oidcSkipped++; continue; } const existing = await rlDb.get('SELECT id FROM users WHERE email = ?', [email]); if (existing) { userIdMap.set(u.id, existing.id); // Link OAuth provider if not set yet if (!DRY_RUN && u.external_id) { await rlDb.run( 'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND oauth_provider IS NULL', ['oidc', u.external_id, existing.id] ); } skip(`${email} — already exists (id=${existing.id}), linked OIDC`); oidcSkipped++; continue; } // Generate a random unusable password hash for OIDC users const randomHash = `oauth:${crypto.randomUUID()}`; loud(`INSERT OIDC user ${email} (role=${role}, sub=${u.external_id || '?'})`); if (!DRY_RUN) { const result = await rlDb.run( `INSERT INTO users (name, display_name, email, password_hash, role, language, email_verified, oauth_provider, oauth_provider_id) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`, [displayName, displayName, email, randomHash, role, lang, 'oidc', u.external_id || null] ); userIdMap.set(u.id, result.lastInsertRowid); ok(`${email} (${role}, OIDC)`); } else { ok(`[dry] ${email} (${role}, OIDC)`); } oidcCreated++; } log(); log(` Created: ${c.green}${oidcCreated}${c.reset} Skipped: ${oidcSkipped}`); log(); if (SKIP_ROOMS) { log(`${c.yellow}--skip-rooms set, stopping here.${c.reset}`); await cleanup(glDb, rlDb); return; } // ── 4. Load Greenlight rooms + meeting options ───────────────────────── log(`${c.bold}Rooms${c.reset}`); const glRooms = await glDb.all(` SELECT r.id, r.name, r.meeting_id, r.user_id FROM rooms r ORDER BY r.created_at ASC `); // Load all meeting options (key name list) const meetingOptions = await glDb.all('SELECT id, name FROM meeting_options'); const optionIdToName = new Map(meetingOptions.map(m => [m.id, m.name])); // Load all room_meeting_option values in one shot const roomOptionRows = await glDb.all('SELECT room_id, meeting_option_id, value FROM room_meeting_options'); const roomOptions = new Map(); // room_id → { optionName: value } for (const row of roomOptionRows) { const name = optionIdToName.get(row.meeting_option_id); if (!name) continue; if (!roomOptions.has(row.room_id)) roomOptions.set(row.room_id, {}); roomOptions.get(row.room_id)[name] = row.value; } info(`Found ${glRooms.length} room(s) across all users`); log(); let roomsCreated = 0, roomsSkipped = 0; const roomIdMap = new Map(); // gl room id → rl room id for (const room of glRooms) { // Determine the redlight owner const rlUserId = userIdMap.get(room.user_id); if (!rlUserId && !DRY_RUN) { // Try to look up the email in redlight directly in case user already existed const glUser = glUsers.find(u => u.id === room.user_id); if (glUser) { const ex = await rlDb.get('SELECT id FROM users WHERE email = ?', [glUser.email]); if (ex) { userIdMap.set(room.user_id, ex.id); } else { warn(`Room "${room.name}" — owner not found in Redlight, skipping`); roomsSkipped++; continue; } } else { warn(`Room "${room.name}" — owner not found in Greenlight users, skipping`); roomsSkipped++; continue; } } const ownerId = userIdMap.get(room.user_id) || null; // Use Greenlight meeting_id as the uid (preserves BBB meeting identity) // Greenlight meeting_ids can be longer, but Redlight stores uid as TEXT — no problem. const uid = room.meeting_id; // Check if already in Redlight const existingRoom = await rlDb.get('SELECT id FROM rooms WHERE uid = ?', [uid]); if (existingRoom) { roomIdMap.set(room.id, existingRoom.id); skip(`"${room.name}" (${uid.substring(0, 12)}…) — already exists`); roomsSkipped++; continue; } // Map meeting options to Redlight fields const opts = roomOptions.get(room.id) || {}; const mute_on_join = opts.muteOnStart ? boolOption(opts.muteOnStart) : 1; const record_meeting = opts.record ? boolOption(opts.record) : 1; const anyone_can_start = opts.glAnyoneCanStart ? boolOption(opts.glAnyoneCanStart) : 0; const all_join_moderator = opts.glAnyoneJoinAsModerator ? boolOption(opts.glAnyoneJoinAsModerator): 0; const require_approval = opts.guestPolicy === 'ASK_MODERATOR' ? 1 : 0; const access_code = (opts.glViewerAccessCode && opts.glViewerAccessCode !== 'false') ? opts.glViewerAccessCode : null; const moderator_code = (opts.glModeratorAccessCode && opts.glModeratorAccessCode !== 'false') ? opts.glModeratorAccessCode : null; const guest_access = 1; // Default open like Greenlight // Ensure room name meets Redlight 2-char minimum const roomName = (room.name || 'Room').length >= 2 ? room.name : (room.name || 'Room').padEnd(2, ' '); loud(`INSERT room "${roomName}" uid=${uid.substring(0, 16)}… owner=${ownerId}`); if (!DRY_RUN && ownerId) { const result = await rlDb.run( `INSERT INTO rooms (uid, name, user_id, mute_on_join, record_meeting, anyone_can_start, all_join_moderator, require_approval, access_code, moderator_code, guest_access) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [uid, roomName, ownerId, mute_on_join, record_meeting, anyone_can_start, all_join_moderator, require_approval, access_code, moderator_code, guest_access] ); roomIdMap.set(room.id, result.lastInsertRowid); const optSummary = [ mute_on_join ? 'muted' : null, record_meeting ? 'record' : null, anyone_can_start ? 'anyStart' : null, all_join_moderator ? 'allMod' : null, require_approval ? 'approval' : null, access_code ? 'code:'+access_code.substring(0,4)+'…' : null, ].filter(Boolean).join(', '); ok(`"${roomName}"${optSummary ? ` [${optSummary}]` : ''}`); } else if (DRY_RUN) { ok(`[dry] "${roomName}" (uid=${uid.substring(0, 16)}…)`); } else { warn(`"${roomName}" — skipped (no owner resolved)`); roomsSkipped++; continue; } roomsCreated++; } log(); log(` Created: ${c.green}${roomsCreated}${c.reset} Skipped: ${roomsSkipped}`); log(); // ── 5. Migrate shared_accesses ───────────────────────────────────────── if (!SKIP_SHARES) { log(`${c.bold}Room Shares${c.reset}`); const shares = await glDb.all('SELECT user_id, room_id FROM shared_accesses'); info(`Found ${shares.length} shared accesse(s)`); log(); let sharesCreated = 0, sharesSkipped = 0; for (const s of shares) { const rlUser = userIdMap.get(s.user_id); const rlRoom = roomIdMap.get(s.room_id); if (!rlUser || !rlRoom) { loud(`Skip share user=${s.user_id} room=${s.room_id} — not found in target`); sharesSkipped++; continue; } const exists = await rlDb.get( 'SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [rlRoom, rlUser] ); if (exists) { sharesSkipped++; continue; } if (!DRY_RUN) { await rlDb.run( 'INSERT INTO room_shares (room_id, user_id) VALUES (?, ?)', [rlRoom, rlUser] ); ok(`Share room_id=${rlRoom} → user_id=${rlUser}`); } else { ok(`[dry] Share room_id=${rlRoom} → user_id=${rlUser}`); } sharesCreated++; } log(); log(` Created: ${c.green}${sharesCreated}${c.reset} Skipped: ${sharesSkipped}`); log(); } // ── 6. Migrate site_settings / branding ──────────────────────────────── if (!SKIP_SETTINGS) { log(`${c.bold}Site Settings & Branding${c.reset}`); let settingsCount = 0; // Check if site_settings table exists in Greenlight let hasSiteSettings = false; try { await glDb.get('SELECT 1 FROM site_settings LIMIT 1'); hasSiteSettings = true; } catch { warn('No site_settings table found in Greenlight DB — skipping settings migration'); } if (hasSiteSettings) { const glSettings = await glDb.all('SELECT setting, value FROM site_settings'); const settingsMap = new Map(glSettings.map(s => [s.setting, s.value])); info(`Found ${glSettings.length} site_setting(s) in Greenlight`); // ── Registration mode ────────────────────────────────────────────── const regMethod = settingsMap.get('RegistrationMethod'); if (regMethod) { // Greenlight: "open", "invite", "approval" → Redlight: "open" or "invite" const mode = regMethod === 'open' ? 'open' : 'invite'; if (!DRY_RUN) await upsertSetting(rlDb, 'registration_mode', mode, isPostgresTarget); ok(`registration_mode → ${mode} (was: ${regMethod})`); settingsCount++; } // ── Privacy policy URL ───────────────────────────────────────────── const privacy = settingsMap.get('PrivacyPolicy'); if (privacy && privacy.trim()) { if (!DRY_RUN) await upsertSetting(rlDb, 'privacy_url', privacy.trim(), isPostgresTarget); ok(`privacy_url → ${privacy.trim()}`); settingsCount++; } // ── Terms / Imprint URL ──────────────────────────────────────────── const terms = settingsMap.get('Terms'); if (terms && terms.trim()) { if (!DRY_RUN) await upsertSetting(rlDb, 'imprint_url', terms.trim(), isPostgresTarget); ok(`imprint_url → ${terms.trim()}`); settingsCount++; } // ── Primary color (informational only — Redlight uses themes) ───── const primaryColor = settingsMap.get('PrimaryColor'); if (primaryColor) { info(`PrimaryColor in Greenlight was: ${primaryColor} (not mapped — Redlight uses themes)`); } // ── Branding image / logo ────────────────────────────────────────── const brandingImage = settingsMap.get('BrandingImage'); if (brandingImage && brandingImage.trim()) { const logoUrl = brandingImage.trim(); info(`BrandingImage URL: ${logoUrl}`); if (!DRY_RUN) { try { const response = await fetch(logoUrl, { signal: AbortSignal.timeout(15_000) }); if (response.ok) { const contentType = response.headers.get('content-type') || ''; let ext = '.png'; if (contentType.includes('svg')) ext = '.svg'; else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = '.jpg'; else if (contentType.includes('gif')) ext = '.gif'; else if (contentType.includes('webp')) ext = '.webp'; else if (contentType.includes('ico')) ext = '.ico'; const brandingDir = path.join(__dirname, 'uploads', 'branding'); fs.mkdirSync(brandingDir, { recursive: true }); // Remove old logos if (fs.existsSync(brandingDir)) { for (const f of fs.readdirSync(brandingDir)) { if (f.startsWith('logo.')) fs.unlinkSync(path.join(brandingDir, f)); } } const logoPath = path.join(brandingDir, `logo${ext}`); const buffer = Buffer.from(await response.arrayBuffer()); fs.writeFileSync(logoPath, buffer); ok(`Logo saved → uploads/branding/logo${ext} (${(buffer.length / 1024).toFixed(1)} KB)`); settingsCount++; } else { warn(`Could not download logo (HTTP ${response.status}) — skipping`); } } catch (dlErr) { warn(`Logo download failed: ${dlErr.message}`); } } else { ok(`[dry] Would download logo from ${logoUrl}`); settingsCount++; } } log(); log(` Settings migrated: ${c.green}${settingsCount}${c.reset}`); log(); } } // ── 7. Migrate OAuth / OIDC configuration ────────────────────────────── if (!SKIP_OAUTH) { log(`${c.bold}OAuth / OIDC Configuration${c.reset}`); const issuer = process.env.GL_OIDC_ISSUER; const clientId = process.env.GL_OIDC_CLIENT_ID; const clientSecret = process.env.GL_OIDC_CLIENT_SECRET; const displayName = process.env.GL_OIDC_DISPLAY_NAME || 'SSO'; if (issuer && clientId && clientSecret) { if (!process.env.JWT_SECRET) { warn('JWT_SECRET is not set — cannot encrypt client secret. Skipping OAuth migration.'); } else { // Check if already configured const existing = await rlDb.get("SELECT key FROM settings WHERE key = 'oauth_config'"); if (existing) { warn('oauth_config already exists in Redlight — skipping (delete it first to re-migrate)'); } else { info(`Issuer: ${issuer}`); info(`Client ID: ${clientId}`); info(`Display name: ${displayName}`); if (!DRY_RUN) { const config = JSON.stringify({ issuer, clientId, encryptedSecret: encryptSecret(clientSecret), displayName, autoRegister: true, }); await upsertSetting(rlDb, 'oauth_config', config, isPostgresTarget); ok('OAuth/OIDC configuration saved'); } else { ok('[dry] Would save OAuth/OIDC configuration'); } } } } else if (issuer || clientId || clientSecret) { warn('Incomplete OIDC config — set GL_OIDC_ISSUER, GL_OIDC_CLIENT_ID, and GL_OIDC_CLIENT_SECRET'); } else { info('No GL_OIDC_* env vars set — skipping OAuth migration'); info('Set GL_OIDC_ISSUER, GL_OIDC_CLIENT_ID, GL_OIDC_CLIENT_SECRET to migrate OIDC config'); } log(); } // ── Summary ──────────────────────────────────────────────────────────── log(`${c.bold}${c.green}Migration complete${c.reset}`); log(); log(` Local users migrated: ${c.green}${usersCreated}${c.reset}`); log(` OIDC users migrated: ${c.green}${oidcCreated}${c.reset}`); log(` Rooms migrated: ${c.green}${roomsCreated}${c.reset}`); if (!SKIP_SHARES) log(` Shares migrated: (see above)`); if (!SKIP_SETTINGS) log(` Settings migrated: (see above)`); if (!SKIP_OAUTH) log(` OAuth config: (see above)`); if (DRY_RUN) { log(); log(` ${c.yellow}${c.bold}This was a DRY RUN — rerun without --dry-run to apply.${c.reset}`); } log(); await cleanup(glDb, rlDb); } async function cleanup(glDb, rlDb) { try { await glDb.end?.(); } catch {} try { await rlDb.end?.(); rlDb.close?.(); } catch {} } main().catch(e => { console.error(`\n${c.red}Fatal error:${c.reset}`, e.message); process.exit(1); });