From e7e9f4780ec7df84759bd68fe928f8ef72f118fb Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Fri, 6 Feb 2026 16:25:23 -0600 Subject: [PATCH 1/9] Add Supabase auto-export command and improve name extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `export:supabase` command connects to Supabase Postgres and exports all users from auth.users including encrypted_password (bcrypt hashes) for password-preserving migration to Clerk - Auto-extracts display_name/first_name/name from raw_user_meta_data - Supabase transformer postTransform now extracts firstName/lastName from publicMetadata when not already set, so users don't need custom SQL to get names — the transformer handles it automatically Co-Authored-By: Claude Opus 4.6 --- bun.lock | 32 ++++++ package.json | 3 + src/export/index.ts | 88 ++++++++++++++++ src/export/supabase.ts | 144 +++++++++++++++++++++++++++ src/migrate/transformers/supabase.ts | 17 ++++ 5 files changed, 284 insertions(+) create mode 100644 src/export/index.ts create mode 100644 src/export/supabase.ts diff --git a/bun.lock b/bun.lock index 5993ecd..cde4f70 100644 --- a/bun.lock +++ b/bun.lock @@ -12,12 +12,14 @@ "dotenv": "16.6.1", "mime-types": "^3.0.2", "p-limit": "^7.2.0", + "pg": "^8.18.0", "picocolors": "^1.1.1", "zod": "^4.3.5", }, "devDependencies": { "@types/bun": "^1.3.6", "@types/mime-types": "^3.0.1", + "@types/pg": "^8.16.0", "@typescript-eslint/eslint-plugin": "^8.53.1", "@typescript-eslint/parser": "^8.53.1", "eslint": "^9.39.2", @@ -213,6 +215,8 @@ "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], + "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/type-utils": "8.53.1", "@typescript-eslint/utils": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg=="], @@ -461,6 +465,22 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pg": ["pg@8.18.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ=="], + + "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], + + "pg-connection-string": ["pg-connection-string@2.11.0", "", {}, "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.11.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w=="], + + "pg-protocol": ["pg-protocol@1.11.0", "", {}, "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -469,6 +489,14 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.0", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA=="], @@ -503,6 +531,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], @@ -559,6 +589,8 @@ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], diff --git a/package.json b/package.json index e3f995b..922f267 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "clean-logs": "bun ./src/clean-logs/index.ts", "convert-logs": "bun ./src/convert-logs/index.ts", "delete": "bun ./src/delete/index.ts", + "export:supabase": "bun ./src/export/index.ts", "format": "prettier . --write", "format:test": "prettier . --check", "lint": "eslint .", @@ -33,12 +34,14 @@ "dotenv": "16.6.1", "mime-types": "^3.0.2", "p-limit": "^7.2.0", + "pg": "^8.18.0", "picocolors": "^1.1.1", "zod": "^4.3.5" }, "devDependencies": { "@types/bun": "^1.3.6", "@types/mime-types": "^3.0.1", + "@types/pg": "^8.16.0", "@typescript-eslint/eslint-plugin": "^8.53.1", "@typescript-eslint/parser": "^8.53.1", "eslint": "^9.39.2", diff --git a/src/export/index.ts b/src/export/index.ts new file mode 100644 index 0000000..9737da7 --- /dev/null +++ b/src/export/index.ts @@ -0,0 +1,88 @@ +/** + * Supabase user export CLI + * + * Exports users from a Supabase Postgres database to a JSON file + * compatible with the migration script's Supabase transformer. + * + * Usage: + * bun run export:supabase + * bun run export:supabase --db-url postgresql://... --output users.json + * + * Environment variables: + * SUPABASE_DB_URL - Postgres connection string (alternative to --db-url flag) + */ +import 'dotenv/config'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; +import { exportSupabaseUsers, displayExportSummary } from './supabase'; + +async function main() { + p.intro(color.bgCyan(color.black('Supabase User Export'))); + + // Parse CLI flags + const args = process.argv.slice(2); + let dbUrl = process.env.SUPABASE_DB_URL; + let outputFile = 'supabase-export.json'; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--db-url' && args[i + 1]) { + dbUrl = args[i + 1]; + i++; + } else if (args[i] === '--output' && args[i + 1]) { + outputFile = args[i + 1]; + i++; + } + } + + // Prompt for DB URL if not provided + if (!dbUrl) { + const input = await p.text({ + message: 'Enter your Supabase Postgres connection string', + placeholder: + 'postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres', + validate: (value) => { + if (!value || value.trim() === '') { + return 'Connection string is required'; + } + if ( + !value.startsWith('postgresql://') && + !value.startsWith('postgres://') + ) { + return 'Must be a valid Postgres connection string (postgresql://...)'; + } + }, + }); + + if (p.isCancel(input)) { + p.cancel('Export cancelled.'); + process.exit(0); + } + + dbUrl = input; + } + + const spinner = p.spinner(); + spinner.start('Connecting to Supabase database...'); + + try { + const result = await exportSupabaseUsers(dbUrl, outputFile); + spinner.stop(`Found ${result.userCount} users`); + + displayExportSummary(result); + + p.log.info( + color.dim( + `Next step: run ${color.bold('bun run migrate')} and select "Supabase" with file "${outputFile}"` + ) + ); + + p.outro(color.green('Export complete!')); + } catch (err) { + spinner.stop('Export failed'); + const message = err instanceof Error ? err.message : String(err); + p.log.error(color.red(message)); + process.exit(1); + } +} + +main(); diff --git a/src/export/supabase.ts b/src/export/supabase.ts new file mode 100644 index 0000000..2ffdb99 --- /dev/null +++ b/src/export/supabase.ts @@ -0,0 +1,144 @@ +/** + * Supabase user export module + * + * Connects to a Supabase Postgres database and exports users from the auth.users table + * in a format compatible with the Supabase migration transformer. + * + * Includes: + * - encrypted_password (bcrypt hashes) — not available via Supabase Admin API + * - first_name extracted from raw_user_meta_data.display_name + * - All standard auth fields (email, phone, confirmation status, metadata) + */ +import { Client } from 'pg'; +import fs from 'fs'; +import path from 'path'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; + +/** + * SQL query that exports users in the format expected by the Supabase transformer. + * + * Extracts display_name from raw_user_meta_data as first_name so the transformer + * can map it directly without custom SQL from the user. + */ +const EXPORT_QUERY = ` + SELECT + id, + email, + email_confirmed_at, + encrypted_password, + phone, + phone_confirmed_at, + COALESCE( + raw_user_meta_data->>'display_name', + raw_user_meta_data->>'first_name', + raw_user_meta_data->>'name' + ) as first_name, + raw_user_meta_data->>'last_name' as last_name, + raw_user_meta_data, + created_at + FROM auth.users + ORDER BY created_at +`; + +interface ExportResult { + userCount: number; + outputPath: string; + fieldCoverage: { + email: number; + emailConfirmed: number; + password: number; + phone: number; + firstName: number; + lastName: number; + }; +} + +/** + * Exports users from a Supabase Postgres database to a JSON file. + * + * @param dbUrl - Postgres connection string (e.g., postgresql://postgres:password@db.xxx.supabase.co:5432/postgres) + * @param outputFile - Output file path (relative to project root) + * @returns Export result with user count and field coverage stats + */ +export async function exportSupabaseUsers( + dbUrl: string, + outputFile: string +): Promise { + const client = new Client({ connectionString: dbUrl }); + + try { + await client.connect(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to connect to Supabase database: ${message}\n\n` + + `Connection string format: postgresql://postgres:[PASSWORD]@db.[PROJECT_REF].supabase.co:5432/postgres\n` + + `Find this in Supabase Dashboard → Settings → Database → Connection string` + ); + } + + try { + const { rows } = await client.query(EXPORT_QUERY); + + // Calculate field coverage + const coverage = { + email: 0, + emailConfirmed: 0, + password: 0, + phone: 0, + firstName: 0, + lastName: 0, + }; + + for (const row of rows) { + if (row.email) coverage.email++; + if (row.email_confirmed_at) coverage.emailConfirmed++; + if (row.encrypted_password) coverage.password++; + if (row.phone) coverage.phone++; + if (row.first_name) coverage.firstName++; + if (row.last_name) coverage.lastName++; + } + + // Write output + const outputPath = path.isAbsolute(outputFile) + ? outputFile + : path.join(process.cwd(), outputFile); + + fs.writeFileSync(outputPath, JSON.stringify(rows, null, 2)); + + return { + userCount: rows.length, + outputPath, + fieldCoverage: coverage, + }; + } finally { + await client.end(); + } +} + +/** + * Displays a summary of the export results with field coverage stats. + */ +export function displayExportSummary(result: ExportResult): void { + const { userCount, outputPath, fieldCoverage } = result; + + const getIcon = (count: number, total: number): string => { + if (count === total) return color.green('●'); + if (count > 0) return color.yellow('○'); + return color.dim('○'); + }; + + let summary = ''; + summary += `${getIcon(fieldCoverage.email, userCount)} ${color.dim(`${fieldCoverage.email}/${userCount} have email`)}\n`; + summary += `${getIcon(fieldCoverage.emailConfirmed, userCount)} ${color.dim(`${fieldCoverage.emailConfirmed}/${userCount} email confirmed`)}\n`; + summary += `${getIcon(fieldCoverage.password, userCount)} ${color.dim(`${fieldCoverage.password}/${userCount} have password hash`)}\n`; + summary += `${getIcon(fieldCoverage.phone, userCount)} ${color.dim(`${fieldCoverage.phone}/${userCount} have phone`)}\n`; + summary += `${getIcon(fieldCoverage.firstName, userCount)} ${color.dim(`${fieldCoverage.firstName}/${userCount} have first name`)}\n`; + summary += `${getIcon(fieldCoverage.lastName, userCount)} ${color.dim(`${fieldCoverage.lastName}/${userCount} have last name`)}`; + + p.note(summary, 'Field Coverage'); + p.log.success( + `Exported ${color.bold(String(userCount))} users to ${color.dim(outputPath)}` + ); +} diff --git a/src/migrate/transformers/supabase.ts b/src/migrate/transformers/supabase.ts index bd92ea3..41c56d7 100644 --- a/src/migrate/transformers/supabase.ts +++ b/src/migrate/transformers/supabase.ts @@ -76,6 +76,23 @@ const supabaseTransformer = { } } + // Extract display_name from raw_user_meta_data → firstName/lastName + // This handles cases where the export doesn't have a separate first_name column + // (e.g., when using the Supabase admin API or a basic SQL export without COALESCE) + if (!user.firstName && user.publicMetadata) { + const meta = user.publicMetadata as Record; + const displayName = (meta.display_name ?? + meta.first_name ?? + meta.name) as string | undefined; + if (typeof displayName === 'string' && displayName.trim()) { + const parts = displayName.trim().split(/\s+/); + user.firstName = parts[0]; + if (parts.length > 1 && !user.lastName) { + user.lastName = parts.slice(1).join(' '); + } + } + } + // Clean up the emailConfirmedAt and phoneConfirmedAt fields as they aren't // part of our schema delete user.emailConfirmedAt; From 84beacfe065f9c54bcb1b008eb11ef430f9dd5aa Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Mon, 9 Feb 2026 10:59:26 -0600 Subject: [PATCH 2/9] Add OAuth provider pre-flight check for Supabase migrations Scans the export data for raw_app_meta_data.providers to detect which OAuth providers (Google, Apple, GitHub, etc.) users signed up with. Displays a warning with user counts per provider and prompts to confirm the corresponding social connections are enabled in the Clerk Dashboard. Users who signed up via an OAuth provider that isn't enabled in Clerk won't be able to sign back in after their bridged session expires. Also adds raw_app_meta_data to the auto-export SQL query so provider data is included by default. Co-Authored-By: Claude Opus 4.6 --- src/export/supabase.ts | 1 + src/migrate/cli.ts | 164 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 3 deletions(-) diff --git a/src/export/supabase.ts b/src/export/supabase.ts index 2ffdb99..1c130c0 100644 --- a/src/export/supabase.ts +++ b/src/export/supabase.ts @@ -36,6 +36,7 @@ const EXPORT_QUERY = ` ) as first_name, raw_user_meta_data->>'last_name' as last_name, raw_user_meta_data, + raw_app_meta_data, created_at FROM auth.users ORDER BY created_at diff --git a/src/migrate/cli.ts b/src/migrate/cli.ts index f065d01..dce6277 100644 --- a/src/migrate/cli.ts +++ b/src/migrate/cli.ts @@ -453,6 +453,142 @@ export async function displayPasswordAnalysis( return false; // All users have passwords, no need for skipPasswordRequirement } +// OAuth providers that need social connections enabled in Clerk +// Maps Supabase provider names to human-readable Clerk names +const OAUTH_PROVIDER_LABELS: Record = { + google: 'Google', + apple: 'Apple', + github: 'GitHub', + facebook: 'Facebook', + twitter: 'Twitter (X)', + discord: 'Discord', + spotify: 'Spotify', + slack: 'Slack', + twitch: 'Twitch', + linkedin: 'LinkedIn', + linkedin_oidc: 'LinkedIn', + bitbucket: 'Bitbucket', + gitlab: 'GitLab', + azure: 'Microsoft (Azure)', + kakao: 'Kakao', + notion: 'Notion', + zoom: 'Zoom', + keycloak: 'Keycloak', + figma: 'Figma', + dropbox: 'Dropbox', +}; + +type ProviderAnalysis = { + providers: Map; + totalUsersWithProviderData: number; +}; + +/** + * Scans user data for OAuth provider information from Supabase's raw_app_meta_data + * + * Supabase stores the authentication provider(s) in raw_app_meta_data: + * { "provider": "google", "providers": ["google"] } + * + * This data is only available if the export includes raw_app_meta_data + * (included by default in the auto-export command). + * + * @param users - Array of user objects (post-transform but pre-validation) + * @returns Provider analysis with counts per provider, or empty if no provider data found + */ +export function analyzeProviders( + users: Record[] +): ProviderAnalysis { + const providers = new Map(); + let totalUsersWithProviderData = 0; + + for (const user of users) { + const appMeta = user.raw_app_meta_data as + | Record + | undefined; + if (!appMeta) continue; + + totalUsersWithProviderData++; + + // Supabase stores providers as an array in raw_app_meta_data.providers + const userProviders = appMeta.providers as string[] | undefined; + const primaryProvider = appMeta.provider as string | undefined; + + if (Array.isArray(userProviders)) { + for (const provider of userProviders) { + providers.set(provider, (providers.get(provider) || 0) + 1); + } + } else if (primaryProvider) { + providers.set(primaryProvider, (providers.get(primaryProvider) || 0) + 1); + } + } + + return { providers, totalUsersWithProviderData }; +} + +/** + * Displays OAuth provider analysis and Dashboard configuration guidance + * + * Shows which OAuth providers are used by Supabase users and warns that + * these need to be enabled as social connections in Clerk. Users who signed + * up via an OAuth provider that isn't enabled in Clerk won't be able to + * sign back in after their bridged session expires. + * + * @param analysis - The provider analysis results + * @param totalUsers - Total number of users being migrated + * @returns true if OAuth providers were found and confirmation is needed, false otherwise + */ +export function displayProviderAnalysis( + analysis: ProviderAnalysis, + totalUsers: number +): boolean { + const { providers, totalUsersWithProviderData } = analysis; + + // No provider data available (export didn't include raw_app_meta_data) + if (totalUsersWithProviderData === 0) { + return false; + } + + // Filter to OAuth providers only (exclude "email" — that's password-based) + const oauthProviders = new Map(); + for (const [provider, count] of providers) { + if (provider !== 'email') { + oauthProviders.set(provider, count); + } + } + + // No OAuth providers found + if (oauthProviders.size === 0) { + return false; + } + + let message = ''; + + message += color.bold(color.whiteBright('OAuth Provider Analysis:\n\n')); + + // Sort by count descending + const sorted = [...oauthProviders.entries()].sort((a, b) => b[1] - a[1]); + + for (const [provider, count] of sorted) { + const label = OAUTH_PROVIDER_LABELS[provider] || provider; + const percentage = Math.round((count / totalUsers) * 100); + message += ` ${color.yellow('○')} ${color.bold(color.whiteBright(label))}: ${color.dim(`${count} user${count === 1 ? '' : 's'} (${percentage}%)`)}\n`; + } + + message += '\n'; + message += DASHBOARD_CONFIGURATION; + message += ` ${color.yellow('⚠')} ${color.whiteBright('Enable these as Social connections in Clerk Dashboard')}\n`; + message += color.dim( + ` Users who signed up via OAuth won't be able to sign back in\n` + ); + message += color.dim( + ` after their session expires unless the provider is enabled.` + ); + + p.note(message.trim(), 'Social Connections'); + + return true; +} + /** * Displays user model analysis (first/last name) and Dashboard configuration guidance * @@ -927,7 +1063,29 @@ export async function runCLI() { } } - // Step 7: Display user model analysis + // Step 7: Display OAuth provider analysis (Supabase-specific) + const providerAnalysis = analyzeProviders(filteredUsers); + const hasOAuthProviders = displayProviderAnalysis( + providerAnalysis, + analysis.totalUsers + ); + + if (hasOAuthProviders) { + const confirmProviders = await p.confirm({ + message: + 'Have you enabled these social connections in the Clerk Dashboard?', + initialValue: true, + }); + + if (p.isCancel(confirmProviders) || !confirmProviders) { + p.cancel( + 'Migration cancelled. Please enable the required social connections and try again.' + ); + process.exit(0); + } + } + + // Step 8: Display user model analysis (first/last name) const needsUserModelConfirmation = displayUserModelAnalysis(analysis); if (needsUserModelConfirmation) { @@ -945,7 +1103,7 @@ export async function runCLI() { } } - // Step 8: Display and confirm other field settings (if any) + // Step 9: Display and confirm other field settings (if any) const hasOtherFields = displayOtherFieldsAnalysis(analysis); if (hasOtherFields) { @@ -962,7 +1120,7 @@ export async function runCLI() { } } - // Step 9: Final confirmation + // Step 10: Final confirmation const beginMigration = await p.confirm({ message: 'Begin Migration?', initialValue: true, From f3de174db0f4359a347ea56f54eb0d1f8150ace1 Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Mon, 9 Feb 2026 11:04:04 -0600 Subject: [PATCH 3/9] Fetch OAuth providers from Supabase auth config instead of scanning users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the user-data scanning approach with a direct fetch to the Supabase auth settings endpoint (GET /auth/v1/settings). This is authoritative — shows exactly which providers are configured at the project level rather than inferring from user records. Checks NEXT_PUBLIC_SUPABASE_URL + NEXT_PUBLIC_SUPABASE_ANON_KEY env vars (falls back to SUPABASE_URL, SUPABASE_ANON_KEY, or SUPABASE_SERVICE_ROLE_KEY). Gracefully skips if env vars aren't set. Co-Authored-By: Claude Opus 4.6 --- src/migrate/cli.ts | 178 +++++++++++++++++++++------------------------ 1 file changed, 84 insertions(+), 94 deletions(-) diff --git a/src/migrate/cli.ts b/src/migrate/cli.ts index dce6277..96c95c0 100644 --- a/src/migrate/cli.ts +++ b/src/migrate/cli.ts @@ -453,8 +453,7 @@ export async function displayPasswordAnalysis( return false; // All users have passwords, no need for skipPasswordRequirement } -// OAuth providers that need social connections enabled in Clerk -// Maps Supabase provider names to human-readable Clerk names +// Maps Supabase provider keys to human-readable labels const OAUTH_PROVIDER_LABELS: Record = { google: 'Google', apple: 'Apple', @@ -464,9 +463,10 @@ const OAUTH_PROVIDER_LABELS: Record = { discord: 'Discord', spotify: 'Spotify', slack: 'Slack', + slack_oidc: 'Slack (OIDC)', twitch: 'Twitch', linkedin: 'LinkedIn', - linkedin_oidc: 'LinkedIn', + linkedin_oidc: 'LinkedIn (OIDC)', bitbucket: 'Bitbucket', gitlab: 'GitLab', azure: 'Microsoft (Azure)', @@ -475,103 +475,79 @@ const OAUTH_PROVIDER_LABELS: Record = { zoom: 'Zoom', keycloak: 'Keycloak', figma: 'Figma', - dropbox: 'Dropbox', + fly: 'Fly.io', + workos: 'WorkOS', + snapchat: 'Snapchat', }; -type ProviderAnalysis = { - providers: Map; - totalUsersWithProviderData: number; -}; +// Non-OAuth entries in the Supabase external config to ignore +const IGNORED_PROVIDERS = new Set(['email', 'phone', 'anonymous_users']); + +interface SupabaseAuthSettings { + external: Record; +} /** - * Scans user data for OAuth provider information from Supabase's raw_app_meta_data + * Fetches the Supabase project's auth settings to determine which OAuth providers are enabled. * - * Supabase stores the authentication provider(s) in raw_app_meta_data: - * { "provider": "google", "providers": ["google"] } + * Calls GET {supabaseUrl}/auth/v1/settings with the API key. This endpoint returns + * the `external` config object with a boolean for each provider (google, apple, etc.). * - * This data is only available if the export includes raw_app_meta_data - * (included by default in the auto-export command). - * - * @param users - Array of user objects (post-transform but pre-validation) - * @returns Provider analysis with counts per provider, or empty if no provider data found + * @param supabaseUrl - The Supabase project URL (e.g., https://xxx.supabase.co) + * @param apiKey - Any valid Supabase API key (anon or service role) + * @returns List of enabled OAuth provider keys, or null if the fetch failed */ -export function analyzeProviders( - users: Record[] -): ProviderAnalysis { - const providers = new Map(); - let totalUsersWithProviderData = 0; - - for (const user of users) { - const appMeta = user.raw_app_meta_data as - | Record - | undefined; - if (!appMeta) continue; - - totalUsersWithProviderData++; +export async function fetchSupabaseProviders( + supabaseUrl: string, + apiKey: string +): Promise { + try { + const url = `${supabaseUrl.replace(/\/$/, '')}/auth/v1/settings`; + const res = await fetch(url, { + headers: { apikey: apiKey }, + }); - // Supabase stores providers as an array in raw_app_meta_data.providers - const userProviders = appMeta.providers as string[] | undefined; - const primaryProvider = appMeta.provider as string | undefined; + if (!res.ok) { + return null; + } - if (Array.isArray(userProviders)) { - for (const provider of userProviders) { - providers.set(provider, (providers.get(provider) || 0) + 1); - } - } else if (primaryProvider) { - providers.set(primaryProvider, (providers.get(primaryProvider) || 0) + 1); + const settings = (await res.json()) as SupabaseAuthSettings; + if (!settings.external) { + return null; } - } - return { providers, totalUsersWithProviderData }; + return Object.entries(settings.external) + .filter(([key, enabled]) => enabled && !IGNORED_PROVIDERS.has(key)) + .map(([key]) => key); + } catch { + return null; + } } /** - * Displays OAuth provider analysis and Dashboard configuration guidance + * Displays which OAuth providers are enabled on the Supabase project and warns + * that these need to be configured as social connections in Clerk. * - * Shows which OAuth providers are used by Supabase users and warns that - * these need to be enabled as social connections in Clerk. Users who signed - * up via an OAuth provider that isn't enabled in Clerk won't be able to - * sign back in after their bridged session expires. + * Users who signed up via an OAuth provider that isn't enabled in Clerk won't + * be able to sign back in after their bridged session expires. * - * @param analysis - The provider analysis results - * @param totalUsers - Total number of users being migrated - * @returns true if OAuth providers were found and confirmation is needed, false otherwise + * @param enabledProviders - List of enabled OAuth provider keys from Supabase + * @returns true if OAuth providers were found and confirmation is needed */ -export function displayProviderAnalysis( - analysis: ProviderAnalysis, - totalUsers: number -): boolean { - const { providers, totalUsersWithProviderData } = analysis; - - // No provider data available (export didn't include raw_app_meta_data) - if (totalUsersWithProviderData === 0) { - return false; - } - - // Filter to OAuth providers only (exclude "email" — that's password-based) - const oauthProviders = new Map(); - for (const [provider, count] of providers) { - if (provider !== 'email') { - oauthProviders.set(provider, count); - } - } - - // No OAuth providers found - if (oauthProviders.size === 0) { +export function displayProviderAnalysis(enabledProviders: string[]): boolean { + if (enabledProviders.length === 0) { return false; } let message = ''; - message += color.bold(color.whiteBright('OAuth Provider Analysis:\n\n')); - - // Sort by count descending - const sorted = [...oauthProviders.entries()].sort((a, b) => b[1] - a[1]); + message += color.bold( + color.whiteBright('Enabled OAuth Providers in Supabase:\n\n') + ); - for (const [provider, count] of sorted) { + for (const provider of enabledProviders) { const label = OAUTH_PROVIDER_LABELS[provider] || provider; - const percentage = Math.round((count / totalUsers) * 100); - message += ` ${color.yellow('○')} ${color.bold(color.whiteBright(label))}: ${color.dim(`${count} user${count === 1 ? '' : 's'} (${percentage}%)`)}\n`; + message += ` ${color.yellow('○')} ${color.bold(color.whiteBright(label))}\n`; } message += '\n'; @@ -1063,25 +1039,39 @@ export async function runCLI() { } } - // Step 7: Display OAuth provider analysis (Supabase-specific) - const providerAnalysis = analyzeProviders(filteredUsers); - const hasOAuthProviders = displayProviderAnalysis( - providerAnalysis, - analysis.totalUsers - ); - - if (hasOAuthProviders) { - const confirmProviders = await p.confirm({ - message: - 'Have you enabled these social connections in the Clerk Dashboard?', - initialValue: true, - }); - - if (p.isCancel(confirmProviders) || !confirmProviders) { - p.cancel( - 'Migration cancelled. Please enable the required social connections and try again.' + // Step 7: Check Supabase OAuth providers (Supabase-specific) + if (initialArgs.key === 'supabase') { + const supabaseUrl = + process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL; + const supabaseApiKey = + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || + process.env.SUPABASE_ANON_KEY || + process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (supabaseUrl && supabaseApiKey) { + const enabledProviders = await fetchSupabaseProviders( + supabaseUrl, + supabaseApiKey ); - process.exit(0); + + if (enabledProviders) { + const hasOAuthProviders = displayProviderAnalysis(enabledProviders); + + if (hasOAuthProviders) { + const confirmProviders = await p.confirm({ + message: + 'Have you enabled these social connections in the Clerk Dashboard?', + initialValue: true, + }); + + if (p.isCancel(confirmProviders) || !confirmProviders) { + p.cancel( + 'Migration cancelled. Please enable the required social connections and try again.' + ); + process.exit(0); + } + } + } } } From 1d8e3efc8864d4a97d557ec3a83b57d49ca4e9a1 Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Mon, 9 Feb 2026 16:12:46 -0600 Subject: [PATCH 4/9] Replace manual config confirmations with automated cross-reference readiness check Instead of 5+ separate "have you configured X?" prompts, the CLI now automatically fetches both the Supabase auth config and Clerk instance config (via FAPI /v1/environment), cross-references them with user data, and displays a single unified readiness report. Users with disabled social providers are excluded from import. Co-Authored-By: Claude Opus 4.6 --- bun.lock | 110 ++++ package.json | 2 + src/export/index.ts | 12 + src/migrate/cli.ts | 740 ++++++++++++++------------- src/migrate/index.ts | 16 + src/migrate/transformers/supabase.ts | 27 +- tests/migrate/cli.test.ts | 521 +++++++++---------- 7 files changed, 789 insertions(+), 639 deletions(-) diff --git a/bun.lock b/bun.lock index cde4f70..855b0f2 100644 --- a/bun.lock +++ b/bun.lock @@ -6,10 +6,12 @@ "dependencies": { "@clack/prompts": "^1.0.0-alpha.9", "@clerk/backend": "^2.29.3", + "@clerk/nextjs": "^6.37.3", "@clerk/types": "^4.101.11", "bun": "^1.3.6", "csv-parser": "^3.2.0", "dotenv": "16.6.1", + "jose": "^6.1.3", "mime-types": "^3.0.2", "p-limit": "^7.2.0", "pg": "^8.18.0", @@ -39,10 +41,16 @@ "@clerk/backend": ["@clerk/backend@2.29.3", "", { "dependencies": { "@clerk/shared": "^3.43.0", "@clerk/types": "^4.101.11", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-BLepnFJRsnkqqXu2a79pgbzZz+veecB2bqMrqcmzLl+nBdUPPdeCTRazcmIifKB/424nyT8eX9ADqOz5iySoug=="], + "@clerk/clerk-react": ["@clerk/clerk-react@5.60.0", "", { "dependencies": { "@clerk/shared": "^3.44.0", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "sha512-P88FncsJpq/3WZJhhlj+md8mYb35BIXpr462C/figwsBGHsinr8VuBQUMcMZZ/6M34C8ABfLTPa6PHVp6+3D5Q=="], + + "@clerk/nextjs": ["@clerk/nextjs@6.37.3", "", { "dependencies": { "@clerk/backend": "^2.30.1", "@clerk/clerk-react": "^5.60.0", "@clerk/shared": "^3.44.0", "@clerk/types": "^4.101.14", "server-only": "0.0.1", "tslib": "2.8.1" }, "peerDependencies": { "next": "^13.5.7 || ^14.2.25 || ^15.2.3 || ^16", "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "sha512-kammmf4b5R2Izb/SN4UbEa/6rdyop9fPHwZkyyJoVfgMLFM26fwpXWaSqVJPe4YL2BmHKP+orIOolzTmEhhdQQ=="], + "@clerk/shared": ["@clerk/shared@3.43.0", "", { "dependencies": { "csstype": "3.1.3", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0", "swr": "2.3.4" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-pj8jgV5TX7l0ClHMvDLG7Ensp1BwA63LNvOE2uLwRV4bx3j9s4oGHy5bZlLBoOxdvRPCMpQksHi/O0x1Y+obdw=="], "@clerk/types": ["@clerk/types@4.101.11", "", { "dependencies": { "@clerk/shared": "^3.43.0" } }, "sha512-6m1FQSLFqb4L+ovMDxNIRSrw6I0ByVX5hs6slcevOaaD5UXNzSANWqVtKaU80AZwcm391lZqVS5fRisHt9tmXA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], @@ -121,8 +129,76 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-27rypIapNkYboOSylkf1tD9UW9Ado2I+P1NBL46Qz29KmOjTL6WuJ7mHDC5O66CYxlOkF5r93NPDAC3lFHYBXw=="], "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-I82xGzPkBxzBKgbl8DsA0RfMQCWTWjNmLjIEkW1ECiv3qK02kHGQ5FGUr/29L/SuvnGsULW4tBTRNZiMzL37nA=="], @@ -201,6 +277,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -269,6 +347,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -279,6 +359,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -287,6 +369,8 @@ "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -309,6 +393,8 @@ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -399,6 +485,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -447,6 +535,8 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -507,6 +597,8 @@ "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -515,8 +607,14 @@ "rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -547,6 +645,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "swr": ["swr@2.3.4", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg=="], @@ -597,6 +697,14 @@ "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "@clerk/clerk-react/@clerk/shared": ["@clerk/shared@3.44.0", "", { "dependencies": { "csstype": "3.1.3", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0", "swr": "2.3.4" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-kH+chNeZwqml3IDpWLgebWECfOZifyUQO4OISd/96w1EuCY1Bzw6cBq/ZbpsoO8jyG8/6bGr/MGXLhDzTrpPfA=="], + + "@clerk/nextjs/@clerk/backend": ["@clerk/backend@2.30.1", "", { "dependencies": { "@clerk/shared": "^3.44.0", "@clerk/types": "^4.101.14", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-GoxnJzVH0ycNPAGCDMfo3lPBFbo5nehpLSVFjgGEnzIRGGahBtAB8PQT7KM2zo58pD8apjb/+suhcB/WCiEasQ=="], + + "@clerk/nextjs/@clerk/shared": ["@clerk/shared@3.44.0", "", { "dependencies": { "csstype": "3.1.3", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0", "swr": "2.3.4" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-kH+chNeZwqml3IDpWLgebWECfOZifyUQO4OISd/96w1EuCY1Bzw6cBq/ZbpsoO8jyG8/6bGr/MGXLhDzTrpPfA=="], + + "@clerk/nextjs/@clerk/types": ["@clerk/types@4.101.14", "", { "dependencies": { "@clerk/shared": "^3.44.0" } }, "sha512-jl7DywmeaZx1IntgEXcjDZq2uyk+X/1yAZOjxOboeGTS0rNTiQNhv7xK8tFVjexsUAFrYlwC1AxhFuJiMDQjow=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -607,6 +715,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/package.json b/package.json index 922f267..a10057a 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,12 @@ "dependencies": { "@clack/prompts": "^1.0.0-alpha.9", "@clerk/backend": "^2.29.3", + "@clerk/nextjs": "^6.37.3", "@clerk/types": "^4.101.11", "bun": "^1.3.6", "csv-parser": "^3.2.0", "dotenv": "16.6.1", + "jose": "^6.1.3", "mime-types": "^3.0.2", "p-limit": "^7.2.0", "pg": "^8.18.0", diff --git a/src/export/index.ts b/src/export/index.ts index 9737da7..d5279f4 100644 --- a/src/export/index.ts +++ b/src/export/index.ts @@ -36,6 +36,18 @@ async function main() { // Prompt for DB URL if not provided if (!dbUrl) { + p.note( + `Find this in the Supabase Dashboard by clicking the ${color.bold('Connect')} button.\n\n` + + `${color.bold('Direct connection')} (requires IPv6):\n` + + ` ${color.dim('postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres')}\n\n` + + `${color.bold('Pooler connection')} (works on IPv4 — use this if direct fails):\n` + + ` ${color.dim('postgres://postgres.[REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres')}\n\n` + + color.dim( + 'Alternatively, run the export SQL in the Supabase SQL Editor and save the result as JSON.' + ), + 'Connection String' + ); + const input = await p.text({ message: 'Enter your Supabase Postgres connection string', placeholder: diff --git a/src/migrate/cli.ts b/src/migrate/cli.ts index 96c95c0..e6497fc 100644 --- a/src/migrate/cli.ts +++ b/src/migrate/cli.ts @@ -29,10 +29,6 @@ type Settings = { const DEV_USER_LIMIT = 500; -const DASHBOARD_CONFIGURATION = color.bold( - color.whiteBright('Dashboard Configuration:\n') -); - /** * Detects whether the Clerk instance is development or production based on the secret key * @@ -285,174 +281,6 @@ export function analyzeFields(users: Record[]): FieldAnalysis { return { presentOnAll, presentOnSome, identifiers, totalUsers, fieldCounts }; } -/** - * Formats a count statistic into a human-readable string - * - * @param count - The number of users who have the field - * @param total - The total number of users - * @param label - The label for the field - * @returns A formatted string like "All users have...", "No users have...", or "X of Y users have..." - */ -export function formatCount( - count: number, - total: number, - label: string -): string { - if (count === total) { - return `All users have ${label}`; - } else if (count === 0) { - return `No users have ${label}`; - } - return `${count} of ${total} users have ${label}`; -} - -/** - * Displays identifier analysis and Dashboard configuration guidance - * - * Shows: - * - Count of users with each identifier type (verified emails, verified phones, usernames) - * - Count of users with unverified identifiers (if any) - * - Whether all users have at least one valid identifier - * - Dashboard configuration recommendations (required vs optional identifiers) - * - * Uses color coding: green for complete coverage, yellow for partial, red for missing. - * - * @param analysis - The field analysis results - */ -export function displayIdentifierAnalysis(analysis: FieldAnalysis): void { - const { identifiers, totalUsers } = analysis; - - let identifierMessage = ''; - - // Show counts for each identifier type - identifierMessage += color.bold(color.whiteBright('Identifier Analysis:\n')); - - // Helper to get the correct icon based on coverage - const getIcon = (count: number, total: number): string => { - if (count === total) return color.bold(color.greenBright('●')); - if (count > 0) return color.bold(color.yellowBright('○')); - return color.red('○'); - }; - - identifierMessage += ` ${getIcon(identifiers.verifiedEmails, totalUsers)} ${color.dim(formatCount(identifiers.verifiedEmails, totalUsers, 'verified emails'))}\n`; - identifierMessage += ` ${getIcon(identifiers.verifiedPhones, totalUsers)} ${color.dim(formatCount(identifiers.verifiedPhones, totalUsers, 'verified phone numbers'))}\n`; - identifierMessage += ` ${getIcon(identifiers.username, totalUsers)} ${color.dim(formatCount(identifiers.username, totalUsers, 'a username'))}\n`; - - // Show unverified counts if present - if (identifiers.unverifiedEmails > 0) { - identifierMessage += ` ${getIcon(identifiers.unverifiedEmails, totalUsers)} ${color.dim(formatCount(identifiers.unverifiedEmails, totalUsers, 'unverified emails'))}\n`; - } - if (identifiers.unverifiedPhones > 0) { - identifierMessage += ` ${getIcon(identifiers.unverifiedPhones, totalUsers)} ${color.dim(formatCount(identifiers.unverifiedPhones, totalUsers, 'unverified phone numbers'))}\n`; - } - - // Check if all users have at least one identifier - identifierMessage += '\n'; - if (identifiers.hasAnyIdentifier === totalUsers) { - identifierMessage += color.green( - 'All users have at least one identifier (verified email, verified phone, or username).\n' - ); - } else { - const missing = totalUsers - identifiers.hasAnyIdentifier; - identifierMessage += color.red( - `${missing} user${missing === 1 ? ' does' : 's do'} not have a verified email, verified phone, or username.\n` - ); - identifierMessage += color.red('These users cannot be imported.\n'); - } - - // Dashboard configuration advice - identifierMessage += '\n'; - identifierMessage += DASHBOARD_CONFIGURATION; - - const requiredIdentifiers: string[] = []; - const optionalIdentifiers: string[] = []; - - // Only consider users that will actually be imported (have at least one identifier) - const importableUsers = identifiers.hasAnyIdentifier; - - if (identifiers.verifiedEmails === importableUsers) { - requiredIdentifiers.push('Email'); - } else if (identifiers.verifiedEmails > 0) { - optionalIdentifiers.push('Email'); - } - - if (identifiers.verifiedPhones === importableUsers) { - requiredIdentifiers.push('Phone'); - } else if (identifiers.verifiedPhones > 0) { - optionalIdentifiers.push('Phone'); - } - - if (identifiers.username === importableUsers) { - requiredIdentifiers.push('Username'); - } else if (identifiers.username > 0) { - optionalIdentifiers.push('Username'); - } - - if (requiredIdentifiers.length > 0) { - identifierMessage += ` ${color.green('●')} ${color.bold(color.whiteBright(requiredIdentifiers.join(', ')))}: ${color.dim('Enable and optionally require in the Dashboard')}\n`; - } - if (optionalIdentifiers.length > 0) { - identifierMessage += ` ${color.yellow('○')} ${color.bold(color.whiteBright(optionalIdentifiers.join(', ')))}: Enable in the Dashboard but do not require\n`; - } - - p.note(identifierMessage.trim(), 'Identifiers'); -} - -/** - * Displays password analysis and prompts for migration preference - * - * Shows how many users have passwords and provides Dashboard configuration guidance. - * If some users lack passwords, prompts whether to migrate those users anyway. - * If no users have passwords, returns immediately without displaying anything. - * - * @param analysis - The field analysis results - * @returns true if users without passwords should be migrated (skipPasswordRequirement), - * false if all users have passwords, - * null if the user cancelled - */ -export async function displayPasswordAnalysis( - analysis: FieldAnalysis -): Promise { - const { totalUsers, fieldCounts } = analysis; - const usersWithPasswords = fieldCounts.password || 0; - - // If no users have passwords, show message and skip password section - if (usersWithPasswords === 0) { - p.note(`${color.dim('○')} No users have passwords`, 'Password'); - return true; - } - - let passwordMessage = ''; - - if (usersWithPasswords === totalUsers) { - passwordMessage += `${color.green('●')} All users have passwords\n`; - } else { - passwordMessage += `${color.yellow('○')} ${usersWithPasswords} of ${totalUsers} users have passwords\n`; - } - - passwordMessage += '\n'; - passwordMessage += DASHBOARD_CONFIGURATION; - passwordMessage += ` ${color.green('●')} ${color.bold(color.whiteBright('Password'))}: Enable in Dashboard\n`; - - p.note(passwordMessage.trim(), 'Password'); - - // Ask if user wants to migrate users without passwords - if (usersWithPasswords < totalUsers) { - const migrateWithoutPassword = await p.confirm({ - message: "Do you want to migrate users who don't have a password?", - initialValue: true, - }); - - if (p.isCancel(migrateWithoutPassword)) { - return null; // User cancelled - } - - return migrateWithoutPassword; - } - - return false; // All users have passwords, no need for skipPasswordRequirement -} - // Maps Supabase provider keys to human-readable labels const OAUTH_PROVIDER_LABELS: Record = { google: 'Google', @@ -524,145 +352,250 @@ export async function fetchSupabaseProviders( } } +// --- Clerk Instance Configuration --- + +interface ClerkConfig { + attributes: Record; + social: Record; +} + /** - * Displays which OAuth providers are enabled on the Supabase project and warns - * that these need to be configured as social connections in Clerk. + * Decodes a Clerk publishable key to extract the frontend API hostname. * - * Users who signed up via an OAuth provider that isn't enabled in Clerk won't - * be able to sign back in after their bridged session expires. + * Format: pk_test_ or pk_live_ + * The base64 payload decodes to a hostname ending with '$'. * - * @param enabledProviders - List of enabled OAuth provider keys from Supabase - * @returns true if OAuth providers were found and confirmation is needed + * @param key - The Clerk publishable key + * @returns The frontend API hostname, or null if decoding fails */ -export function displayProviderAnalysis(enabledProviders: string[]): boolean { - if (enabledProviders.length === 0) { - return false; +function decodePublishableKey(key: string): string | null { + if (!key.startsWith('pk_test_') && !key.startsWith('pk_live_')) { + return null; + } + try { + const base64Part = key.split('_')[2]; + const decoded = Buffer.from(base64Part, 'base64').toString(); + if (!decoded.endsWith('$') || !decoded.includes('.')) { + return null; + } + return decoded.slice(0, -1); + } catch { + return null; } +} - let message = ''; +/** + * Fetches the Clerk instance configuration via the Frontend API. + * + * Decodes the publishable key to derive the FAPI hostname, then calls + * GET /v1/environment to retrieve auth settings, social connections, + * and user model configuration. + * + * @param publishableKey - The Clerk publishable key (pk_test_... or pk_live_...) + * @returns Clerk configuration with attributes and social connections, or null on failure + */ +export async function fetchClerkConfig( + publishableKey: string +): Promise { + const frontendApi = decodePublishableKey(publishableKey); + if (!frontendApi) return null; - message += color.bold( - color.whiteBright('Enabled OAuth Providers in Supabase:\n\n') - ); + try { + const res = await fetch(`https://${frontendApi}/v1/environment`); + if (!res.ok) return null; + + const data = (await res.json()) as { + user_settings?: { + attributes?: Record; + social?: Record; + }; + }; + const userSettings = data?.user_settings; + if (!userSettings) return null; - for (const provider of enabledProviders) { - const label = OAUTH_PROVIDER_LABELS[provider] || provider; - message += ` ${color.yellow('○')} ${color.bold(color.whiteBright(label))}\n`; + return { + attributes: userSettings.attributes || {}, + social: userSettings.social || {}, + }; + } catch { + return null; } +} - message += '\n'; - message += DASHBOARD_CONFIGURATION; - message += ` ${color.yellow('⚠')} ${color.whiteBright('Enable these as Social connections in Clerk Dashboard')}\n`; - message += color.dim( - ` Users who signed up via OAuth won't be able to sign back in\n` - ); - message += color.dim( - ` after their session expires unless the provider is enabled.` - ); - - p.note(message.trim(), 'Social Connections'); +/** + * Analyzes the raw export data to count users per auth provider. + * + * Reads raw_app_meta_data.providers from each user record in the JSON file. + * This runs on the raw (pre-transformation) data since the transformer + * doesn't map raw_app_meta_data. + * + * @param filePath - Path to the JSON export file + * @returns Map of provider name to user count (e.g., { email: 142, discord: 5 }) + */ +export function analyzeUserProviders(filePath: string): Record { + try { + const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record< + string, + unknown + >[]; + const counts: Record = {}; + + for (const user of raw) { + const appMeta = user.raw_app_meta_data as + | Record + | undefined; + if (!appMeta?.providers) continue; + + const providers = appMeta.providers as string[]; + for (const provider of providers) { + counts[provider] = (counts[provider] || 0) + 1; + } + } - return true; + return counts; + } catch { + return {}; + } } /** - * Displays user model analysis (first/last name) and Dashboard configuration guidance + * Finds user IDs that have any of the specified disabled social providers. * - * Shows how many users have first and last names and provides recommendations - * for Dashboard configuration (required vs optional vs disabled). + * Reads the raw export file and checks each user's raw_app_meta_data.providers. + * If a user has any provider in the disabled list, their ID is included in the result. * - * @param analysis - The field analysis results - * @returns true if users have name data and confirmation is needed, false otherwise + * @param filePath - Path to the JSON export file + * @param disabledProviders - List of provider names not enabled in Clerk (e.g., ['discord']) + * @returns Set of user IDs to exclude from import */ -export const displayUserModelAnalysis = (analysis: FieldAnalysis): boolean => { - const { totalUsers, fieldCounts } = analysis; - const usersWithFirstName = fieldCounts.firstName || 0; - const usersWithLastName = fieldCounts.lastName || 0; - - // Count users who have BOTH first and last name - const usersWithBothNames = Math.min(usersWithFirstName, usersWithLastName); - const someUsersHaveNames = usersWithFirstName > 0 || usersWithLastName > 0; - const noUsersHaveNames = usersWithFirstName === 0 && usersWithLastName === 0; - - let nameMessage = ''; - - // Show combined first and last name stats - if (usersWithBothNames === totalUsers) { - nameMessage += `${color.green('●')} All users have first and last names\n`; - } else if (someUsersHaveNames && !noUsersHaveNames) { - nameMessage += `${color.yellow('○')} Some users have first and last names\n`; - } else { - nameMessage += `${color.dim('○')} No users have first and last names\n`; - } +export function findUsersWithDisabledProviders( + filePath: string, + disabledProviders: string[] +): Set { + if (disabledProviders.length === 0) return new Set(); - nameMessage += '\n'; - nameMessage += DASHBOARD_CONFIGURATION; + try { + const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record< + string, + unknown + >[]; + const excluded = new Set(); + const disabledSet = new Set(disabledProviders); + + for (const user of raw) { + const appMeta = user.raw_app_meta_data as + | Record + | undefined; + if (!appMeta?.providers) continue; + + const providers = appMeta.providers as string[]; + if (providers.some((p) => disabledSet.has(p))) { + excluded.add(user.id as string); + } + } - if (usersWithBothNames === totalUsers) { - nameMessage += ` ${color.green('●')} ${color.bold(color.whiteBright('First and last name'))}: Must be enabled in the Dashboard and could be required\n`; - } else if (someUsersHaveNames) { - nameMessage += ` ${color.yellow('○')} ${color.bold(color.whiteBright('First and last name'))}: Must be enabled in the Dashboard but not required\n`; - } else { - nameMessage += ` ${color.dim('○')} ${color.bold(color.whiteBright('First and last name'))}: Could be enabled or disabled in the Dashboard but cannot be required\n`; + return excluded; + } catch { + return new Set(); } +} - p.note(nameMessage.trim(), 'User Model'); +// --- Cross-Reference Display --- - // Return true if confirmation is needed (when users have name data) - return someUsersHaveNames; -}; +interface ReadinessItem { + label: string; + userCount: number; + clerkEnabled: boolean | null; // null = Clerk config not available + section: 'identifiers' | 'auth' | 'social' | 'model'; +} /** - * Displays analysis of other fields (excluding identifiers, password, and names) + * Displays a unified migration readiness report. * - * Shows fields like TOTP Secret that are present on all or some users, - * with Dashboard configuration guidance. + * Cross-references the Supabase auth config, Clerk instance config, and user + * data to show a single report with all configuration items, their status, + * and affected user counts. * - * @param analysis - The field analysis results - * @returns true if there are other fields to display, false otherwise + * Items are grouped by section (Identifiers, Authentication, Social Connections, + * User Model) and each shows: + * - ✓ enabled in Clerk (green) + * - ✗ not enabled in Clerk (red, needs attention) + * - ○ status unknown, enable in Dashboard (yellow, no Clerk config available) + * + * @param items - List of readiness items to display + * @param analysis - Field analysis results for total user count and identifier check */ -export const displayOtherFieldsAnalysis = ( +export function displayCrossReference( + items: ReadinessItem[], analysis: FieldAnalysis -): boolean => { - // Filter out password, firstName, and lastName since they have dedicated sections - const excludedFields = ['Password', 'First Name', 'Last Name']; - const filteredPresentOnAll = analysis.presentOnAll.filter( - (f) => !excludedFields.includes(f) - ); - const filteredPresentOnSome = analysis.presentOnSome.filter( - (f) => !excludedFields.includes(f) - ); +): void { + const sections: Record = {}; + for (const item of items) { + if (!sections[item.section]) sections[item.section] = []; + sections[item.section].push(item); + } + + let message = ''; + const needsAttention: ReadinessItem[] = []; - let fieldsMessage = ''; + const sectionLabels: Record = { + identifiers: 'Identifiers', + auth: 'Authentication', + social: 'Social Connections', + model: 'User Model', + }; - if (filteredPresentOnAll.length > 0) { - fieldsMessage += color.bold('Fields present on ALL users:\n'); - fieldsMessage += color.dim( - 'These fields must be enabled in the Clerk Dashboard and could be set as required.' - ); - for (const field of filteredPresentOnAll) { - fieldsMessage += `\n ${color.green('●')} ${color.reset(field)}`; + const sectionOrder = ['identifiers', 'auth', 'social', 'model']; + + for (const section of sectionOrder) { + const sectionItems = sections[section]; + if (!sectionItems || sectionItems.length === 0) continue; + + message += color.bold(color.whiteBright(`${sectionLabels[section]}\n`)); + + for (const item of sectionItems) { + const countStr = + item.userCount === analysis.totalUsers + ? 'all users' + : `${item.userCount} user${item.userCount === 1 ? '' : 's'}`; + + if (item.clerkEnabled === true) { + message += ` ${color.green('✓')} ${item.label} — ${color.dim(`enabled in Clerk — ${countStr}`)}\n`; + } else if (item.clerkEnabled === false) { + message += ` ${color.red('✗')} ${item.label} — ${color.red('not enabled in Clerk')} — ${color.dim(countStr)}\n`; + needsAttention.push(item); + } else { + message += ` ${color.yellow('○')} ${item.label} — ${color.dim(`${countStr} — enable in Clerk Dashboard`)}\n`; + } } + + message += '\n'; } - if (filteredPresentOnSome.length > 0) { - if (fieldsMessage) fieldsMessage += '\n\n'; - fieldsMessage += color.bold('Fields present on SOME users:\n'); - fieldsMessage += color.dim( - 'These fields must be enabled in the Clerk Dashboard but must be set as optional.' + // Check for users without any identifier + if (analysis.identifiers.hasAnyIdentifier < analysis.totalUsers) { + const missing = analysis.totalUsers - analysis.identifiers.hasAnyIdentifier; + message += color.red( + `⚠ ${missing} user${missing === 1 ? '' : 's'} without any identifier (cannot be imported)\n\n` ); - for (const field of filteredPresentOnSome) { - fieldsMessage += `\n ${color.yellow('○')} ${color.reset(field)}`; - } } - if (fieldsMessage) { - p.note(fieldsMessage.trim(), 'Other Fields'); - return true; + // Summary + if (needsAttention.length > 0) { + const totalAffected = needsAttention.reduce( + (sum, item) => sum + item.userCount, + 0 + ); + message += color.yellow( + `⚠ ${needsAttention.length} setting${needsAttention.length === 1 ? '' : 's'} need${needsAttention.length === 1 ? 's' : ''} attention` + + (totalAffected > 0 ? ` (up to ${totalAffected} users affected)` : '') + ); + } else if (items.some((i) => i.clerkEnabled !== null)) { + message += color.green('All settings are configured in Clerk'); } - return false; -}; + p.note(message.trim(), 'Migration Readiness'); +} /** * Handles Firebase hash configuration collection and validation @@ -992,9 +925,6 @@ export async function runCLI() { } } - // Step 5: Display and confirm identifier settings - displayIdentifierAnalysis(analysis); - // Exit if no users have valid identifiers if (analysis.identifiers.hasAnyIdentifier === 0) { p.cancel( @@ -1003,117 +933,197 @@ export async function runCLI() { process.exit(1); } - const confirmIdentifiers = await p.confirm({ - message: 'Have you configured the identifier settings in the Dashboard?', - initialValue: true, - }); - - if (p.isCancel(confirmIdentifiers) || !confirmIdentifiers) { - p.cancel( - 'Migration cancelled. Please configure identifier settings and try again.' - ); - process.exit(0); + // Step 5: Fetch configurations for cross-reference + const isSupabase = initialArgs.key === 'supabase'; + + const publishableKey = + process.env.CLERK_PUBLISHABLE_KEY || + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + const supabaseUrl = + process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL; + const supabaseApiKey = + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || + process.env.SUPABASE_ANON_KEY || + process.env.SUPABASE_SERVICE_ROLE_KEY; + + const configSpinner = p.spinner(); + configSpinner.start('Checking configuration...'); + + const [clerkConfig, supabaseProviders] = await Promise.all([ + publishableKey ? fetchClerkConfig(publishableKey) : Promise.resolve(null), + isSupabase && supabaseUrl && supabaseApiKey + ? fetchSupabaseProviders(supabaseUrl, supabaseApiKey) + : Promise.resolve(null), + ]); + + // Analyze raw provider counts (Supabase only, from raw export data) + let providerCounts: Record = {}; + if (isSupabase) { + const filePath = createImportFilePath(initialArgs.file); + providerCounts = analyzeUserProviders(filePath); } - // Step 6: Display password analysis and get migration preference - const skipPasswordRequirement = await displayPasswordAnalysis(analysis); + configSpinner.stop('Configuration checked'); - if (skipPasswordRequirement === null) { - p.cancel('Migration cancelled.'); - process.exit(0); - } + // Step 6: Build cross-reference items + const items: ReadinessItem[] = []; - // Only show password confirmation if users have passwords - const usersWithPasswords = analysis.fieldCounts.password || 0; - if (usersWithPasswords > 0) { - const confirmPassword = await p.confirm({ - message: 'Have you enabled Password in the Dashboard?', - initialValue: true, + // Identifiers + const emailCount = + analysis.identifiers.verifiedEmails + analysis.identifiers.unverifiedEmails; + if (emailCount > 0) { + items.push({ + label: 'Email', + userCount: emailCount, + clerkEnabled: clerkConfig?.attributes.email_address?.enabled ?? null, + section: 'identifiers', }); + } - if (p.isCancel(confirmPassword) || !confirmPassword) { - p.cancel( - 'Migration cancelled. Please enable Password in the Dashboard and try again.' - ); - process.exit(0); - } + const phoneCount = + analysis.identifiers.verifiedPhones + analysis.identifiers.unverifiedPhones; + if (phoneCount > 0) { + items.push({ + label: 'Phone', + userCount: phoneCount, + clerkEnabled: clerkConfig?.attributes.phone_number?.enabled ?? null, + section: 'identifiers', + }); } - // Step 7: Check Supabase OAuth providers (Supabase-specific) - if (initialArgs.key === 'supabase') { - const supabaseUrl = - process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL; - const supabaseApiKey = - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || - process.env.SUPABASE_ANON_KEY || - process.env.SUPABASE_SERVICE_ROLE_KEY; + if (analysis.identifiers.username > 0) { + items.push({ + label: 'Username', + userCount: analysis.identifiers.username, + clerkEnabled: clerkConfig?.attributes.username?.enabled ?? null, + section: 'identifiers', + }); + } - if (supabaseUrl && supabaseApiKey) { - const enabledProviders = await fetchSupabaseProviders( - supabaseUrl, - supabaseApiKey - ); + // Authentication + const passwordCount = analysis.fieldCounts.password || 0; + if (passwordCount > 0) { + items.push({ + label: 'Password', + userCount: passwordCount, + clerkEnabled: clerkConfig?.attributes.password?.enabled ?? null, + section: 'auth', + }); + } - if (enabledProviders) { - const hasOAuthProviders = displayProviderAnalysis(enabledProviders); - - if (hasOAuthProviders) { - const confirmProviders = await p.confirm({ - message: - 'Have you enabled these social connections in the Clerk Dashboard?', - initialValue: true, - }); - - if (p.isCancel(confirmProviders) || !confirmProviders) { - p.cancel( - 'Migration cancelled. Please enable the required social connections and try again.' - ); - process.exit(0); - } - } + // Social connections (from Supabase config) + const disabledProviders: string[] = []; + if (supabaseProviders) { + for (const provider of supabaseProviders) { + const clerkKey = `oauth_${provider}`; + const clerkEnabled = clerkConfig + ? (clerkConfig.social[clerkKey]?.enabled ?? false) + : null; + + items.push({ + label: OAUTH_PROVIDER_LABELS[provider] || provider, + userCount: providerCounts[provider] || 0, + clerkEnabled, + section: 'social', + }); + + if (clerkEnabled === false) { + disabledProviders.push(provider); } } } - // Step 8: Display user model analysis (first/last name) - const needsUserModelConfirmation = displayUserModelAnalysis(analysis); + // Find users to exclude (those with disabled social providers) + let excludedUserIds = new Set(); + if (isSupabase && disabledProviders.length > 0) { + const filePath = createImportFilePath(initialArgs.file); + excludedUserIds = findUsersWithDisabledProviders( + filePath, + disabledProviders + ); + } - if (needsUserModelConfirmation) { - const confirmUserModel = await p.confirm({ - message: - 'Have you configured first and last name settings in the Dashboard?', - initialValue: true, + // User model + const firstNameCount = analysis.fieldCounts.firstName || 0; + if (firstNameCount > 0) { + items.push({ + label: 'First Name', + userCount: firstNameCount, + clerkEnabled: clerkConfig?.attributes.first_name?.enabled ?? null, + section: 'model', }); + } - if (p.isCancel(confirmUserModel) || !confirmUserModel) { - p.cancel( - 'Migration cancelled. Please configure user model settings and try again.' - ); - process.exit(0); - } + const lastNameCount = analysis.fieldCounts.lastName || 0; + if (lastNameCount > 0) { + items.push({ + label: 'Last Name', + userCount: lastNameCount, + clerkEnabled: clerkConfig?.attributes.last_name?.enabled ?? null, + section: 'model', + }); } - // Step 9: Display and confirm other field settings (if any) - const hasOtherFields = displayOtherFieldsAnalysis(analysis); + // Step 7: Display unified cross-reference report + displayCrossReference(items, analysis); - if (hasOtherFields) { - const confirmFields = await p.confirm({ - message: 'Have you configured the other field settings in the Dashboard?', - initialValue: true, - }); + // Show hint if configs couldn't be loaded + if (!clerkConfig && !publishableKey) { + p.log.info( + color.dim( + 'Set CLERK_PUBLISHABLE_KEY in .env to enable automatic configuration checking.' + ) + ); + } else if (!clerkConfig && publishableKey) { + p.log.warn(color.yellow('Could not fetch Clerk instance configuration.')); + } - if (p.isCancel(confirmFields) || !confirmFields) { - p.cancel( - 'Migration cancelled. Please configure field settings and try again.' + if (isSupabase && !supabaseProviders) { + if (supabaseUrl && supabaseApiKey) { + p.log.warn( + color.yellow( + 'Could not fetch Supabase auth settings. Social connections not checked.' + ) + ); + } else { + p.log.info( + color.dim( + 'Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in .env to check social connections.' + ) ); - process.exit(0); } } - // Step 10: Final confirmation + // Step 8: Show exclusion info and confirm + if (excludedUserIds.size > 0) { + const providerNames = disabledProviders + .map((p) => OAUTH_PROVIDER_LABELS[p] || p) + .join(', '); + p.log.warn( + color.yellow( + `${excludedUserIds.size} user${excludedUserIds.size === 1 ? '' : 's'} will be excluded — signed up via disabled social connection${disabledProviders.length === 1 ? '' : 's'} (${providerNames})` + ) + ); + } + + const importCount = userCount - excludedUserIds.size; + const hasIssues = items.some((i) => i.clerkEnabled === false); + + if (importCount <= 0) { + p.cancel('No users can be imported after exclusions.'); + process.exit(0); + } + + const confirmMessage = + excludedUserIds.size > 0 + ? `Import ${importCount} user${importCount === 1 ? '' : 's'}? (${excludedUserIds.size} excluded)` + : hasIssues + ? 'Some settings need attention. Proceed with migration?' + : 'Begin migration?'; + const beginMigration = await p.confirm({ - message: 'Begin Migration?', - initialValue: true, + message: confirmMessage, + initialValue: !hasIssues || excludedUserIds.size > 0, }); if (p.isCancel(beginMigration) || !beginMigration) { @@ -1121,16 +1131,20 @@ export async function runCLI() { process.exit(0); } - // Save settings for next run (not including instance - always auto-detected) + // Save settings for next run saveSettings({ key: initialArgs.key, file: initialArgs.file, }); + // Auto-determine skipPasswordRequirement: true if any users lack passwords + const skipPasswordRequirement = passwordCount < analysis.totalUsers; + return { ...initialArgs, instance: instanceType, begin: beginMigration, - skipPasswordRequirement: skipPasswordRequirement || false, + skipPasswordRequirement, + excludedUserIds, }; } diff --git a/src/migrate/index.ts b/src/migrate/index.ts index db462c8..c59b0e2 100644 --- a/src/migrate/index.ts +++ b/src/migrate/index.ts @@ -44,6 +44,22 @@ async function main() { } } + // Exclude users with disabled social providers + if (args.excludedUserIds && args.excludedUserIds.size > 0) { + const before = usersToImport.length; + usersToImport = usersToImport.filter( + (u) => !args.excludedUserIds.has(u.userId) + ); + const excluded = before - usersToImport.length; + if (excluded > 0) { + p.log.info( + color.dim( + `Excluded ${excluded} user${excluded === 1 ? '' : 's'} with disabled social connections` + ) + ); + } + } + await importUsers( usersToImport, args.skipPasswordRequirement, diff --git a/src/migrate/transformers/supabase.ts b/src/migrate/transformers/supabase.ts index 41c56d7..2187679 100644 --- a/src/migrate/transformers/supabase.ts +++ b/src/migrate/transformers/supabase.ts @@ -81,18 +81,31 @@ const supabaseTransformer = { // (e.g., when using the Supabase admin API or a basic SQL export without COALESCE) if (!user.firstName && user.publicMetadata) { const meta = user.publicMetadata as Record; - const displayName = (meta.display_name ?? - meta.first_name ?? - meta.name) as string | undefined; + let displayName = (meta.display_name ?? meta.first_name ?? meta.name) as + | string + | undefined; if (typeof displayName === 'string' && displayName.trim()) { - const parts = displayName.trim().split(/\s+/); - user.firstName = parts[0]; - if (parts.length > 1 && !user.lastName) { - user.lastName = parts.slice(1).join(' '); + // Strip Discord-style discriminators (e.g., "username#0", "name#1234") + displayName = displayName.replace(/#\d+$/, '').trim(); + if (displayName) { + const parts = displayName.split(/\s+/); + user.firstName = parts[0]; + if (parts.length > 1 && !user.lastName) { + user.lastName = parts.slice(1).join(' '); + } } } } + // Strip Discord-style discriminators from names (e.g., "username#0" → "username") + // Discord sets display_name as "name#0" which gets misinterpreted as a URL + if (typeof user.firstName === 'string') { + user.firstName = user.firstName.replace(/#\d+$/, '').trim() || undefined; + } + if (typeof user.lastName === 'string') { + user.lastName = user.lastName.replace(/#\d+$/, '').trim() || undefined; + } + // Clean up the emailConfirmedAt and phoneConfirmedAt fields as they aren't // part of our schema delete user.emailConfirmedAt; diff --git a/tests/migrate/cli.test.ts b/tests/migrate/cli.test.ts index 1df5c0b..4130547 100644 --- a/tests/migrate/cli.test.ts +++ b/tests/migrate/cli.test.ts @@ -4,9 +4,10 @@ import path from 'path'; import { analyzeFields, detectInstanceType, - displayIdentifierAnalysis, - displayOtherFieldsAnalysis, - formatCount, + fetchClerkConfig, + analyzeUserProviders, + findUsersWithDisabledProviders, + displayCrossReference, hasValue, loadRawUsers, loadSettings, @@ -577,38 +578,266 @@ describe('analyzeFields', () => { }); // ============================================================================ -// formatCount tests +// analyzeUserProviders tests // ============================================================================ -describe('formatCount', () => { - test('returns "All users have {label}" when count equals total', () => { - const result = formatCount(10, 10, 'email'); - expect(result).toBe('All users have email'); +describe('analyzeUserProviders', () => { + beforeEach(() => { + vi.clearAllMocks(); }); - test('returns "No users have {label}" when count is 0', () => { - const result = formatCount(0, 10, 'email'); - expect(result).toBe('No users have email'); + test('counts users per provider from raw_app_meta_data', () => { + const mockData = [ + { raw_app_meta_data: { providers: ['email'] } }, + { raw_app_meta_data: { providers: ['email'] } }, + { raw_app_meta_data: { providers: ['discord'] } }, + { raw_app_meta_data: { providers: ['email', 'google'] } }, + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = analyzeUserProviders('test.json'); + + expect(result).toEqual({ email: 3, discord: 1, google: 1 }); }); - test('returns "{count} of {total} users have {label}" for partial counts', () => { - const result = formatCount(5, 10, 'email'); - expect(result).toBe('5 of 10 users have email'); + test('returns empty object for invalid file', () => { + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('File not found'); + }); + + const result = analyzeUserProviders('missing.json'); + + expect(result).toEqual({}); }); - test('handles count of 1 out of many', () => { - const result = formatCount(1, 100, 'a username'); - expect(result).toBe('1 of 100 users have a username'); + test('skips users without raw_app_meta_data', () => { + const mockData = [ + { raw_app_meta_data: { providers: ['email'] } }, + { email: 'test@example.com' }, // no raw_app_meta_data + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = analyzeUserProviders('test.json'); + + expect(result).toEqual({ email: 1 }); }); +}); + +// ============================================================================ +// findUsersWithDisabledProviders tests +// ============================================================================ - test('handles large numbers', () => { - const result = formatCount(1234, 5678, 'verified emails'); - expect(result).toBe('1234 of 5678 users have verified emails'); +describe('findUsersWithDisabledProviders', () => { + beforeEach(() => { + vi.clearAllMocks(); }); - test('handles count equal to total of 1', () => { - const result = formatCount(1, 1, 'phone number'); - expect(result).toBe('All users have phone number'); + test('returns user IDs that have disabled providers', () => { + const mockData = [ + { id: 'user-1', raw_app_meta_data: { providers: ['email'] } }, + { id: 'user-2', raw_app_meta_data: { providers: ['discord'] } }, + { + id: 'user-3', + raw_app_meta_data: { providers: ['email', 'discord'] }, + }, + { id: 'user-4', raw_app_meta_data: { providers: ['google'] } }, + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = findUsersWithDisabledProviders('test.json', ['discord']); + + expect(result).toEqual(new Set(['user-2', 'user-3'])); + }); + + test('returns empty set when no disabled providers specified', () => { + const result = findUsersWithDisabledProviders('test.json', []); + + expect(result).toEqual(new Set()); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); + + test('returns empty set for invalid file', () => { + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('File not found'); + }); + + const result = findUsersWithDisabledProviders('missing.json', ['discord']); + + expect(result).toEqual(new Set()); + }); + + test('handles multiple disabled providers', () => { + const mockData = [ + { id: 'user-1', raw_app_meta_data: { providers: ['email'] } }, + { id: 'user-2', raw_app_meta_data: { providers: ['discord'] } }, + { id: 'user-3', raw_app_meta_data: { providers: ['twitter'] } }, + { + id: 'user-4', + raw_app_meta_data: { providers: ['email', 'google'] }, + }, + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = findUsersWithDisabledProviders('test.json', [ + 'discord', + 'twitter', + ]); + + expect(result).toEqual(new Set(['user-2', 'user-3'])); + }); + + test('skips users without raw_app_meta_data', () => { + const mockData = [ + { id: 'user-1', email: 'test@example.com' }, + { id: 'user-2', raw_app_meta_data: { providers: ['discord'] } }, + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = findUsersWithDisabledProviders('test.json', ['discord']); + + expect(result).toEqual(new Set(['user-2'])); + }); +}); + +// ============================================================================ +// displayCrossReference tests +// ============================================================================ + +describe('displayCrossReference', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('calls p.note with Migration Readiness title', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.any(String), + 'Migration Readiness' + ); + }); + + test('shows enabled items with green check', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('enabled in Clerk'), + 'Migration Readiness' + ); + }); + + test('shows disabled items with red cross', () => { + const items = [ + { + label: 'Discord', + userCount: 5, + clerkEnabled: false as boolean | null, + section: 'social' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('not enabled in Clerk'), + 'Migration Readiness' + ); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('1 setting'), + 'Migration Readiness' + ); + }); + + test('shows unknown items with yellow circle when no Clerk config', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: null as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('enable in Clerk Dashboard'), + 'Migration Readiness' + ); }); }); @@ -869,249 +1098,3 @@ describe('loadRawUsers', () => { }); }); }); - -// ============================================================================ -// displayIdentifierAnalysis tests -// ============================================================================ - -describe('displayIdentifierAnalysis', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('calls p.note with analysis message', () => { - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 10, - unverifiedEmails: 0, - verifiedPhones: 10, - unverifiedPhones: 0, - username: 10, - hasAnyIdentifier: 10, - }, - totalUsers: 10, - fieldCounts: {}, - }; - - displayIdentifierAnalysis(analysis); - - expect(p.note).toHaveBeenCalledWith(expect.any(String), 'Identifiers'); - }); - - test('handles analysis with all users having identifiers', () => { - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 5, - unverifiedEmails: 0, - verifiedPhones: 5, - unverifiedPhones: 0, - username: 5, - hasAnyIdentifier: 5, - }, - totalUsers: 5, - fieldCounts: {}, - }; - - // Should not throw - expect(() => displayIdentifierAnalysis(analysis)).not.toThrow(); - }); - - test('handles analysis with missing identifiers', () => { - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 3, - unverifiedEmails: 0, - verifiedPhones: 2, - unverifiedPhones: 0, - username: 1, - hasAnyIdentifier: 8, - }, - totalUsers: 10, - fieldCounts: {}, - }; - - // Should not throw - expect(() => displayIdentifierAnalysis(analysis)).not.toThrow(); - }); - - test('handles analysis with unverified identifiers', () => { - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 5, - unverifiedEmails: 3, - verifiedPhones: 5, - unverifiedPhones: 2, - username: 5, - hasAnyIdentifier: 5, - }, - totalUsers: 5, - fieldCounts: {}, - }; - - // Should not throw - expect(() => displayIdentifierAnalysis(analysis)).not.toThrow(); - }); - - test('recommends email as required when all importable users have email (even if some users lack identifiers)', () => { - // Scenario: 3309 users have email, 259 users have no identifier (will fail validation) - // All importable users (3309) have email, so email should be required - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 3309, - unverifiedEmails: 259, - verifiedPhones: 0, - unverifiedPhones: 0, - username: 0, - hasAnyIdentifier: 3309, // Only users with verified email can be imported - }, - totalUsers: 3568, // Total includes users who will fail validation - fieldCounts: {}, - }; - - displayIdentifierAnalysis(analysis); - - // Verify p.note was called with a message that includes email as required - expect(p.note).toHaveBeenCalledWith( - expect.stringContaining('Enable and optionally require'), - 'Identifiers' - ); - expect(p.note).toHaveBeenCalledWith( - expect.stringContaining('email'), - 'Identifiers' - ); - }); - - test('recommends identifiers as optional when not all importable users have them', () => { - // Scenario: 50 users have email, 50 users have phone, 100 total importable - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 50, - unverifiedEmails: 0, - verifiedPhones: 50, - unverifiedPhones: 0, - username: 0, - hasAnyIdentifier: 100, // 50 with email + 50 with phone = 100 importable - }, - totalUsers: 100, - fieldCounts: {}, - }; - - displayIdentifierAnalysis(analysis); - - // Both should be optional since not all importable users have each identifier - expect(p.note).toHaveBeenCalledWith( - expect.stringContaining('Enable in the Dashboard but do not require'), - 'Identifiers' - ); - }); -}); - -// ============================================================================ -// displayOtherFieldsAnalysis tests -// ============================================================================ - -describe('displayOtherFieldsAnalysis', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('returns false when no fields are analyzed', () => { - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 0, - unverifiedEmails: 0, - verifiedPhones: 0, - unverifiedPhones: 0, - username: 0, - hasAnyIdentifier: 0, - }, - totalUsers: 0, - fieldCounts: {}, - }; - - const result = displayOtherFieldsAnalysis(analysis); - - expect(result).toBe(false); - expect(p.note).not.toHaveBeenCalled(); - }); - - test('returns true when fields are present on all users', () => { - const analysis = { - presentOnAll: ['TOTP Secret'], - presentOnSome: [], - identifiers: { - verifiedEmails: 10, - unverifiedEmails: 0, - verifiedPhones: 0, - unverifiedPhones: 0, - username: 0, - hasAnyIdentifier: 10, - }, - totalUsers: 10, - fieldCounts: {}, - }; - - const result = displayOtherFieldsAnalysis(analysis); - - expect(result).toBe(true); - expect(p.note).toHaveBeenCalledWith(expect.any(String), 'Other Fields'); - }); - - test('returns true when fields are present on some users', () => { - const analysis = { - presentOnAll: [], - presentOnSome: ['TOTP Secret'], - identifiers: { - verifiedEmails: 10, - unverifiedEmails: 0, - verifiedPhones: 0, - unverifiedPhones: 0, - username: 0, - hasAnyIdentifier: 10, - }, - totalUsers: 10, - fieldCounts: {}, - }; - - const result = displayOtherFieldsAnalysis(analysis); - - expect(result).toBe(true); - expect(p.note).toHaveBeenCalledWith(expect.any(String), 'Other Fields'); - }); - - test('returns true when both presentOnAll and presentOnSome have fields', () => { - const analysis = { - presentOnAll: ['TOTP Secret'], - presentOnSome: [], - identifiers: { - verifiedEmails: 10, - unverifiedEmails: 0, - verifiedPhones: 0, - unverifiedPhones: 0, - username: 0, - hasAnyIdentifier: 10, - }, - totalUsers: 10, - fieldCounts: {}, - }; - - const result = displayOtherFieldsAnalysis(analysis); - - expect(result).toBe(true); - expect(p.note).toHaveBeenCalledWith(expect.any(String), 'Other Fields'); - }); -}); From 7f5ccb6ae8ef5b76272cf437f6a2ea91816b106b Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 10 Feb 2026 14:53:46 -0500 Subject: [PATCH 5/9] chore: Fixed lint errors and warings --- src/export/index.ts | 13 +++---------- src/export/supabase.ts | 12 +++++++++++- src/migrate/cli.ts | 29 ++++++++++++++++------------- src/migrate/index.ts | 2 +- tests/migrate/cli.test.ts | 5 ++--- 5 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/export/index.ts b/src/export/index.ts index d5279f4..e9e3172 100644 --- a/src/export/index.ts +++ b/src/export/index.ts @@ -14,7 +14,7 @@ import 'dotenv/config'; import * as p from '@clack/prompts'; import color from 'picocolors'; -import { exportSupabaseUsers, displayExportSummary } from './supabase'; +import { displayExportSummary, exportSupabaseUsers } from './supabase'; async function main() { p.intro(color.bgCyan(color.black('Supabase User Export'))); @@ -37,14 +37,7 @@ async function main() { // Prompt for DB URL if not provided if (!dbUrl) { p.note( - `Find this in the Supabase Dashboard by clicking the ${color.bold('Connect')} button.\n\n` + - `${color.bold('Direct connection')} (requires IPv6):\n` + - ` ${color.dim('postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres')}\n\n` + - `${color.bold('Pooler connection')} (works on IPv4 — use this if direct fails):\n` + - ` ${color.dim('postgres://postgres.[REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres')}\n\n` + - color.dim( - 'Alternatively, run the export SQL in the Supabase SQL Editor and save the result as JSON.' - ), + `Find this in the Supabase Dashboard by clicking the ${color.bold('Connect')} button.\n\n${color.bold('Direct connection')} (requires IPv6):\n ${color.dim('postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres')}\n\n${color.bold('Pooler connection')} (works on IPv4 — use this if direct fails):\n ${color.dim('postgres://postgres.[REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres')}\n\n${color.dim('Alternatively, run the export SQL in the Supabase SQL Editor and save the result as JSON.')}`, 'Connection String' ); @@ -97,4 +90,4 @@ async function main() { } } -main(); +void main(); diff --git a/src/export/supabase.ts b/src/export/supabase.ts index 1c130c0..75d9dcd 100644 --- a/src/export/supabase.ts +++ b/src/export/supabase.ts @@ -80,7 +80,17 @@ export async function exportSupabaseUsers( } try { - const { rows } = await client.query(EXPORT_QUERY); + interface SupabaseUserRow { + email: string | null; + email_confirmed_at: string | null; + encrypted_password: string | null; + phone: string | null; + first_name: string | null; + last_name: string | null; + [key: string]: unknown; + } + + const { rows } = await client.query(EXPORT_QUERY); // Calculate field coverage const coverage = { diff --git a/src/migrate/cli.ts b/src/migrate/cli.ts index b65a366..fdf0b60 100644 --- a/src/migrate/cli.ts +++ b/src/migrate/cli.ts @@ -408,6 +408,7 @@ export async function runNonInteractive(args: CLIArgs): Promise<{ instance: 'dev' | 'prod'; begin: boolean; skipPasswordRequirement: boolean; + excludedUserIds: Set; }> { // Handle help flag if (args.help) { @@ -558,6 +559,7 @@ export async function runNonInteractive(args: CLIArgs): Promise<{ instance: instanceType, begin: true, skipPasswordRequirement, + excludedUserIds: new Set(), }; } @@ -827,7 +829,7 @@ const OAUTH_PROVIDER_LABELS: Record = { const IGNORED_PROVIDERS = new Set(['email', 'phone', 'anonymous_users']); interface SupabaseAuthSettings { - external: Record; + external?: Record; } /** @@ -870,8 +872,8 @@ export async function fetchSupabaseProviders( // --- Clerk Instance Configuration --- interface ClerkConfig { - attributes: Record; - social: Record; + attributes: Partial>; + social: Partial>; } /** @@ -925,7 +927,7 @@ export async function fetchClerkConfig( social?: Record; }; }; - const userSettings = data?.user_settings; + const userSettings = data.user_settings; if (!userSettings) return null; return { @@ -1044,7 +1046,7 @@ export function displayCrossReference( items: ReadinessItem[], analysis: FieldAnalysis ): void { - const sections: Record = {}; + const sections: Partial> = {}; for (const item of items) { if (!sections[item.section]) sections[item.section] = []; sections[item.section].push(item); @@ -1102,8 +1104,7 @@ export function displayCrossReference( 0 ); message += color.yellow( - `⚠ ${needsAttention.length} setting${needsAttention.length === 1 ? '' : 's'} need${needsAttention.length === 1 ? 's' : ''} attention` + - (totalAffected > 0 ? ` (up to ${totalAffected} users affected)` : '') + `⚠ ${needsAttention.length} setting${needsAttention.length === 1 ? '' : 's'} need${needsAttention.length === 1 ? 's' : ''} attention${totalAffected > 0 ? ` (up to ${totalAffected} users affected)` : ''}` ); } else if (items.some((i) => i.clerkEnabled !== null)) { message += color.green('All settings are configured in Clerk'); @@ -1649,12 +1650,14 @@ export async function runCLI(cliArgs?: CLIArgs) { process.exit(0); } - const confirmMessage = - excludedUserIds.size > 0 - ? `Import ${importCount} user${importCount === 1 ? '' : 's'}? (${excludedUserIds.size} excluded)` - : hasIssues - ? 'Some settings need attention. Proceed with migration?' - : 'Begin migration?'; + let confirmMessage: string; + if (excludedUserIds.size > 0) { + confirmMessage = `Import ${importCount} user${importCount === 1 ? '' : 's'}? (${excludedUserIds.size} excluded)`; + } else if (hasIssues) { + confirmMessage = 'Some settings need attention. Proceed with migration?'; + } else { + confirmMessage = 'Begin migration?'; + } const beginMigration = await p.confirm({ message: confirmMessage, diff --git a/src/migrate/index.ts b/src/migrate/index.ts index e763fa4..68a6ad7 100644 --- a/src/migrate/index.ts +++ b/src/migrate/index.ts @@ -51,7 +51,7 @@ async function main() { } // Exclude users with disabled social providers - if (args.excludedUserIds && args.excludedUserIds.size > 0) { + if (args.excludedUserIds.size > 0) { const before = usersToImport.length; usersToImport = usersToImport.filter( (u) => !args.excludedUserIds.has(u.userId) diff --git a/tests/migrate/cli.test.ts b/tests/migrate/cli.test.ts index 4130547..a2ed066 100644 --- a/tests/migrate/cli.test.ts +++ b/tests/migrate/cli.test.ts @@ -3,11 +3,10 @@ import fs from 'fs'; import path from 'path'; import { analyzeFields, - detectInstanceType, - fetchClerkConfig, analyzeUserProviders, - findUsersWithDisabledProviders, + detectInstanceType, displayCrossReference, + findUsersWithDisabledProviders, hasValue, loadRawUsers, loadSettings, From afad281638b41b0798e939530135a1afc5a75366 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 10 Feb 2026 16:23:32 -0500 Subject: [PATCH 6/9] chore: Updated prompts --- prompts/migration-prompt.md | 40 +++++++++++++++++------------------ prompts/transformer-prompt.md | 34 ++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/prompts/migration-prompt.md b/prompts/migration-prompt.md index c893310..7c72690 100644 --- a/prompts/migration-prompt.md +++ b/prompts/migration-prompt.md @@ -19,11 +19,18 @@ Follow these steps EXACTLY in order. Do NOT skip any steps. ### Step 1: Verify Environment -Before doing ANYTHING else: +Before proceeding, check if dependencies are installed. If not: +1. Use `bun install` to install dependencies. + +After confirming dependencies are installed and before doing ANYTHING else: +1. Check if dependencies are installed, and if not use `bun install` to install dependencies. 1. Check if `.env` file exists with `CLERK_SECRET_KEY` -2. If missing, IMMEDIATELY ask for the key (Clerk Dashboard → API Keys → Secret keys, or https://dashboard.clerk.com/~/api-keys) -3. Create/update the `.env` file with the provided key -4. Do NOT proceed until the key is configured +1. If missing, IMMEDIATELY ask for the key (Clerk Dashboard → API Keys → Secret keys, or https://dashboard.clerk.com/~/api-keys) +1. Create/update the `.env` file with the provided key +1. Do NOT proceed until the key is configured + + +**DO NOT** move to step 1 until the dependenices are installed and the `CLERK_SECRET_KEY` is present in the `.env` file ### Step 2: Analyze the Data File @@ -40,9 +47,10 @@ Read a sample of the file to understand its structure. Look for signature fields ### Step 3A: If a Transformer Matches 1. Tell me which transformer will be used -2. Summarize the field mappings that will be applied -3. Ask if I want to proceed with the migration -4. If confirmed, run: +1. Summarize the field mappings that will be applied +1. Use `displayCrossReference()` and related code to display a mnigration readiness table to the user. +1. Ask if I want to proceed with the migration +1. If confirmed, run: ```bash bun migrate -y --transformer [transformer-key] --file [file-path] ``` @@ -52,19 +60,11 @@ Read a sample of the file to understand its structure. Look for signature fields If the data doesn't match any existing transformer, you MUST: 1. **Inform the user**: Explain that no existing transformer matches their data format -2. **List the fields found**: Show all fields discovered in their data file -3. **Create a custom transformer**: Generate a transformer file at `src/transformers/[platform-name].ts` using the 'transformer' skill or the `prompts/transformer-prompt.md` -4. **MANDATORY - Register the transformer**: - - Add an import to `src/transformers/index.ts` - - Add the transformer to the `transformers` array - - **THIS STEP IS NOT OPTIONAL.** If you skip registration: - - The transformer will NOT appear in the CLI's platform selection - - The `bun delete` command will NOT find migrated users - - Users will see "Found 0 migrated users to delete" after migration - -5. **Run tests**: Execute `bun run test` to verify the transformer is properly registered -6. **Run the migration**: After tests pass, run the migration command +1. **List the fields found**: Show all fields discovered in their data file +1. **Create a custom transformer**: Use the `transformer` skill. If the `transformer` skill is not available use `.claude/skills/transformer/SKILL.md` or `prompts/transformer-prompt.md`. Do not try and create a transformer without using the skill or prompt. +1. **Run tests**: Execute `bun run test` to verify the transformer is properly registered +1. Use `displayCrossReference()` and related code to display a mnigration readiness table to the user. Always display this after any field mapping summary. +1. **Run the migration**: After tests pass, run the migration command ### Step 4: Post-Migration Verification diff --git a/prompts/transformer-prompt.md b/prompts/transformer-prompt.md index f5d34bb..ab4eab3 100644 --- a/prompts/transformer-prompt.md +++ b/prompts/transformer-prompt.md @@ -13,10 +13,13 @@ I need to create a custom transformer for the Clerk user migration script. Pleas ## Environment Setup +Before proceeding, check if dependencies are installed. If not: +1. Use `bun install` to install dependencies. + Before generating the transformer, check if a `.env` file exists with `CLERK_SECRET_KEY`. If not: 1. Ask the user to provide their CLERK_SECRET_KEY (found in Clerk Dashboard → API Keys → Secret keys) -2. Create the `.env` file with the provided key -3. Continue with the transformer generation without stopping +1. Create the `.env` file with the provided key +1. Continue with the transformer generation without stopping Do not ask "would you like me to create one?" - just ask for the key directly and create the file. @@ -31,7 +34,7 @@ Do not ask "would you like me to create one?" - just ask for the key directly an - If not, ask me for the key (found in Clerk Dashboard → API Keys → Secret keys, or https://dashboard.clerk.com/~/api-keys) - Create/update the `.env` file with the key -2. Analyze the JSON/CSV structure to identify: +1. Analyze the JSON/CSV structure to identify: - User ID field (maps to `userId`) - Email field(s) and verification status - Phone field(s) and verification status @@ -39,7 +42,7 @@ Do not ask "would you like me to create one?" - just ask for the key directly an - Password field and hash algorithm - Any metadata fields -3. Generate a complete transformer file following this structure: +1. Generate a complete transformer file following this structure: ```typescript // src/transformers/[platform-name].ts @@ -61,7 +64,7 @@ const [platformName]Transformer = { export default [platformName]Transformer; ``` -4. **CRITICAL - Register the transformer**: After creating the transformer file, you MUST register it in `src/transformers/index.ts`. This is NOT optional. The migration and delete commands will fail silently if the transformer is not registered. +1. **CRITICAL - Register the transformer**: After creating the transformer file, you MUST register it in `src/transformers/index.ts`. This is NOT optional. The migration and delete commands will fail silently if the transformer is not registered. Add both an import and include it in the exports array: @@ -80,6 +83,17 @@ export default [platformName]Transformer; - The transformer will NOT appear in the CLI's platform selection - The `bun delete` command will NOT be able to find migrated users - Users will see "Found 0 migrated users to delete" even after successful migration + + **THIS STEP IS NOT OPTIONAL.** If you skip registration: + - The transformer will NOT appear in the CLI's platform selection + - The `bun delete` command will NOT find migrated users + - Users will see "Found 0 migrated users to delete" after migration + +1. **Run tests**: Execute `bun run test` to verify the transformer is properly registered +1. Use `displayCrossReference()` and related code to display a mnigration readiness table to the user. Always display this after any field mapping summary. +1. **Run the migration**: After tests pass, run the migration command + + ```` ## Questions to Answer @@ -194,14 +208,14 @@ After generating your transformer, verify these steps were completed: ### 1. Transformer File Created -- [ ] File exists at `src/transformers/[platform-name].ts` -- [ ] Has a default export with `key`, `value`, `label`, `description`, and `transformer` fields -- [ ] The `transformer` object maps source fields to Clerk fields (including a field that maps to `userId`) +- \[ ] File exists at `src/transformers/[platform-name].ts` +- \[ ] Has a default export with `key`, `value`, `label`, `description`, and `transformer` fields +- \[ ] The `transformer` object maps source fields to Clerk fields (including a field that maps to `userId`) ### 2. Transformer Registered (CRITICAL) -- [ ] Import added to `src/transformers/index.ts` -- [ ] Transformer added to the `transformers` array export +- \[ ] Import added to `src/transformers/index.ts` +- \[ ] Transformer added to the `transformers` array export **If you skip registration, the delete command will fail to find migrated users!** From c9d4369d8fa74b3761685821d5d829a4a87864b3 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 18 Feb 2026 18:22:06 -0500 Subject: [PATCH 7/9] chore: Addressed PR review suggestions --- src/export/supabase.ts | 24 ++- src/migrate/cli.ts | 121 +++++++++++- tests/migrate/cli.test.ts | 398 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 526 insertions(+), 17 deletions(-) diff --git a/src/export/supabase.ts b/src/export/supabase.ts index 75d9dcd..e9a13d3 100644 --- a/src/export/supabase.ts +++ b/src/export/supabase.ts @@ -129,24 +129,32 @@ export async function exportSupabaseUsers( } /** - * Displays a summary of the export results with field coverage stats. + * Displays the export results as a field coverage report and success message. + * + * Shows each field with an icon indicating coverage level: + * - ● green — all users have this field + * - ○ yellow — some users have this field + * - ○ dim — no users have this field + * + * @param result - Export result containing user count, output path, and per-field coverage stats */ export function displayExportSummary(result: ExportResult): void { const { userCount, outputPath, fieldCoverage } = result; - const getIcon = (count: number, total: number): string => { + /** Returns a colored icon based on how many users have a given field. */ + const getCoverageIcon = (count: number, total: number): string => { if (count === total) return color.green('●'); if (count > 0) return color.yellow('○'); return color.dim('○'); }; let summary = ''; - summary += `${getIcon(fieldCoverage.email, userCount)} ${color.dim(`${fieldCoverage.email}/${userCount} have email`)}\n`; - summary += `${getIcon(fieldCoverage.emailConfirmed, userCount)} ${color.dim(`${fieldCoverage.emailConfirmed}/${userCount} email confirmed`)}\n`; - summary += `${getIcon(fieldCoverage.password, userCount)} ${color.dim(`${fieldCoverage.password}/${userCount} have password hash`)}\n`; - summary += `${getIcon(fieldCoverage.phone, userCount)} ${color.dim(`${fieldCoverage.phone}/${userCount} have phone`)}\n`; - summary += `${getIcon(fieldCoverage.firstName, userCount)} ${color.dim(`${fieldCoverage.firstName}/${userCount} have first name`)}\n`; - summary += `${getIcon(fieldCoverage.lastName, userCount)} ${color.dim(`${fieldCoverage.lastName}/${userCount} have last name`)}`; + summary += `${getCoverageIcon(fieldCoverage.email, userCount)} ${color.dim(`${fieldCoverage.email}/${userCount} have email`)}\n`; + summary += `${getCoverageIcon(fieldCoverage.emailConfirmed, userCount)} ${color.dim(`${fieldCoverage.emailConfirmed}/${userCount} email confirmed`)}\n`; + summary += `${getCoverageIcon(fieldCoverage.password, userCount)} ${color.dim(`${fieldCoverage.password}/${userCount} have password hash`)}\n`; + summary += `${getCoverageIcon(fieldCoverage.phone, userCount)} ${color.dim(`${fieldCoverage.phone}/${userCount} have phone`)}\n`; + summary += `${getCoverageIcon(fieldCoverage.firstName, userCount)} ${color.dim(`${fieldCoverage.firstName}/${userCount} have first name`)}\n`; + summary += `${getCoverageIcon(fieldCoverage.lastName, userCount)} ${color.dim(`${fieldCoverage.lastName}/${userCount} have last name`)}`; p.note(summary, 'Field Coverage'); p.log.success( diff --git a/src/migrate/cli.ts b/src/migrate/cli.ts index 9b62868..51e970e 100644 --- a/src/migrate/cli.ts +++ b/src/migrate/cli.ts @@ -12,6 +12,7 @@ import { import { checkIfFileExists, createImportFilePath, + getDateTimeStamp, getFileType, transformKeys as transformKeysFromFunctions, tryCatch, @@ -28,6 +29,8 @@ import type { IdentifierCounts, Settings, } from '../types'; +import { userSchema } from './validator'; +import { validationLogger } from '../logger'; /** * Parsed command-line arguments for the migration tool @@ -798,6 +801,54 @@ export function analyzeFields(users: Record[]): FieldAnalysis { return { presentOnAll, presentOnSome, identifiers, totalUsers, fieldCounts }; } +/** + * Validates users against the schema and logs validation errors. + * + * Runs before the readiness display so users can see the validation failure + * count and review the log file before confirming the migration. + * + * Applies transformer default fields (e.g., Supabase passwordHasher: "bcrypt") + * before validation to match the behavior of the full import pipeline. + * + * @param users - Array of transformed user objects from loadRawUsers() + * @param transformerKey - Transformer key to look up default fields + * @returns Object with validation failure count and log file path + */ +export function validateUsers( + users: Record[], + transformerKey: string +): { validationFailed: number; logFile: string } { + const dateTime = getDateTimeStamp(); + const logFile = `migration-${dateTime}.log`; + let validationFailed = 0; + + // Look up transformer defaults (e.g., Supabase adds passwordHasher: "bcrypt") + const transformer = transformers.find((obj) => obj.key === transformerKey); + const defaultFields = + transformer && 'defaults' in transformer ? transformer.defaults : null; + + for (let i = 0; i < users.length; i++) { + const user = defaultFields ? { ...users[i], ...defaultFields } : users[i]; + const result = userSchema.safeParse(user); + + if (!result.success) { + validationFailed++; + const firstIssue = result.error.issues[0]; + validationLogger( + { + error: firstIssue.message, + path: firstIssue.path as (string | number)[], + userId: (user.userId as string) || `row-${i}`, + row: i, + }, + dateTime + ); + } + } + + return { validationFailed, logFile }; +} + // Maps Supabase provider keys to human-readable labels const OAUTH_PROVIDER_LABELS: Record = { google: 'Google', @@ -1023,6 +1074,7 @@ interface ReadinessItem { label: string; userCount: number; clerkEnabled: boolean | null; // null = Clerk config not available + clerkRequired: boolean | null; // null = Clerk config not available section: 'identifiers' | 'auth' | 'social' | 'model'; } @@ -1037,14 +1089,26 @@ interface ReadinessItem { * User Model) and each shows: * - ✓ enabled in Clerk (green) * - ✗ not enabled in Clerk (red, needs attention) + * - ⚠ required in Clerk but not all users have it (yellow, will cause failures) * - ○ status unknown, enable in Dashboard (yellow, no Clerk config available) * + * For identifiers (email, phone, username), provides actionable guidance on + * whether the field can safely be required based on user coverage. + * + * Also displays total user count and validation failure summary (with log file + * reference) so users can review errors before confirming the migration. + * * @param items - List of readiness items to display * @param analysis - Field analysis results for total user count and identifier check + * @param validation - Validation results: failure count and log file path (0 failures hides the section) */ export function displayCrossReference( items: ReadinessItem[], - analysis: FieldAnalysis + analysis: FieldAnalysis, + validation: { validationFailed: number; logFile: string } = { + validationFailed: 0, + logFile: '', + } ): void { const sections: Partial> = {}; for (const item of items) { @@ -1052,7 +1116,20 @@ export function displayCrossReference( sections[item.section].push(item); } + const total = analysis.totalUsers; + let message = ''; + message += `${color.dim(`${total} user${total === 1 ? '' : 's'} in file`)}\n`; + + // Show validation failures if any + if (validation.validationFailed > 0) { + message += color.yellow( + `${validation.validationFailed} user${validation.validationFailed === 1 ? '' : 's'} failed validation and will be skipped — see ${color.dim(`logs/${validation.logFile}`)}` + ); + message += '\n'; + } + message += '\n'; + const needsAttention: ReadinessItem[] = []; const sectionLabels: Record = { @@ -1071,17 +1148,32 @@ export function displayCrossReference( message += color.bold(color.whiteBright(`${sectionLabels[section]}\n`)); for (const item of sectionItems) { - const countStr = - item.userCount === analysis.totalUsers - ? 'all users' - : `${item.userCount} user${item.userCount === 1 ? '' : 's'}`; + const allUsers = item.userCount === total; + const countStr = allUsers + ? 'all users' + : `${item.userCount}/${total} users`; + const isIdentifier = item.section === 'identifiers'; if (item.clerkEnabled === true) { - message += ` ${color.green('✓')} ${item.label} — ${color.dim(`enabled in Clerk — ${countStr}`)}\n`; + // Enabled + required but not all users have it → will cause import failures + if (isIdentifier && item.clerkRequired === true && !allUsers) { + const missing = total - item.userCount; + message += ` ${color.yellow('⚠')} ${item.label} — ${color.yellow('required in Clerk')} — ${color.dim(`${countStr} (${missing} will fail without ${item.label.toLowerCase()})`)}\n`; + needsAttention.push(item); + } else { + message += ` ${color.green('✓')} ${item.label} — ${color.dim(`enabled in Clerk — ${countStr}`)}\n`; + } } else if (item.clerkEnabled === false) { message += ` ${color.red('✗')} ${item.label} — ${color.red('not enabled in Clerk')} — ${color.dim(countStr)}\n`; needsAttention.push(item); + } else if (isIdentifier && allUsers) { + // No Clerk config — all users have this identifier, safe to require + message += ` ${color.yellow('○')} ${item.label} — ${color.dim(`${countStr} — enable in Clerk Dashboard (can be required)`)}\n`; + } else if (isIdentifier) { + // No Clerk config — not all users have this identifier, requiring would cause failures + message += ` ${color.yellow('○')} ${item.label} — ${color.dim(`${countStr} — enable in Clerk Dashboard (do not require)`)}\n`; } else { + // No Clerk config — non-identifier item message += ` ${color.yellow('○')} ${item.label} — ${color.dim(`${countStr} — enable in Clerk Dashboard`)}\n`; } } @@ -1090,8 +1182,8 @@ export function displayCrossReference( } // Check for users without any identifier - if (analysis.identifiers.hasAnyIdentifier < analysis.totalUsers) { - const missing = analysis.totalUsers - analysis.identifiers.hasAnyIdentifier; + if (analysis.identifiers.hasAnyIdentifier < total) { + const missing = total - analysis.identifiers.hasAnyIdentifier; message += color.red( `⚠ ${missing} user${missing === 1 ? '' : 's'} without any identifier (cannot be imported)\n\n` ); @@ -1426,6 +1518,10 @@ export async function runCLI(cliArgs?: CLIArgs) { const analysis = analyzeFields(filteredUsers); + // Validate users and log errors so they're available before the readiness display. + // Users can cancel after seeing the results and review the log file. + const validation = validateUsers(filteredUsers, initialArgs.key); + // Step 4: Check instance type and validate const instanceType = detectInstanceType(); @@ -1513,6 +1609,7 @@ export async function runCLI(cliArgs?: CLIArgs) { label: 'Email', userCount: emailCount, clerkEnabled: clerkConfig?.attributes.email_address?.enabled ?? null, + clerkRequired: clerkConfig?.attributes.email_address?.required ?? null, section: 'identifiers', }); } @@ -1524,6 +1621,7 @@ export async function runCLI(cliArgs?: CLIArgs) { label: 'Phone', userCount: phoneCount, clerkEnabled: clerkConfig?.attributes.phone_number?.enabled ?? null, + clerkRequired: clerkConfig?.attributes.phone_number?.required ?? null, section: 'identifiers', }); } @@ -1533,6 +1631,7 @@ export async function runCLI(cliArgs?: CLIArgs) { label: 'Username', userCount: analysis.identifiers.username, clerkEnabled: clerkConfig?.attributes.username?.enabled ?? null, + clerkRequired: clerkConfig?.attributes.username?.required ?? null, section: 'identifiers', }); } @@ -1544,6 +1643,7 @@ export async function runCLI(cliArgs?: CLIArgs) { label: 'Password', userCount: passwordCount, clerkEnabled: clerkConfig?.attributes.password?.enabled ?? null, + clerkRequired: null, section: 'auth', }); } @@ -1561,6 +1661,7 @@ export async function runCLI(cliArgs?: CLIArgs) { label: OAUTH_PROVIDER_LABELS[provider] || provider, userCount: providerCounts[provider] || 0, clerkEnabled, + clerkRequired: null, section: 'social', }); @@ -1587,6 +1688,7 @@ export async function runCLI(cliArgs?: CLIArgs) { label: 'First Name', userCount: firstNameCount, clerkEnabled: clerkConfig?.attributes.first_name?.enabled ?? null, + clerkRequired: null, section: 'model', }); } @@ -1597,12 +1699,13 @@ export async function runCLI(cliArgs?: CLIArgs) { label: 'Last Name', userCount: lastNameCount, clerkEnabled: clerkConfig?.attributes.last_name?.enabled ?? null, + clerkRequired: null, section: 'model', }); } // Step 7: Display unified cross-reference report - displayCrossReference(items, analysis); + displayCrossReference(items, analysis, validation); // Show hint if configs couldn't be loaded if (!clerkConfig && !publishableKey) { diff --git a/tests/migrate/cli.test.ts b/tests/migrate/cli.test.ts index a2ed066..9366410 100644 --- a/tests/migrate/cli.test.ts +++ b/tests/migrate/cli.test.ts @@ -11,6 +11,7 @@ import { loadRawUsers, loadSettings, saveSettings, + validateUsers, } from '../../src/migrate/cli'; // Mock modules @@ -55,6 +56,10 @@ vi.mock('picocolors', () => ({ }, })); +vi.mock('../../src/logger', () => ({ + validationLogger: vi.fn(), +})); + // Import the mocked module to get access to the mock import * as p from '@clack/prompts'; @@ -713,6 +718,7 @@ describe('displayCrossReference', () => { label: 'Email', userCount: 10, clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, section: 'identifiers' as const, }, ]; @@ -745,6 +751,7 @@ describe('displayCrossReference', () => { label: 'Email', userCount: 10, clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, section: 'identifiers' as const, }, ]; @@ -777,6 +784,7 @@ describe('displayCrossReference', () => { label: 'Discord', userCount: 5, clerkEnabled: false as boolean | null, + clerkRequired: null as boolean | null, section: 'social' as const, }, ]; @@ -813,6 +821,7 @@ describe('displayCrossReference', () => { label: 'Email', userCount: 10, clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, section: 'identifiers' as const, }, ]; @@ -838,6 +847,395 @@ describe('displayCrossReference', () => { 'Migration Readiness' ); }); + + test('shows "can be required" hint for identifiers when all users have it and no Clerk config', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('can be required'), + 'Migration Readiness' + ); + }); + + test('shows "do not require" hint for identifiers when not all users have it and no Clerk config', () => { + const items = [ + { + label: 'Email', + userCount: 7, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 7, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('do not require'), + 'Migration Readiness' + ); + }); + + test('warns when identifier is required in Clerk but not all users have it', () => { + const items = [ + { + label: 'Email', + userCount: 7, + clerkEnabled: true as boolean | null, + clerkRequired: true as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 7, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('required in Clerk'), + 'Migration Readiness' + ); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('3 will fail without email'), + 'Migration Readiness' + ); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('1 setting'), + 'Migration Readiness' + ); + }); + + test('shows green check when identifier is required and all users have it', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: true as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('enabled in Clerk'), + 'Migration Readiness' + ); + expect(p.note).not.toHaveBeenCalledWith( + expect.stringContaining('will fail'), + 'Migration Readiness' + ); + }); + + test('does not show require hints for non-identifier sections', () => { + const items = [ + { + label: 'Password', + userCount: 10, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'auth' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + const noteCall = vi.mocked(p.note).mock.calls[0][0] as string; + expect(noteCall).not.toContain('can be required'); + expect(noteCall).not.toContain('do not require'); + }); + + test('shows total user count', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('10 users in file'), + 'Migration Readiness' + ); + }); + + test('shows count/total format for partial coverage', () => { + const items = [ + { + label: 'Email', + userCount: 15, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 15, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 30, + }, + totalUsers: 30, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('15/30 users'), + 'Migration Readiness' + ); + }); + + test('shows validation failure count and log file reference', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis, { + validationFailed: 3, + logFile: 'migration-2025-01-01-120000.log', + }); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('3 users failed validation'), + 'Migration Readiness' + ); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('migration-2025-01-01-120000.log'), + 'Migration Readiness' + ); + }); + + test('does not show validation section when no failures', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis, { + validationFailed: 0, + logFile: '', + }); + + const noteCall = vi.mocked(p.note).mock.calls[0][0] as string; + expect(noteCall).not.toContain('failed validation'); + }); +}); + +// ============================================================================ +// validateUsers tests +// ============================================================================ + +describe('validateUsers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('returns 0 failures for valid users', () => { + const users = [ + { userId: 'user_1', email: 'a@test.com' }, + { userId: 'user_2', email: 'b@test.com' }, + ]; + + const result = validateUsers(users, 'clerk'); + + expect(result.validationFailed).toBe(0); + expect(result.logFile).toMatch(/^migration-.*\.log$/); + }); + + test('counts validation failures for users missing identifiers', () => { + const users = [ + { userId: 'user_1', email: 'a@test.com' }, + { userId: 'user_2' }, // no identifier + { userId: 'user_3' }, // no identifier + ]; + + const result = validateUsers(users, 'clerk'); + + expect(result.validationFailed).toBe(2); + }); + + test('applies transformer defaults before validating', () => { + // Supabase transformer adds passwordHasher: "bcrypt" as default + // Without defaults, users with password but no hasher would fail + const users = [ + { userId: 'user_1', email: 'a@test.com', password: '$2a$10$hash' }, + ]; + + const result = validateUsers(users, 'supabase'); + + expect(result.validationFailed).toBe(0); + }); + + test('fails validation for users with password but no hasher when no defaults', () => { + const users = [ + { userId: 'user_1', email: 'a@test.com', password: 'somepassword' }, + ]; + + const result = validateUsers(users, 'clerk'); + + expect(result.validationFailed).toBe(1); + }); + + test('logs validation errors via validationLogger', async () => { + const { validationLogger } = await import('../../src/logger'); + const users = [ + { userId: 'user_1' }, // no identifier + ]; + + validateUsers(users, 'clerk'); + + expect(validationLogger).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user_1', + row: 0, + }), + expect.any(String) + ); + }); }); // ============================================================================ From 255f55afaffcb2ce04e2b2539a4a8a3399cc4d9b Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 19 Feb 2026 00:09:15 -0500 Subject: [PATCH 8/9] refactor: Updated CLI messaging, clean up, added tests --- README.md | 10 ++ docs/exporting-users.md | 130 +++++++++++++++++++ src/export/index.ts | 38 +++--- src/export/supabase.ts | 43 ++++++- src/migrate/cli.ts | 126 ++++++++++-------- src/utils.ts | 71 +++++++++++ tests/export.test.ts | 262 ++++++++++++++++++++++++++++++++++++++ tests/migrate/cli.test.ts | 227 ++++++++++++++++++++++++++++++++- 8 files changed, 824 insertions(+), 83 deletions(-) create mode 100644 docs/exporting-users.md create mode 100644 tests/export.test.ts diff --git a/README.md b/README.md index ee73edc..c8d273f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This repository contains a tool that takes a JSON file as input, containing a li ## Table of Contents - [Getting Started](#getting-started) +- [Exporting Users](#exporting-users) - [Migrating OAuth Connections](#migrating-oauth-connections) - [Handle Existing User IDs and Foreign Key Constraints](#handle-existing-user-ids-and-foreign-key-constraints) - [Configuration](#configuration) @@ -15,6 +16,7 @@ This repository contains a tool that takes a JSON file as input, containing a li ### Documentation +- [Exporting Users](docs/exporting-users.md) - [Schema Fields Reference](docs/schema-fields.md) - [Creating Custom Transformers](docs/creating-transformers.md) - [AI Migration Prompt](prompts/migration-prompt.md) @@ -169,6 +171,14 @@ bun migrate -y \ - `--file` (or `-f`) - `CLERK_SECRET_KEY` (via `--clerk-secret-key`, environment variable, or `.env` file) +## Exporting Users + +Some platforms require exporting users directly from their database before migrating. See the [Exporting Users](docs/exporting-users.md) guide for setup, CLI options, and troubleshooting. + +```bash +bun export:supabase +``` + ## Migrating OAuth Connections OAuth connections can not be directly migrated. The creation of the connection requires the user to consent, which can't happen on a migration like this. Instead you can rely on Clerk's [Account Linking](https://clerk.com/docs/guides/configure/auth-strategies/social-connections/account-linking) to handle this. diff --git a/docs/exporting-users.md b/docs/exporting-users.md new file mode 100644 index 0000000..dbd5e4a --- /dev/null +++ b/docs/exporting-users.md @@ -0,0 +1,130 @@ +# Exporting Users + +Some platforms require exporting users directly from their database before migrating to Clerk. This guide covers the available export commands and how to use them. + +## Supabase + +The Supabase export connects directly to your Supabase Postgres database and exports users from the `auth.users` table. This is the recommended approach because it includes `encrypted_password` (bcrypt hashes), which are not available through the Supabase Admin API. + +### Prerequisites + +- A Supabase project with **Auth enabled** (Authentication section in the Supabase Dashboard) +- A Postgres connection string from your Supabase project + +### Getting Your Connection String + +1. Open your Supabase project dashboard +2. Click the **Connect** button +3. Copy one of the connection strings below + +**Pooler connection** (recommended): + +``` +postgres://postgres.[REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres +``` + +**Direct connection** (requires the IPv4 add-on): + +``` +postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres +``` + +Replace `[PASSWORD]` with your database password. If your password contains special characters (`@`, `#`, `%`, etc.), they must be URL-encoded (e.g. `@` becomes `%40`). + +### Environment Variables + +| Variable | Description | +| ----------------- | -------------------------- | +| `SUPABASE_DB_URL` | Postgres connection string | + +Add this to your `.env` file to skip the interactive prompt: + +```bash +SUPABASE_DB_URL=postgres://postgres.ref:password@aws-0-us-east-1.pooler.supabase.com:6543/postgres +``` + +### Usage + +```bash +bun export:supabase +``` + +If no connection string is found in environment variables, the CLI will prompt you to enter one. + +### CLI Options + +| Option | Description | +| ----------------- | --------------------------------------------------------- | +| `--db-url ` | Postgres connection string (takes priority over env vars) | +| `--output ` | Output file path (default: `supabase-export.json`) | + +```bash +# Specify connection string and output file +bun export:supabase --db-url "postgres://..." --output users.json +``` + +### Connection String Priority + +The export resolves the connection string in this order: + +1. `--db-url` CLI flag +2. `SUPABASE_DB_URL` environment variable +3. Interactive prompt + +### Output + +The export produces a JSON file (default: `supabase-export.json`) containing an array of user objects with the following fields: + +| Field | Description | +| -------------------- | ------------------------------------------------------------------ | +| `id` | Supabase user ID | +| `email` | User email address | +| `email_confirmed_at` | Email verification timestamp | +| `encrypted_password` | Bcrypt password hash | +| `phone` | Phone number | +| `phone_confirmed_at` | Phone verification timestamp | +| `first_name` | Extracted from `display_name`, `first_name`, or `name` in metadata | +| `last_name` | Extracted from `last_name` in metadata | +| `raw_user_meta_data` | Full user metadata object | +| `raw_app_meta_data` | Full app metadata object | +| `created_at` | Account creation timestamp | + +After exporting, a field coverage summary shows how many users have each field populated. + +### Next Step + +Once the export is complete, run the migration: + +```bash +bun migrate --transformer supabase --file supabase-export.json +``` + +### Troubleshooting + +#### ENOTFOUND — hostname could not be resolved + +The project ref in the connection string is incorrect. Verify it matches your Supabase project by checking the URL in your Supabase Dashboard. + +#### ETIMEDOUT or ENETUNREACH — connection timed out + +Direct connections (`db.[REF].supabase.co`) require the IPv4 add-on. Use a pooler connection instead, or enable IPv4 in Supabase Dashboard under Settings > Add-Ons. + +#### Authentication failed + +The database password is incorrect. Reset it in Supabase Dashboard under Settings > Database > Database Password. If the new password contains special characters, URL-encode them in the connection string. + +#### Could not read from auth.users + +The `auth.users` table is created automatically when Supabase Auth is enabled. Ensure Auth is enabled in Supabase Dashboard under Authentication, and that you are connecting with the `postgres` role (not an application-level role). + +#### Connection string cannot be parsed as a URL + +The connection string contains characters that break URL parsing. Ensure special characters in the password are URL-encoded: + +| Character | Encoded | +| --------- | ------- | +| `@` | `%40` | +| `#` | `%23` | +| `%` | `%25` | +| `?` | `%3F` | +| space | `%20` | diff --git a/src/export/index.ts b/src/export/index.ts index e9e3172..1863c67 100644 --- a/src/export/index.ts +++ b/src/export/index.ts @@ -9,35 +9,38 @@ * bun run export:supabase --db-url postgresql://... --output users.json * * Environment variables: - * SUPABASE_DB_URL - Postgres connection string (alternative to --db-url flag) + * SUPABASE_DB_URL - Postgres connection string + * + * Priority: --db-url flag > SUPABASE_DB_URL env var > interactive prompt */ import 'dotenv/config'; import * as p from '@clack/prompts'; import color from 'picocolors'; import { displayExportSummary, exportSupabaseUsers } from './supabase'; +import { isValidConnectionString, resolveConnectionString } from '../utils'; async function main() { p.intro(color.bgCyan(color.black('Supabase User Export'))); - // Parse CLI flags - const args = process.argv.slice(2); - let dbUrl = process.env.SUPABASE_DB_URL; - let outputFile = 'supabase-export.json'; + const { + dbUrl: resolvedUrl, + outputFile, + warning, + } = resolveConnectionString( + process.argv.slice(2), + process.env as Record + ); - for (let i = 0; i < args.length; i++) { - if (args[i] === '--db-url' && args[i + 1]) { - dbUrl = args[i + 1]; - i++; - } else if (args[i] === '--output' && args[i + 1]) { - outputFile = args[i + 1]; - i++; - } + let dbUrl = resolvedUrl; + + if (warning) { + p.log.warn(color.yellow(warning)); } - // Prompt for DB URL if not provided + // Prompt for connection string if not resolved from flag or env if (!dbUrl) { p.note( - `Find this in the Supabase Dashboard by clicking the ${color.bold('Connect')} button.\n\n${color.bold('Direct connection')} (requires IPv6):\n ${color.dim('postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres')}\n\n${color.bold('Pooler connection')} (works on IPv4 — use this if direct fails):\n ${color.dim('postgres://postgres.[REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres')}\n\n${color.dim('Alternatively, run the export SQL in the Supabase SQL Editor and save the result as JSON.')}`, + `Find this in the Supabase Dashboard by clicking the ${color.bold('Connect')} button.\n\n${color.bold('Direct connection')} (requires IPv4 add-on):\n ${color.dim('postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres')}\n\n${color.bold('Pooler connection')} (works without IPv4 add-on):\n ${color.dim('postgres://postgres.[REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres')}`, 'Connection String' ); @@ -49,10 +52,7 @@ async function main() { if (!value || value.trim() === '') { return 'Connection string is required'; } - if ( - !value.startsWith('postgresql://') && - !value.startsWith('postgres://') - ) { + if (!isValidConnectionString(value)) { return 'Must be a valid Postgres connection string (postgresql://...)'; } }, diff --git a/src/export/supabase.ts b/src/export/supabase.ts index e9a13d3..ce2c80b 100644 --- a/src/export/supabase.ts +++ b/src/export/supabase.ts @@ -72,10 +72,28 @@ export async function exportSupabaseUsers( await client.connect(); } catch (err) { const message = err instanceof Error ? err.message : String(err); + let hint: string; + if (message.includes('ENOTFOUND')) { + hint = + 'The hostname could not be resolved. Check the project ref in your connection string.'; + } else if ( + message.includes('ETIMEDOUT') || + message.includes('ENETUNREACH') + ) { + hint = + 'Direct connections require the IPv4 add-on. Use a pooler connection instead,\n' + + 'or enable IPv4 in Supabase Dashboard → Settings → Add-Ons.'; + } else if ( + message.includes('authentication failed') || + message.includes('password') + ) { + hint = 'Check the password in your connection string.'; + } else { + hint = + 'Verify your connection string and ensure your Supabase project is accessible.'; + } throw new Error( - `Failed to connect to Supabase database: ${message}\n\n` + - `Connection string format: postgresql://postgres:[PASSWORD]@db.[PROJECT_REF].supabase.co:5432/postgres\n` + - `Find this in Supabase Dashboard → Settings → Database → Connection string` + `Failed to connect to Supabase database: ${message}\n\n${hint}` ); } @@ -90,7 +108,24 @@ export async function exportSupabaseUsers( [key: string]: unknown; } - const { rows } = await client.query(EXPORT_QUERY); + let rows: SupabaseUserRow[]; + try { + ({ rows } = await client.query(EXPORT_QUERY)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if ( + message.includes('does not exist') || + message.includes('permission denied') + ) { + throw new Error( + `Could not read from auth.users: ${message}\n\n` + + 'The auth.users table is created automatically when Supabase Auth is enabled.\n' + + 'Ensure Auth is enabled in Supabase Dashboard → Authentication, and that\n' + + 'you are connecting with the postgres role (not an application-level role).' + ); + } + throw err; + } // Calculate field coverage const coverage = { diff --git a/src/migrate/cli.ts b/src/migrate/cli.ts index 51e970e..2fed246 100644 --- a/src/migrate/cli.ts +++ b/src/migrate/cli.ts @@ -1078,58 +1078,90 @@ interface ReadinessItem { section: 'identifiers' | 'auth' | 'social' | 'model'; } +interface ConfigStatus { + clerk: 'loaded' | 'failed' | 'skipped'; + supabase?: 'loaded' | 'failed' | 'skipped'; // undefined = not a supabase migration +} + /** * Displays a unified migration readiness report. * - * Cross-references the Supabase auth config, Clerk instance config, and user - * data to show a single report with all configuration items, their status, - * and affected user counts. + * Combines configuration check status, validation results, and user data + * analysis into a single report. Sections: * - * Items are grouped by section (Identifiers, Authentication, Social Connections, - * User Model) and each shows: - * - ✓ enabled in Clerk (green) - * - ✗ not enabled in Clerk (red, needs attention) - * - ⚠ required in Clerk but not all users have it (yellow, will cause failures) - * - ○ status unknown, enable in Dashboard (yellow, no Clerk config available) + * 1. **Clerk Configuration** — whether the config was loaded, failed, or skipped, + * with actionable guidance for non-success cases + * 2. **Supabase Configuration** (Supabase migrations only) — same pattern + * 3. **Import File** — total users, validation failures, and per-field readiness: + * - ✓ enabled in Clerk (green) + * - ✗ not enabled in Clerk (red, needs attention) + * - ⚠ required in Clerk but not all users have it (yellow, will cause failures) + * - ○ status unknown, enable in Dashboard (yellow, no Clerk config available) * * For identifiers (email, phone, username), provides actionable guidance on * whether the field can safely be required based on user coverage. * - * Also displays total user count and validation failure summary (with log file - * reference) so users can review errors before confirming the migration. - * * @param items - List of readiness items to display * @param analysis - Field analysis results for total user count and identifier check - * @param validation - Validation results: failure count and log file path (0 failures hides the section) + * @param configStatus - Whether Clerk/Supabase config checks succeeded, failed, or were skipped + * @param validation - Validation results: failure count and log file path (0 failures hides the line) */ export function displayCrossReference( items: ReadinessItem[], analysis: FieldAnalysis, + configStatus: ConfigStatus = { clerk: 'skipped' }, validation: { validationFailed: number; logFile: string } = { validationFailed: 0, logFile: '', } ): void { - const sections: Partial> = {}; - for (const item of items) { - if (!sections[item.section]) sections[item.section] = []; - sections[item.section].push(item); + let message = ''; + + // --- Clerk Configuration section --- + message += color.bold(color.whiteBright('Clerk Configuration\n')); + if (configStatus.clerk === 'loaded') { + message += ` ${color.green('✓')} ${color.dim('Configuration loaded from Clerk')}\n`; + } else if (configStatus.clerk === 'failed') { + message += ` ${color.yellow('⚠')} ${color.yellow('Could not fetch Clerk configuration')}\n`; + message += ` ${color.dim(' Verify your Clerk Dashboard settings match the report below')}\n`; + } else { + message += ` ${color.yellow('○')} ${color.dim('Add CLERK_PUBLISHABLE_KEY to .env and restart to enable automatic checking,')}\n`; + message += ` ${color.dim(' or verify your Clerk Dashboard settings match the report below')}\n`; } + message += '\n'; - const total = analysis.totalUsers; + // --- Supabase Configuration section (only for Supabase migrations) --- + if (configStatus.supabase !== undefined) { + message += color.bold(color.whiteBright('Supabase Configuration\n')); + if (configStatus.supabase === 'loaded') { + message += ` ${color.green('✓')} ${color.dim('Auth settings loaded from Supabase')}\n`; + } else if (configStatus.supabase === 'failed') { + message += ` ${color.yellow('⚠')} ${color.yellow('Could not fetch Supabase auth settings')}\n`; + message += ` ${color.dim(' Social connections were not checked')}\n`; + } else { + message += ` ${color.yellow('○')} ${color.dim('Add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY to .env')}\n`; + message += ` ${color.dim(' and restart to check social connections')}\n`; + } + message += '\n'; + } - let message = ''; - message += `${color.dim(`${total} user${total === 1 ? '' : 's'} in file`)}\n`; + // --- Import File section --- + const total = analysis.totalUsers; + message += color.bold(color.whiteBright('Import File\n')); + message += ` ${color.dim(`${total} user${total === 1 ? '' : 's'} in file`)}\n`; - // Show validation failures if any if (validation.validationFailed > 0) { - message += color.yellow( - `${validation.validationFailed} user${validation.validationFailed === 1 ? '' : 's'} failed validation and will be skipped — see ${color.dim(`logs/${validation.logFile}`)}` - ); - message += '\n'; + message += ` ${color.yellow(`${validation.validationFailed} user${validation.validationFailed === 1 ? '' : 's'} failed validation and will be skipped`)} — ${color.dim(`see logs/${validation.logFile}`)}\n`; } message += '\n'; + // --- Field readiness sections --- + const sections: Partial> = {}; + for (const item of items) { + if (!sections[item.section]) sections[item.section] = []; + sections[item.section].push(item); + } + const needsAttention: ReadinessItem[] = []; const sectionLabels: Record = { @@ -1596,6 +1628,21 @@ export async function runCLI(cliArgs?: CLIArgs) { providerCounts = analyzeUserProviders(filePath); } + // Determine config check outcomes for the readiness report + let clerkStatus: ConfigStatus['clerk'] = 'skipped'; + if (publishableKey) { + clerkStatus = clerkConfig ? 'loaded' : 'failed'; + } + const configStatus: ConfigStatus = { clerk: clerkStatus }; + + if (isSupabase) { + if (supabaseUrl && supabaseApiKey) { + configStatus.supabase = supabaseProviders ? 'loaded' : 'failed'; + } else { + configStatus.supabase = 'skipped'; + } + } + configSpinner.stop('Configuration checked'); // Step 6: Build cross-reference items @@ -1705,34 +1752,7 @@ export async function runCLI(cliArgs?: CLIArgs) { } // Step 7: Display unified cross-reference report - displayCrossReference(items, analysis, validation); - - // Show hint if configs couldn't be loaded - if (!clerkConfig && !publishableKey) { - p.log.info( - color.dim( - 'Set CLERK_PUBLISHABLE_KEY in .env to enable automatic configuration checking.' - ) - ); - } else if (!clerkConfig && publishableKey) { - p.log.warn(color.yellow('Could not fetch Clerk instance configuration.')); - } - - if (isSupabase && !supabaseProviders) { - if (supabaseUrl && supabaseApiKey) { - p.log.warn( - color.yellow( - 'Could not fetch Supabase auth settings. Social connections not checked.' - ) - ); - } else { - p.log.info( - color.dim( - 'Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in .env to check social connections.' - ) - ); - } - } + displayCrossReference(items, analysis, configStatus, validation); // Step 8: Show exclusion info and confirm if (excludedUserIds.size > 0) { diff --git a/src/utils.ts b/src/utils.ts index cfbff11..31f041b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -190,3 +190,74 @@ export function getRetryDelay( const delaySeconds = retryAfterSeconds || defaultDelayMs / 1000; return { delayMs, delaySeconds }; } + +/** + * Checks if a string is a valid Postgres connection string. + * + * Verifies the value starts with postgresql:// or postgres:// and is + * parseable as a URL. Passwords with special characters (like @, #, %) + * must be URL-encoded for the string to be valid. + * + * @param value - The string to check + * @returns true if the value is a parseable Postgres URL + */ +export const isValidConnectionString = (value: string): boolean => { + if (!value.startsWith('postgresql://') && !value.startsWith('postgres://')) { + return false; + } + try { + new URL(value); + return true; + } catch { + return false; + } +}; + +/** + * Resolves the database connection string from CLI args and environment variables. + * + * Priority: --db-url flag > SUPABASE_DB_URL env var > interactive prompt + * + * Returns the resolved URL and an optional warning if an env var was present + * but had an invalid format. + * + * @param cliArgs - Raw CLI arguments (process.argv.slice(2)) + * @param env - Environment variables to check + * @returns Object with resolved dbUrl (undefined if not found) and optional warning + */ +export function resolveConnectionString( + cliArgs: string[], + env: Record +): { + dbUrl: string | undefined; + outputFile: string; + warning: string | undefined; +} { + let dbUrl: string | undefined; + let outputFile = 'supabase-export.json'; + let warning: string | undefined; + + // Parse CLI flags + for (let i = 0; i < cliArgs.length; i++) { + if (cliArgs[i] === '--db-url' && cliArgs[i + 1]) { + dbUrl = cliArgs[i + 1]; + i++; + } else if (cliArgs[i] === '--output' && cliArgs[i + 1]) { + outputFile = cliArgs[i + 1]; + i++; + } + } + + // Fall back to env vars if no --db-url flag, validating format + if (!dbUrl) { + const envUrl = env.SUPABASE_DB_URL; + if (envUrl && isValidConnectionString(envUrl)) { + dbUrl = envUrl; + } else if (envUrl) { + warning = + 'Connection string from environment is not a valid Postgres URL — prompting instead.'; + } + } + + return { dbUrl, outputFile, warning }; +} diff --git a/tests/export.test.ts b/tests/export.test.ts new file mode 100644 index 0000000..b0bde20 --- /dev/null +++ b/tests/export.test.ts @@ -0,0 +1,262 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { isValidConnectionString, resolveConnectionString } from '../src/utils'; + +// Mock pg Client +const mockConnect = vi.fn(); +const mockQuery = vi.fn(); +const mockEnd = vi.fn(); + +vi.mock('pg', () => { + return { + Client: class MockClient { + connect = mockConnect; + query = mockQuery; + end = mockEnd; + }, + }; +}); + +// Mock fs.writeFileSync to avoid writing files during tests +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + default: { + ...(actual as Record), + writeFileSync: vi.fn(), + }, + }; +}); + +// ============================================================================ +// isValidConnectionString tests +// ============================================================================ + +describe('isValidConnectionString', () => { + test('accepts postgresql:// URLs', () => { + expect( + isValidConnectionString( + 'postgresql://postgres:password@db.abc.supabase.co:5432/postgres' + ) + ).toBe(true); + }); + + test('accepts postgres:// URLs', () => { + expect( + isValidConnectionString( + 'postgres://postgres.ref:password@aws-0-us-east-1.pooler.supabase.com:6543/postgres' + ) + ).toBe(true); + }); + + test('rejects non-postgres URLs', () => { + expect(isValidConnectionString('mysql://host:3306/db')).toBe(false); + }); + + test('rejects plain strings', () => { + expect(isValidConnectionString('not-a-url')).toBe(false); + }); + + test('rejects empty strings', () => { + expect(isValidConnectionString('')).toBe(false); + }); + + test('rejects http URLs', () => { + expect(isValidConnectionString('https://supabase.co')).toBe(false); + }); + + test('rejects postgres:// URLs that cannot be parsed', () => { + expect( + isValidConnectionString('postgresql://host with spaces:5432/db') + ).toBe(false); + }); + + test('accepts postgres:// URLs with URL-encoded special characters', () => { + expect( + isValidConnectionString( + 'postgresql://postgres:p%40ss%23word@db.abc.supabase.co:5432/postgres' + ) + ).toBe(true); + }); +}); + +// ============================================================================ +// resolveConnectionString tests +// ============================================================================ + +describe('resolveConnectionString', () => { + const validUrl = + 'postgresql://postgres:password@db.abc.supabase.co:5432/postgres'; + + test('uses --db-url flag when provided', () => { + const result = resolveConnectionString(['--db-url', validUrl], {}); + + expect(result.dbUrl).toBe(validUrl); + expect(result.warning).toBeUndefined(); + }); + + test('--db-url flag takes priority over env var', () => { + const envUrl = + 'postgresql://postgres:other@db.xyz.supabase.co:5432/postgres'; + const result = resolveConnectionString(['--db-url', validUrl], { + SUPABASE_DB_URL: envUrl, + }); + + expect(result.dbUrl).toBe(validUrl); + }); + + test('uses SUPABASE_DB_URL when no flag', () => { + const result = resolveConnectionString([], { + SUPABASE_DB_URL: validUrl, + }); + + expect(result.dbUrl).toBe(validUrl); + expect(result.warning).toBeUndefined(); + }); + + test('returns warning and undefined dbUrl when env var is not a valid Postgres URL', () => { + const result = resolveConnectionString([], { + SUPABASE_DB_URL: 'not-a-valid-url', + }); + + expect(result.dbUrl).toBeUndefined(); + expect(result.warning).toContain('not a valid Postgres URL'); + }); + + test('returns warning for invalid SUPABASE_DB_URL with https scheme', () => { + const result = resolveConnectionString([], { + SUPABASE_DB_URL: 'https://supabase.co/project', + }); + + expect(result.dbUrl).toBeUndefined(); + expect(result.warning).toContain('not a valid Postgres URL'); + }); + + test('returns undefined dbUrl and no warning when no env vars set', () => { + const result = resolveConnectionString([], {}); + + expect(result.dbUrl).toBeUndefined(); + expect(result.warning).toBeUndefined(); + }); + + test('parses --output flag', () => { + const result = resolveConnectionString( + ['--db-url', validUrl, '--output', 'custom.json'], + {} + ); + + expect(result.dbUrl).toBe(validUrl); + expect(result.outputFile).toBe('custom.json'); + }); + + test('defaults outputFile to supabase-export.json', () => { + const result = resolveConnectionString([], {}); + + expect(result.outputFile).toBe('supabase-export.json'); + }); +}); + +// ============================================================================ +// exportSupabaseUsers tests +// ============================================================================ + +describe('exportSupabaseUsers', () => { + const dbUrl = + 'postgresql://postgres:password@db.abc.supabase.co:5432/postgres'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Import dynamically so mocks are applied + async function getExportFn() { + const mod = await import('../src/export/supabase'); + return mod.exportSupabaseUsers; + } + + test('shows hostname hint for ENOTFOUND errors', async () => { + mockConnect.mockRejectedValue( + new Error('getaddrinfo ENOTFOUND db.abc.supabase.co') + ); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'hostname could not be resolved' + ); + }); + + test('shows IPv4 hint for ETIMEDOUT errors', async () => { + mockConnect.mockRejectedValue(new Error('connect ETIMEDOUT 1.2.3.4:5432')); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'IPv4 add-on' + ); + }); + + test('shows IPv4 hint for ENETUNREACH errors', async () => { + mockConnect.mockRejectedValue( + new Error('connect ENETUNREACH 1.2.3.4:5432') + ); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'IPv4 add-on' + ); + }); + + test('shows password hint for authentication errors', async () => { + mockConnect.mockRejectedValue( + new Error('password authentication failed for user "postgres"') + ); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'Check the password' + ); + }); + + test('shows generic hint for unknown connection errors', async () => { + mockConnect.mockRejectedValue(new Error('some unexpected error')); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'Verify your connection string' + ); + }); + + test('shows auth.users hint when table does not exist', async () => { + mockConnect.mockResolvedValue(undefined); + mockQuery.mockRejectedValue( + new Error('relation "auth.users" does not exist') + ); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'Auth is enabled in Supabase Dashboard' + ); + expect(mockEnd).toHaveBeenCalled(); + }); + + test('shows auth.users hint when permission is denied', async () => { + mockConnect.mockResolvedValue(undefined); + mockQuery.mockRejectedValue(new Error('permission denied for schema auth')); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'connecting with the postgres role' + ); + expect(mockEnd).toHaveBeenCalled(); + }); + + test('re-throws unrecognized query errors', async () => { + mockConnect.mockResolvedValue(undefined); + mockQuery.mockRejectedValue(new Error('syntax error in SQL')); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'syntax error in SQL' + ); + expect(mockEnd).toHaveBeenCalled(); + }); +}); diff --git a/tests/migrate/cli.test.ts b/tests/migrate/cli.test.ts index 9366410..98992b0 100644 --- a/tests/migrate/cli.test.ts +++ b/tests/migrate/cli.test.ts @@ -1115,10 +1115,15 @@ describe('displayCrossReference', () => { fieldCounts: {}, }; - displayCrossReference(items, analysis, { - validationFailed: 3, - logFile: 'migration-2025-01-01-120000.log', - }); + displayCrossReference( + items, + analysis, + { clerk: 'loaded' }, + { + validationFailed: 3, + logFile: 'migration-2025-01-01-120000.log', + } + ); expect(p.note).toHaveBeenCalledWith( expect.stringContaining('3 users failed validation'), @@ -1155,13 +1160,221 @@ describe('displayCrossReference', () => { fieldCounts: {}, }; + displayCrossReference( + items, + analysis, + { clerk: 'loaded' }, + { + validationFailed: 0, + logFile: '', + } + ); + + const noteCall = vi.mocked(p.note).mock.calls[0][0] as string; + expect(noteCall).not.toContain('failed validation'); + }); + + test('shows Clerk Configuration loaded when config succeeded', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis, { clerk: 'loaded' }); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('Configuration loaded from Clerk'), + 'Migration Readiness' + ); + }); + + test('shows Clerk Configuration error guidance when config failed', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis, { clerk: 'failed' }); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('Could not fetch Clerk configuration'), + 'Migration Readiness' + ); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('Verify your Clerk Dashboard settings'), + 'Migration Readiness' + ); + }); + + test('shows Clerk Configuration skipped guidance when no publishable key', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis, { clerk: 'skipped' }); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('CLERK_PUBLISHABLE_KEY'), + 'Migration Readiness' + ); + }); + + test('shows Supabase Configuration section only for supabase migrations', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + displayCrossReference(items, analysis, { - validationFailed: 0, - logFile: '', + clerk: 'loaded', + supabase: 'loaded', }); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('Supabase Configuration'), + 'Migration Readiness' + ); + }); + + test('does not show Supabase section for non-supabase migrations', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis, { clerk: 'loaded' }); + const noteCall = vi.mocked(p.note).mock.calls[0][0] as string; - expect(noteCall).not.toContain('failed validation'); + expect(noteCall).not.toContain('Supabase Configuration'); + }); + + test('shows Import File section with total users', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis, { clerk: 'loaded' }); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('Import File'), + 'Migration Readiness' + ); }); }); From 0e925ec3a1641fbb06f65dc0ebee12939232c6e3 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 19 Feb 2026 18:20:21 -0500 Subject: [PATCH 9/9] refactor: Supabase will import if 1+ provider enabled, user can bypass and import --- src/migrate/cli.ts | 96 +++++++++++++++++++++++++++++---------- src/types.ts | 1 + supabase-export.json | 55 ++++++++++++++++++++++ tests/migrate/cli.test.ts | 84 ++++++++++++++++++++++++++++++---- 4 files changed, 204 insertions(+), 32 deletions(-) create mode 100644 supabase-export.json diff --git a/src/migrate/cli.ts b/src/migrate/cli.ts index 2fed246..a371859 100644 --- a/src/migrate/cli.ts +++ b/src/migrate/cli.ts @@ -40,6 +40,7 @@ export type CLIArgs = { file?: string; resumeAfter?: string; skipPasswordRequirement: boolean; + skipUnsupportedProviders?: boolean; nonInteractive: boolean; help: boolean; // Authentication @@ -73,6 +74,7 @@ OPTIONS: -f, --file Path to the user data file (JSON or CSV) -r, --resume-after Resume migration after this user ID --require-password Only migrate users who have passwords (default: false) + --skip-unsupported-providers Skip users who only have unsupported providers (no prompt) -y, --yes Non-interactive mode (skip all confirmations) -h, --help Show this help message @@ -305,6 +307,9 @@ export function parseArgs(argv: string[]): CLIArgs { case '--require-password': args.skipPasswordRequirement = false; break; + case '--skip-unsupported-providers': + args.skipUnsupportedProviders = true; + break; case '--clerk-secret-key': args.clerkSecretKey = nextArg; i++; @@ -1027,27 +1032,31 @@ export function analyzeUserProviders(filePath: string): Record { } /** - * Finds user IDs that have any of the specified disabled social providers. + * Finds user IDs whose only providers are disabled social providers. * * Reads the raw export file and checks each user's raw_app_meta_data.providers. - * If a user has any provider in the disabled list, their ID is included in the result. + * A user is excluded only if ALL of their providers are disabled social providers — + * users with at least one supported provider (email, phone, or an enabled social + * provider) are never excluded. * * @param filePath - Path to the JSON export file * @param disabledProviders - List of provider names not enabled in Clerk (e.g., ['discord']) - * @returns Set of user IDs to exclude from import + * @returns Object with excluded user IDs and per-provider counts of exclusively-affected users */ export function findUsersWithDisabledProviders( filePath: string, disabledProviders: string[] -): Set { - if (disabledProviders.length === 0) return new Set(); +): { excludedIds: Set; exclusionsByProvider: Record } { + if (disabledProviders.length === 0) + return { excludedIds: new Set(), exclusionsByProvider: {} }; try { const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record< string, unknown >[]; - const excluded = new Set(); + const excludedIds = new Set(); + const exclusionsByProvider: Record = {}; const disabledSet = new Set(disabledProviders); for (const user of raw) { @@ -1057,14 +1066,23 @@ export function findUsersWithDisabledProviders( if (!appMeta?.providers) continue; const providers = appMeta.providers as string[]; - if (providers.some((p) => disabledSet.has(p))) { - excluded.add(user.id as string); + const hasSupportedProvider = providers.some( + (p) => IGNORED_PROVIDERS.has(p) || !disabledSet.has(p) + ); + + if (!hasSupportedProvider) { + excludedIds.add(user.id as string); + const disabledForUser = providers.filter((p) => disabledSet.has(p)); + for (const provider of disabledForUser) { + exclusionsByProvider[provider] = + (exclusionsByProvider[provider] || 0) + 1; + } } } - return excluded; + return { excludedIds, exclusionsByProvider }; } catch { - return new Set(); + return { excludedIds: new Set(), exclusionsByProvider: {} }; } } @@ -1718,14 +1736,14 @@ export async function runCLI(cliArgs?: CLIArgs) { } } - // Find users to exclude (those with disabled social providers) + // Find users to exclude (those whose only providers are disabled) let excludedUserIds = new Set(); + let exclusionsByProvider: Record = {}; if (isSupabase && disabledProviders.length > 0) { const filePath = createImportFilePath(initialArgs.file); - excludedUserIds = findUsersWithDisabledProviders( - filePath, - disabledProviders - ); + const result = findUsersWithDisabledProviders(filePath, disabledProviders); + excludedUserIds = result.excludedIds; + exclusionsByProvider = result.exclusionsByProvider; } // User model @@ -1754,16 +1772,43 @@ export async function runCLI(cliArgs?: CLIArgs) { // Step 7: Display unified cross-reference report displayCrossReference(items, analysis, configStatus, validation); - // Step 8: Show exclusion info and confirm + // Step 8: Show disabled providers and handle exclusion + let skipUnsupportedProviders: boolean | undefined; if (excludedUserIds.size > 0) { - const providerNames = disabledProviders - .map((p) => OAUTH_PROVIDER_LABELS[p] || p) - .join(', '); - p.log.warn( - color.yellow( - `${excludedUserIds.size} user${excludedUserIds.size === 1 ? '' : 's'} will be excluded — signed up via disabled social connection${disabledProviders.length === 1 ? '' : 's'} (${providerNames})` - ) - ); + let providerInfo = ''; + for (const provider of disabledProviders) { + const label = OAUTH_PROVIDER_LABELS[provider] || provider; + const count = exclusionsByProvider[provider] || 0; + if (count > 0) { + providerInfo += ` ${color.yellow('•')} ${label} — ${count} user${count === 1 ? '' : 's'}\n`; + } + } + providerInfo += `\n ${excludedUserIds.size} user${excludedUserIds.size === 1 ? '' : 's'} only signed up via disabled provider${excludedUserIds.size === 1 ? '' : 's'}`; + + p.note(providerInfo.trim(), 'Disabled Social Providers'); + + if (cliArgs?.skipUnsupportedProviders) { + p.log.info( + `Skipping ${excludedUserIds.size} user${excludedUserIds.size === 1 ? '' : 's'} (--skip-unsupported-providers)` + ); + } else { + const savedSkip = savedSettings.skipUnsupportedProviders; + const shouldSkip = await p.confirm({ + message: `Skip ${excludedUserIds.size} user${excludedUserIds.size === 1 ? '' : 's'} who only signed up via disabled providers?`, + initialValue: savedSkip ?? true, + }); + + if (p.isCancel(shouldSkip)) { + p.cancel('Migration cancelled.'); + process.exit(0); + } + + skipUnsupportedProviders = shouldSkip; + + if (!shouldSkip) { + excludedUserIds = new Set(); + } + } } const importCount = userCount - excludedUserIds.size; @@ -1797,6 +1842,9 @@ export async function runCLI(cliArgs?: CLIArgs) { saveSettings({ key: initialArgs.key, file: initialArgs.file, + ...(skipUnsupportedProviders !== undefined && { + skipUnsupportedProviders, + }), }); // Auto-determine skipPasswordRequirement: true if any users lack passwords diff --git a/src/types.ts b/src/types.ts index b505c08..27755bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -184,6 +184,7 @@ export type Settings = { key?: string; file?: string; firebaseHashConfig?: FirebaseHashConfig; + skipUnsupportedProviders?: boolean; }; /** diff --git a/supabase-export.json b/supabase-export.json new file mode 100644 index 0000000..e40100f --- /dev/null +++ b/supabase-export.json @@ -0,0 +1,55 @@ +[ + { + "id": "aa16f470-9851-48d5-8a5e-a617fd7c70a3", + "email": "royanger@gmail.com", + "email_confirmed_at": "2026-02-19T00:39:45.479Z", + "encrypted_password": "$2a$10$ijb754iZs7gepAKgZYBVjeW7Gr/rGxmudhLR9vlX1Kr.zTA/YoTDO", + "phone": null, + "phone_confirmed_at": null, + "first_name": null, + "last_name": null, + "raw_user_meta_data": { + "sub": "aa16f470-9851-48d5-8a5e-a617fd7c70a3", + "email": "royanger@gmail.com", + "email_verified": true, + "phone_verified": false + }, + "raw_app_meta_data": { + "provider": "email", + "providers": [ + "email" + ] + }, + "created_at": "2026-02-19T00:39:06.023Z" + }, + { + "id": "c5675a4c-b42f-445a-81c8-d158dec7f2c5", + "email": "roy@clerk.dev", + "email_confirmed_at": "2026-02-19T02:29:54.946Z", + "encrypted_password": "$2a$10$pkx6GxQIMMRC0XN4sAt.lus7u15pctzCfkRXP.WlDoMD6XeYA0OCy", + "phone": null, + "phone_confirmed_at": null, + "first_name": "Roy Anger", + "last_name": null, + "raw_user_meta_data": { + "iss": "https://api.github.com", + "sub": "24333362", + "name": "Roy Anger", + "email": "roy@royanger.com", + "full_name": "Roy Anger", + "user_name": "royanger", + "avatar_url": "https://avatars.githubusercontent.com/u/24333362?v=4", + "provider_id": "24333362", + "email_verified": true, + "phone_verified": false, + "preferred_username": "royanger" + }, + "raw_app_meta_data": { + "provider": "email", + "providers": [ + "github" + ] + }, + "created_at": "2026-02-19T02:29:38.771Z" + } +] diff --git a/tests/migrate/cli.test.ts b/tests/migrate/cli.test.ts index 98992b0..dd97e5e 100644 --- a/tests/migrate/cli.test.ts +++ b/tests/migrate/cli.test.ts @@ -636,7 +636,7 @@ describe('findUsersWithDisabledProviders', () => { vi.clearAllMocks(); }); - test('returns user IDs that have disabled providers', () => { + test('does not exclude users who have a supported provider alongside a disabled one', () => { const mockData = [ { id: 'user-1', raw_app_meta_data: { providers: ['email'] } }, { id: 'user-2', raw_app_meta_data: { providers: ['discord'] } }, @@ -650,24 +650,28 @@ describe('findUsersWithDisabledProviders', () => { const result = findUsersWithDisabledProviders('test.json', ['discord']); - expect(result).toEqual(new Set(['user-2', 'user-3'])); + // user-3 has email (supported) + discord (disabled) → NOT excluded + expect(result.excludedIds).toEqual(new Set(['user-2'])); + expect(result.exclusionsByProvider).toEqual({ discord: 1 }); }); - test('returns empty set when no disabled providers specified', () => { + test('returns empty result when no disabled providers specified', () => { const result = findUsersWithDisabledProviders('test.json', []); - expect(result).toEqual(new Set()); + expect(result.excludedIds).toEqual(new Set()); + expect(result.exclusionsByProvider).toEqual({}); expect(fs.readFileSync).not.toHaveBeenCalled(); }); - test('returns empty set for invalid file', () => { + test('returns empty result for invalid file', () => { vi.mocked(fs.readFileSync).mockImplementation(() => { throw new Error('File not found'); }); const result = findUsersWithDisabledProviders('missing.json', ['discord']); - expect(result).toEqual(new Set()); + expect(result.excludedIds).toEqual(new Set()); + expect(result.exclusionsByProvider).toEqual({}); }); test('handles multiple disabled providers', () => { @@ -687,7 +691,8 @@ describe('findUsersWithDisabledProviders', () => { 'twitter', ]); - expect(result).toEqual(new Set(['user-2', 'user-3'])); + expect(result.excludedIds).toEqual(new Set(['user-2', 'user-3'])); + expect(result.exclusionsByProvider).toEqual({ discord: 1, twitter: 1 }); }); test('skips users without raw_app_meta_data', () => { @@ -699,7 +704,70 @@ describe('findUsersWithDisabledProviders', () => { const result = findUsersWithDisabledProviders('test.json', ['discord']); - expect(result).toEqual(new Set(['user-2'])); + expect(result.excludedIds).toEqual(new Set(['user-2'])); + expect(result.exclusionsByProvider).toEqual({ discord: 1 }); + }); + + test('excludes users with only disabled providers', () => { + const mockData = [ + { id: 'user-1', raw_app_meta_data: { providers: ['github'] } }, + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = findUsersWithDisabledProviders('test.json', ['github']); + + expect(result.excludedIds).toEqual(new Set(['user-1'])); + expect(result.exclusionsByProvider).toEqual({ github: 1 }); + }); + + test('excludes users when all providers are disabled', () => { + const mockData = [ + { + id: 'user-1', + raw_app_meta_data: { providers: ['github', 'discord'] }, + }, + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = findUsersWithDisabledProviders('test.json', [ + 'github', + 'discord', + ]); + + expect(result.excludedIds).toEqual(new Set(['user-1'])); + expect(result.exclusionsByProvider).toEqual({ github: 1, discord: 1 }); + }); + + test('does not exclude users when one social provider is enabled', () => { + const mockData = [ + { + id: 'user-1', + raw_app_meta_data: { providers: ['github', 'google'] }, + }, + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = findUsersWithDisabledProviders('test.json', ['github']); + + // google is not in the disabled list → user has a supported provider + expect(result.excludedIds).toEqual(new Set()); + expect(result.exclusionsByProvider).toEqual({}); + }); + + test('does not exclude users with phone provider alongside disabled social', () => { + const mockData = [ + { + id: 'user-1', + raw_app_meta_data: { providers: ['phone', 'discord'] }, + }, + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = findUsersWithDisabledProviders('test.json', ['discord']); + + // phone is an ignored provider (always supported) + expect(result.excludedIds).toEqual(new Set()); + expect(result.exclusionsByProvider).toEqual({}); }); });