From e7e9f4780ec7df84759bd68fe928f8ef72f118fb Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Fri, 6 Feb 2026 16:25:23 -0600 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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,