Files
redlight/migrate-from-greenlight.mjs
2026-03-10 22:25:53 +01:00

695 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});