/** * One-time script: migrates email/password hashes from a Clerk CSV export into * BetterAuth's accounts table. * * Usage: * DATABASE_URL=... CLERK_SECRET_KEY=... node scripts/migrate-clerk-passwords.mjs * * Before running: * 1. Go to Clerk dashboard → Users → Export → Download CSV * 2. Save the file as exported_users.csv in the project root * * The script is idempotent — safe to run multiple times. * Delete exported_users.csv after confirming the migration succeeded. */ import { readFileSync, existsSync } from "fs"; import { resolve } from "path"; import postgres from "postgres"; const DATABASE_URL = process.env.DATABASE_DIRECT_URL || process.env.DATABASE_URL; if (!DATABASE_URL) { console.error("ERROR: DATABASE_URL (or DATABASE_DIRECT_URL) is required"); process.exit(1); } const CSV_PATH = resolve(process.cwd(), "exported_users.csv"); if (!existsSync(CSV_PATH)) { console.error(`ERROR: exported_users.csv not found at ${CSV_PATH}`); console.error("Export users from the Clerk dashboard (Users → Export) and save as exported_users.csv"); process.exit(1); } const sql = postgres(DATABASE_URL, { max: 1 }); try { await migratePasswords(); } catch (err) { console.error("Migration failed:", err); await sql.end().catch(() => {}); process.exit(1); } await sql.end().catch(() => {}); process.exit(0); // ───────────────────────────────────────────────────────────────────────────── function parseCSV(content) { const lines = content.split("\n").filter((line) => line.trim()); if (lines.length < 2) return []; const headers = lines[0].split(",").map((h) => h.trim().replace(/^"|"$/g, "")); return lines.slice(1).map((line) => { // Handle quoted fields that may contain commas const values = []; let current = ""; let inQuotes = false; for (const char of line) { if (char === '"') { inQuotes = !inQuotes; } else if (char === "," && !inQuotes) { values.push(current.trim()); current = ""; } else { current += char; } } values.push(current.trim()); return headers.reduce((obj, header, i) => { obj[header] = (values[i] ?? "").replace(/^"|"$/g, ""); return obj; }, {}); }); } async function migratePasswords() { const csvContent = readFileSync(CSV_PATH, "utf-8"); const rows = parseCSV(csvContent); console.log(`Parsed ${rows.length} users from CSV`); // Only process rows with a password hash const passwordRows = rows.filter((r) => r.password_digest && r.password_hasher === "bcrypt"); console.log(`Found ${passwordRows.length} users with bcrypt password hashes`); if (passwordRows.length === 0) { console.log("Nothing to migrate."); return; } let created = 0; let skipped = 0; let notFound = 0; for (const row of passwordRows) { const clerkId = row.id; const passwordHash = row.password_digest; // Find the user in our DB by their Clerk ID const [dbUser] = await sql` SELECT id FROM users WHERE clerk_id = ${clerkId} LIMIT 1 `; if (!dbUser) { notFound++; continue; } // Check if a credential account already exists for this user const [existing] = await sql` SELECT 1 FROM accounts WHERE user_id = ${dbUser.id}::uuid AND provider_id = 'credential' LIMIT 1 `; if (existing) { skipped++; continue; } // Insert credential account with the migrated bcrypt hash await sql` INSERT INTO accounts (id, account_id, provider_id, user_id, password, created_at, updated_at) VALUES ( gen_random_uuid()::text, ${dbUser.id}, 'credential', ${dbUser.id}::uuid, ${passwordHash}, now(), now() ) `; created++; } console.log(`Done: ${created} credential accounts created, ${skipped} already existed, ${notFound} Clerk users not found in DB`); if (notFound > 0) { console.log(" (notFound users may have been deleted or never synced via webhook)"); } console.log("\nIMPORTANT: Delete exported_users.csv now — it contains password hashes."); }