diff --git a/README.md b/README.md index ee73edc..c8d273f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This repository contains a tool that takes a JSON file as input, containing a li ## Table of Contents - [Getting Started](#getting-started) +- [Exporting Users](#exporting-users) - [Migrating OAuth Connections](#migrating-oauth-connections) - [Handle Existing User IDs and Foreign Key Constraints](#handle-existing-user-ids-and-foreign-key-constraints) - [Configuration](#configuration) @@ -15,6 +16,7 @@ This repository contains a tool that takes a JSON file as input, containing a li ### Documentation +- [Exporting Users](docs/exporting-users.md) - [Schema Fields Reference](docs/schema-fields.md) - [Creating Custom Transformers](docs/creating-transformers.md) - [AI Migration Prompt](prompts/migration-prompt.md) @@ -169,6 +171,14 @@ bun migrate -y \ - `--file` (or `-f`) - `CLERK_SECRET_KEY` (via `--clerk-secret-key`, environment variable, or `.env` file) +## Exporting Users + +Some platforms require exporting users directly from their database before migrating. See the [Exporting Users](docs/exporting-users.md) guide for setup, CLI options, and troubleshooting. + +```bash +bun export:supabase +``` + ## Migrating OAuth Connections OAuth connections can not be directly migrated. The creation of the connection requires the user to consent, which can't happen on a migration like this. Instead you can rely on Clerk's [Account Linking](https://clerk.com/docs/guides/configure/auth-strategies/social-connections/account-linking) to handle this. diff --git a/bun.lock b/bun.lock index 5993ecd..855b0f2 100644 --- a/bun.lock +++ b/bun.lock @@ -6,18 +6,22 @@ "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", "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", @@ -37,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=="], @@ -119,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=="], @@ -199,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=="], @@ -213,6 +293,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=="], @@ -265,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=="], @@ -275,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=="], @@ -283,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=="], @@ -305,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=="], @@ -395,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=="], @@ -443,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=="], @@ -461,6 +555,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 +579,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=="], @@ -479,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=="], @@ -487,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=="], @@ -503,6 +629,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=="], @@ -517,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=="], @@ -559,12 +689,22 @@ "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=="], "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=="], @@ -575,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/docs/exporting-users.md b/docs/exporting-users.md new file mode 100644 index 0000000..dbd5e4a --- /dev/null +++ b/docs/exporting-users.md @@ -0,0 +1,130 @@ +# Exporting Users + +Some platforms require exporting users directly from their database before migrating to Clerk. This guide covers the available export commands and how to use them. + +## Supabase + +The Supabase export connects directly to your Supabase Postgres database and exports users from the `auth.users` table. This is the recommended approach because it includes `encrypted_password` (bcrypt hashes), which are not available through the Supabase Admin API. + +### Prerequisites + +- A Supabase project with **Auth enabled** (Authentication section in the Supabase Dashboard) +- A Postgres connection string from your Supabase project + +### Getting Your Connection String + +1. Open your Supabase project dashboard +2. Click the **Connect** button +3. Copy one of the connection strings below + +**Pooler connection** (recommended): + +``` +postgres://postgres.[REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres +``` + +**Direct connection** (requires the IPv4 add-on): + +``` +postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres +``` + +Replace `[PASSWORD]` with your database password. If your password contains special characters (`@`, `#`, `%`, etc.), they must be URL-encoded (e.g. `@` becomes `%40`). + +### Environment Variables + +| Variable | Description | +| ----------------- | -------------------------- | +| `SUPABASE_DB_URL` | Postgres connection string | + +Add this to your `.env` file to skip the interactive prompt: + +```bash +SUPABASE_DB_URL=postgres://postgres.ref:password@aws-0-us-east-1.pooler.supabase.com:6543/postgres +``` + +### Usage + +```bash +bun export:supabase +``` + +If no connection string is found in environment variables, the CLI will prompt you to enter one. + +### CLI Options + +| Option | Description | +| ----------------- | --------------------------------------------------------- | +| `--db-url ` | Postgres connection string (takes priority over env vars) | +| `--output ` | Output file path (default: `supabase-export.json`) | + +```bash +# Specify connection string and output file +bun export:supabase --db-url "postgres://..." --output users.json +``` + +### Connection String Priority + +The export resolves the connection string in this order: + +1. `--db-url` CLI flag +2. `SUPABASE_DB_URL` environment variable +3. Interactive prompt + +### Output + +The export produces a JSON file (default: `supabase-export.json`) containing an array of user objects with the following fields: + +| Field | Description | +| -------------------- | ------------------------------------------------------------------ | +| `id` | Supabase user ID | +| `email` | User email address | +| `email_confirmed_at` | Email verification timestamp | +| `encrypted_password` | Bcrypt password hash | +| `phone` | Phone number | +| `phone_confirmed_at` | Phone verification timestamp | +| `first_name` | Extracted from `display_name`, `first_name`, or `name` in metadata | +| `last_name` | Extracted from `last_name` in metadata | +| `raw_user_meta_data` | Full user metadata object | +| `raw_app_meta_data` | Full app metadata object | +| `created_at` | Account creation timestamp | + +After exporting, a field coverage summary shows how many users have each field populated. + +### Next Step + +Once the export is complete, run the migration: + +```bash +bun migrate --transformer supabase --file supabase-export.json +``` + +### Troubleshooting + +#### ENOTFOUND — hostname could not be resolved + +The project ref in the connection string is incorrect. Verify it matches your Supabase project by checking the URL in your Supabase Dashboard. + +#### ETIMEDOUT or ENETUNREACH — connection timed out + +Direct connections (`db.[REF].supabase.co`) require the IPv4 add-on. Use a pooler connection instead, or enable IPv4 in Supabase Dashboard under Settings > Add-Ons. + +#### Authentication failed + +The database password is incorrect. Reset it in Supabase Dashboard under Settings > Database > Database Password. If the new password contains special characters, URL-encode them in the connection string. + +#### Could not read from auth.users + +The `auth.users` table is created automatically when Supabase Auth is enabled. Ensure Auth is enabled in Supabase Dashboard under Authentication, and that you are connecting with the `postgres` role (not an application-level role). + +#### Connection string cannot be parsed as a URL + +The connection string contains characters that break URL parsing. Ensure special characters in the password are URL-encoded: + +| Character | Encoded | +| --------- | ------- | +| `@` | `%40` | +| `#` | `%23` | +| `%` | `%25` | +| `?` | `%3F` | +| space | `%20` | diff --git a/package.json b/package.json index 35ac998..c677105 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 .", @@ -27,18 +28,22 @@ "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", "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/prompts/migration-prompt.md b/prompts/migration-prompt.md index c2679b8..77c3a2a 100644 --- a/prompts/migration-prompt.md +++ b/prompts/migration-prompt.md @@ -19,11 +19,18 @@ Follow these steps EXACTLY in order. Do NOT skip any steps. ### Step 1: Verify Environment -Before doing ANYTHING else: +Before proceeding, check if dependencies are installed. If not: +1. Use `bun install` to install dependencies. + +After confirming dependencies are installed and before doing ANYTHING else: +1. Check if dependencies are installed, and if not use `bun install` to install dependencies. 1. Check if `.env` file exists with `CLERK_SECRET_KEY` -2. If missing, IMMEDIATELY ask for the key (Clerk Dashboard → API Keys → Secret keys, or https://dashboard.clerk.com/~/api-keys) -3. Create/update the `.env` file with the provided key -4. Do NOT proceed until the key is configured +1. If missing, IMMEDIATELY ask for the key (Clerk Dashboard → API Keys → Secret keys, or https://dashboard.clerk.com/~/api-keys) +1. Create/update the `.env` file with the provided key +1. Do NOT proceed until the key is configured + + +**DO NOT** move to step 1 until the dependenices are installed and the `CLERK_SECRET_KEY` is present in the `.env` file ### Step 2: Analyze the Data File @@ -40,9 +47,10 @@ Read a sample of the file to understand its structure. Look for signature fields ### Step 3A: If a Transformer Matches 1. Tell me which transformer will be used -2. Summarize the field mappings that will be applied -3. Ask if I want to proceed with the migration -4. If confirmed, run: +1. Summarize the field mappings that will be applied +1. Use `displayCrossReference()` and related code to display a mnigration readiness table to the user. +1. Ask if I want to proceed with the migration +1. If confirmed, run: ```bash bun migrate -y --transformer [transformer-key] --file [file-path] ``` @@ -52,19 +60,11 @@ Read a sample of the file to understand its structure. Look for signature fields If the data doesn't match any existing transformer, you MUST: 1. **Inform the user**: Explain that no existing transformer matches their data format -2. **List the fields found**: Show all fields discovered in their data file -3. **Create a custom transformer**: Generate a transformer file at `src/transformers/[platform-name].ts` using the 'transformer' skill or the `prompts/transformer-prompt.md` -4. **MANDATORY - Register the transformer**: - - Add an import to `src/transformers/index.ts` - - Add the transformer to the `transformers` array - - **THIS STEP IS NOT OPTIONAL.** If you skip registration: - - The transformer will NOT appear in the CLI's platform selection - - The `bun delete` command will NOT find migrated users - - Users will see "Found 0 migrated users to delete" after migration - -5. **Run tests**: Execute `bun run test` to verify the transformer is properly registered -6. **Run the migration**: After tests pass, run the migration command +1. **List the fields found**: Show all fields discovered in their data file +1. **Create a custom transformer**: Use the `transformer` skill. If the `transformer` skill is not available use `.claude/skills/transformer/SKILL.md` or `prompts/transformer-prompt.md`. Do not try and create a transformer without using the skill or prompt. +1. **Run tests**: Execute `bun run test` to verify the transformer is properly registered +1. Use `displayCrossReference()` and related code to display a mnigration readiness table to the user. Always display this after any field mapping summary. +1. **Run the migration**: After tests pass, run the migration command ### Step 4: Post-Migration Verification diff --git a/prompts/transformer-prompt.md b/prompts/transformer-prompt.md index ea436ba..ad4c8d0 100644 --- a/prompts/transformer-prompt.md +++ b/prompts/transformer-prompt.md @@ -13,10 +13,13 @@ I need to create a custom transformer for the Clerk user migration tool. Please ## Environment Setup +Before proceeding, check if dependencies are installed. If not: +1. Use `bun install` to install dependencies. + Before generating the transformer, check if a `.env` file exists with `CLERK_SECRET_KEY`. If not: 1. Ask the user to provide their CLERK_SECRET_KEY (found in Clerk Dashboard → API Keys → Secret keys) -2. Create the `.env` file with the provided key -3. Continue with the transformer generation without stopping +1. Create the `.env` file with the provided key +1. Continue with the transformer generation without stopping Do not ask "would you like me to create one?" - just ask for the key directly and create the file. @@ -31,7 +34,7 @@ Do not ask "would you like me to create one?" - just ask for the key directly an - If not, ask me for the key (found in Clerk Dashboard → API Keys → Secret keys, or https://dashboard.clerk.com/~/api-keys) - Create/update the `.env` file with the key -2. Analyze the JSON/CSV structure to identify: +1. Analyze the JSON/CSV structure to identify: - User ID field (maps to `userId`) - Email field(s) and verification status - Phone field(s) and verification status @@ -39,7 +42,7 @@ Do not ask "would you like me to create one?" - just ask for the key directly an - Password field and hash algorithm - Any metadata fields -3. Generate a complete transformer file following this structure: +1. Generate a complete transformer file following this structure: ```typescript // src/transformers/[platform-name].ts @@ -61,7 +64,7 @@ const [platformName]Transformer = { export default [platformName]Transformer; ``` -4. **CRITICAL - Register the transformer**: After creating the transformer file, you MUST register it in `src/transformers/index.ts`. This is NOT optional. The migration and delete commands will fail silently if the transformer is not registered. +1. **CRITICAL - Register the transformer**: After creating the transformer file, you MUST register it in `src/transformers/index.ts`. This is NOT optional. The migration and delete commands will fail silently if the transformer is not registered. Add both an import and include it in the exports array: @@ -80,6 +83,17 @@ export default [platformName]Transformer; - The transformer will NOT appear in the CLI's platform selection - The `bun delete` command will NOT be able to find migrated users - Users will see "Found 0 migrated users to delete" even after successful migration + + **THIS STEP IS NOT OPTIONAL.** If you skip registration: + - The transformer will NOT appear in the CLI's platform selection + - The `bun delete` command will NOT find migrated users + - Users will see "Found 0 migrated users to delete" after migration + +1. **Run tests**: Execute `bun run test` to verify the transformer is properly registered +1. Use `displayCrossReference()` and related code to display a mnigration readiness table to the user. Always display this after any field mapping summary. +1. **Run the migration**: After tests pass, run the migration command + + ```` ## Questions to Answer @@ -194,14 +208,14 @@ After generating your transformer, verify these steps were completed: ### 1. Transformer File Created -- [ ] File exists at `src/transformers/[platform-name].ts` -- [ ] Has a default export with `key`, `value`, `label`, `description`, and `transformer` fields -- [ ] The `transformer` object maps source fields to Clerk fields (including a field that maps to `userId`) +- \[ ] File exists at `src/transformers/[platform-name].ts` +- \[ ] Has a default export with `key`, `value`, `label`, `description`, and `transformer` fields +- \[ ] The `transformer` object maps source fields to Clerk fields (including a field that maps to `userId`) ### 2. Transformer Registered (CRITICAL) -- [ ] Import added to `src/transformers/index.ts` -- [ ] Transformer added to the `transformers` array export +- \[ ] Import added to `src/transformers/index.ts` +- \[ ] Transformer added to the `transformers` array export **If you skip registration, the delete command will fail to find migrated users!** diff --git a/src/export/index.ts b/src/export/index.ts new file mode 100644 index 0000000..1863c67 --- /dev/null +++ b/src/export/index.ts @@ -0,0 +1,93 @@ +/** + * 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 + * + * Priority: --db-url flag > SUPABASE_DB_URL env var > interactive prompt + */ +import 'dotenv/config'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; +import { displayExportSummary, exportSupabaseUsers } from './supabase'; +import { isValidConnectionString, resolveConnectionString } from '../utils'; + +async function main() { + p.intro(color.bgCyan(color.black('Supabase User Export'))); + + const { + dbUrl: resolvedUrl, + outputFile, + warning, + } = resolveConnectionString( + process.argv.slice(2), + process.env as Record + ); + + let dbUrl = resolvedUrl; + + if (warning) { + p.log.warn(color.yellow(warning)); + } + + // Prompt for connection string if not resolved from flag or env + if (!dbUrl) { + p.note( + `Find this in the Supabase Dashboard by clicking the ${color.bold('Connect')} button.\n\n${color.bold('Direct connection')} (requires IPv4 add-on):\n ${color.dim('postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres')}\n\n${color.bold('Pooler connection')} (works without IPv4 add-on):\n ${color.dim('postgres://postgres.[REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres')}`, + 'Connection String' + ); + + 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 (!isValidConnectionString(value)) { + 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); + } +} + +void main(); diff --git a/src/export/supabase.ts b/src/export/supabase.ts new file mode 100644 index 0000000..ce2c80b --- /dev/null +++ b/src/export/supabase.ts @@ -0,0 +1,198 @@ +/** + * 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, + raw_app_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); + let hint: string; + if (message.includes('ENOTFOUND')) { + hint = + 'The hostname could not be resolved. Check the project ref in your connection string.'; + } else if ( + message.includes('ETIMEDOUT') || + message.includes('ENETUNREACH') + ) { + hint = + 'Direct connections require the IPv4 add-on. Use a pooler connection instead,\n' + + 'or enable IPv4 in Supabase Dashboard → Settings → Add-Ons.'; + } else if ( + message.includes('authentication failed') || + message.includes('password') + ) { + hint = 'Check the password in your connection string.'; + } else { + hint = + 'Verify your connection string and ensure your Supabase project is accessible.'; + } + throw new Error( + `Failed to connect to Supabase database: ${message}\n\n${hint}` + ); + } + + try { + 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; + } + + let rows: SupabaseUserRow[]; + try { + ({ rows } = await client.query(EXPORT_QUERY)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if ( + message.includes('does not exist') || + message.includes('permission denied') + ) { + throw new Error( + `Could not read from auth.users: ${message}\n\n` + + 'The auth.users table is created automatically when Supabase Auth is enabled.\n' + + 'Ensure Auth is enabled in Supabase Dashboard → Authentication, and that\n' + + 'you are connecting with the postgres role (not an application-level role).' + ); + } + throw err; + } + + // Calculate field coverage + const coverage = { + 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 the export results as a field coverage report and success message. + * + * Shows each field with an icon indicating coverage level: + * - ● green — all users have this field + * - ○ yellow — some users have this field + * - ○ dim — no users have this field + * + * @param result - Export result containing user count, output path, and per-field coverage stats + */ +export function displayExportSummary(result: ExportResult): void { + const { userCount, outputPath, fieldCoverage } = result; + + /** Returns a colored icon based on how many users have a given field. */ + const getCoverageIcon = (count: number, total: number): string => { + if (count === total) return color.green('●'); + if (count > 0) return color.yellow('○'); + return color.dim('○'); + }; + + let summary = ''; + summary += `${getCoverageIcon(fieldCoverage.email, userCount)} ${color.dim(`${fieldCoverage.email}/${userCount} have email`)}\n`; + summary += `${getCoverageIcon(fieldCoverage.emailConfirmed, userCount)} ${color.dim(`${fieldCoverage.emailConfirmed}/${userCount} email confirmed`)}\n`; + summary += `${getCoverageIcon(fieldCoverage.password, userCount)} ${color.dim(`${fieldCoverage.password}/${userCount} have password hash`)}\n`; + summary += `${getCoverageIcon(fieldCoverage.phone, userCount)} ${color.dim(`${fieldCoverage.phone}/${userCount} have phone`)}\n`; + summary += `${getCoverageIcon(fieldCoverage.firstName, userCount)} ${color.dim(`${fieldCoverage.firstName}/${userCount} have first name`)}\n`; + summary += `${getCoverageIcon(fieldCoverage.lastName, userCount)} ${color.dim(`${fieldCoverage.lastName}/${userCount} have last name`)}`; + + p.note(summary, 'Field Coverage'); + p.log.success( + `Exported ${color.bold(String(userCount))} users to ${color.dim(outputPath)}` + ); +} diff --git a/src/migrate/cli.ts b/src/migrate/cli.ts index ca1e19d..a371859 100644 --- a/src/migrate/cli.ts +++ b/src/migrate/cli.ts @@ -12,6 +12,7 @@ import { import { checkIfFileExists, createImportFilePath, + getDateTimeStamp, getFileType, transformKeys as transformKeysFromFunctions, tryCatch, @@ -28,6 +29,8 @@ import type { IdentifierCounts, Settings, } from '../types'; +import { userSchema } from './validator'; +import { validationLogger } from '../logger'; /** * Parsed command-line arguments for the migration tool @@ -37,6 +40,7 @@ export type CLIArgs = { file?: string; resumeAfter?: string; skipPasswordRequirement: boolean; + skipUnsupportedProviders?: boolean; nonInteractive: boolean; help: boolean; // Authentication @@ -70,6 +74,7 @@ OPTIONS: -f, --file Path to the user data file (JSON or CSV) -r, --resume-after Resume migration after this user ID --require-password Only migrate users who have passwords (default: false) + --skip-unsupported-providers Skip users who only have unsupported providers (no prompt) -y, --yes Non-interactive mode (skip all confirmations) -h, --help Show this help message @@ -302,6 +307,9 @@ export function parseArgs(argv: string[]): CLIArgs { case '--require-password': args.skipPasswordRequirement = false; break; + case '--skip-unsupported-providers': + args.skipUnsupportedProviders = true; + break; case '--clerk-secret-key': args.clerkSecretKey = nextArg; i++; @@ -408,6 +416,7 @@ export async function runNonInteractive(args: CLIArgs): Promise<{ instance: 'dev' | 'prod'; begin: boolean; skipPasswordRequirement: boolean; + excludedUserIds: Set; }> { // Handle help flag if (args.help) { @@ -558,13 +567,9 @@ export async function runNonInteractive(args: CLIArgs): Promise<{ instance: instanceType, begin: true, skipPasswordRequirement, + excludedUserIds: new Set(), }; } -/* eslint-enable no-console */ - -const DASHBOARD_CONFIGURATION = color.bold( - color.whiteBright('Dashboard Configuration:\n') -); /** * Detects whether the Clerk instance is development or production based on the secret key @@ -802,271 +807,453 @@ export function analyzeFields(users: Record[]): FieldAnalysis { } /** - * 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 + * Validates users against the schema and logs validation errors. * - * 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) + * Runs before the readiness display so users can see the validation failure + * count and review the log file before confirming the migration. * - * Uses color coding: green for complete coverage, yellow for partial, red for missing. + * Applies transformer default fields (e.g., Supabase passwordHasher: "bcrypt") + * before validation to match the behavior of the full import pipeline. * - * @param analysis - The field analysis results + * @param users - Array of transformed user objects from loadRawUsers() + * @param transformerKey - Transformer key to look up default fields + * @returns Object with validation failure count and log file path */ -export function displayIdentifierAnalysis(analysis: FieldAnalysis): void { - const { identifiers, totalUsers } = analysis; - - let identifierMessage = ''; - - // Show counts for each identifier type - identifierMessage += color.bold(color.whiteBright('Identifier Analysis:\n')); +export function validateUsers( + users: Record[], + transformerKey: string +): { validationFailed: number; logFile: string } { + const dateTime = getDateTimeStamp(); + const logFile = `migration-${dateTime}.log`; + let validationFailed = 0; + + // Look up transformer defaults (e.g., Supabase adds passwordHasher: "bcrypt") + const transformer = transformers.find((obj) => obj.key === transformerKey); + const defaultFields = + transformer && 'defaults' in transformer ? transformer.defaults : null; + + for (let i = 0; i < users.length; i++) { + const user = defaultFields ? { ...users[i], ...defaultFields } : users[i]; + const result = userSchema.safeParse(user); + + if (!result.success) { + validationFailed++; + const firstIssue = result.error.issues[0]; + validationLogger( + { + error: firstIssue.message, + path: firstIssue.path as (string | number)[], + userId: (user.userId as string) || `row-${i}`, + row: i, + }, + dateTime + ); + } + } - // 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('○'); - }; + return { validationFailed, logFile }; +} - 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`; +// Maps Supabase provider keys to human-readable labels +const OAUTH_PROVIDER_LABELS: Record = { + google: 'Google', + apple: 'Apple', + github: 'GitHub', + facebook: 'Facebook', + twitter: 'Twitter (X)', + discord: 'Discord', + spotify: 'Spotify', + slack: 'Slack', + slack_oidc: 'Slack (OIDC)', + twitch: 'Twitch', + linkedin: 'LinkedIn', + linkedin_oidc: 'LinkedIn (OIDC)', + bitbucket: 'Bitbucket', + gitlab: 'GitLab', + azure: 'Microsoft (Azure)', + kakao: 'Kakao', + notion: 'Notion', + zoom: 'Zoom', + keycloak: 'Keycloak', + figma: 'Figma', + fly: 'Fly.io', + workos: 'WorkOS', + snapchat: 'Snapchat', +}; - // 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`; - } +// Non-OAuth entries in the Supabase external config to ignore +const IGNORED_PROVIDERS = new Set(['email', 'phone', 'anonymous_users']); - // 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'); - } +interface SupabaseAuthSettings { + external?: Record; +} - // Dashboard configuration advice - identifierMessage += '\n'; - identifierMessage += DASHBOARD_CONFIGURATION; +/** + * Fetches the Supabase project's auth settings to determine which OAuth providers are enabled. + * + * 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.). + * + * @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 async function fetchSupabaseProviders( + supabaseUrl: string, + apiKey: string +): Promise { + try { + const url = `${supabaseUrl.replace(/\/$/, '')}/auth/v1/settings`; + const res = await fetch(url, { + headers: { apikey: apiKey }, + }); - const requiredIdentifiers: string[] = []; - const optionalIdentifiers: string[] = []; + if (!res.ok) { + return null; + } - // Only consider users that will actually be imported (have at least one identifier) - const importableUsers = identifiers.hasAnyIdentifier; + const settings = (await res.json()) as SupabaseAuthSettings; + if (!settings.external) { + return null; + } - if (identifiers.verifiedEmails === importableUsers) { - requiredIdentifiers.push('Email'); - } else if (identifiers.verifiedEmails > 0) { - optionalIdentifiers.push('Email'); + return Object.entries(settings.external) + .filter(([key, enabled]) => enabled && !IGNORED_PROVIDERS.has(key)) + .map(([key]) => key); + } catch { + return null; } +} - if (identifiers.verifiedPhones === importableUsers) { - requiredIdentifiers.push('Phone'); - } else if (identifiers.verifiedPhones > 0) { - optionalIdentifiers.push('Phone'); - } +// --- Clerk Instance Configuration --- - if (identifiers.username === importableUsers) { - requiredIdentifiers.push('Username'); - } else if (identifiers.username > 0) { - optionalIdentifiers.push('Username'); - } +interface ClerkConfig { + attributes: Partial>; + social: Partial>; +} - if (requiredIdentifiers.length > 0) { - identifierMessage += ` ${color.green('●')} ${color.bold(color.whiteBright(requiredIdentifiers.join(', ')))}: ${color.dim('Enable and optionally require in the Dashboard')}\n`; +/** + * Decodes a Clerk publishable key to extract the frontend API hostname. + * + * Format: pk_test_ or pk_live_ + * The base64 payload decodes to a hostname ending with '$'. + * + * @param key - The Clerk publishable key + * @returns The frontend API hostname, or null if decoding fails + */ +function decodePublishableKey(key: string): string | null { + if (!key.startsWith('pk_test_') && !key.startsWith('pk_live_')) { + return null; } - if (optionalIdentifiers.length > 0) { - identifierMessage += ` ${color.yellow('○')} ${color.bold(color.whiteBright(optionalIdentifiers.join(', ')))}: Enable in the Dashboard but do not require\n`; + 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; } - - p.note(identifierMessage.trim(), 'Identifiers'); } /** - * Displays password analysis and prompts for migration preference + * Fetches the Clerk instance configuration via the Frontend API. * - * 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. + * 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 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 + * @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 displayPasswordAnalysis( - analysis: FieldAnalysis -): Promise { - const { totalUsers, fieldCounts } = analysis; - const usersWithPasswords = fieldCounts.password || 0; +export async function fetchClerkConfig( + publishableKey: string +): Promise { + const frontendApi = decodePublishableKey(publishableKey); + if (!frontendApi) return null; - // 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; - } + try { + const res = await fetch(`https://${frontendApi}/v1/environment`); + if (!res.ok) return null; - let passwordMessage = ''; + const data = (await res.json()) as { + user_settings?: { + attributes?: Record; + social?: Record; + }; + }; + const userSettings = data.user_settings; + if (!userSettings) return null; - if (usersWithPasswords === totalUsers) { - passwordMessage += `${color.green('●')} All users have passwords\n`; - } else { - passwordMessage += `${color.yellow('○')} ${usersWithPasswords} of ${totalUsers} users have passwords\n`; + return { + attributes: userSettings.attributes || {}, + social: userSettings.social || {}, + }; + } catch { + return null; } +} - passwordMessage += '\n'; - passwordMessage += DASHBOARD_CONFIGURATION; - passwordMessage += ` ${color.green('●')} ${color.bold(color.whiteBright('Password'))}: Enable in Dashboard\n`; +/** + * 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; + } + } - p.note(passwordMessage.trim(), 'Password'); + return counts; + } catch { + return {}; + } +} - // 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, - }); +/** + * Finds user IDs whose only providers are disabled social providers. + * + * Reads the raw export file and checks each user's raw_app_meta_data.providers. + * A user is excluded only if ALL of their providers are disabled social providers — + * users with at least one supported provider (email, phone, or an enabled social + * provider) are never excluded. + * + * @param filePath - Path to the JSON export file + * @param disabledProviders - List of provider names not enabled in Clerk (e.g., ['discord']) + * @returns Object with excluded user IDs and per-provider counts of exclusively-affected users + */ +export function findUsersWithDisabledProviders( + filePath: string, + disabledProviders: string[] +): { excludedIds: Set; exclusionsByProvider: Record } { + if (disabledProviders.length === 0) + return { excludedIds: new Set(), exclusionsByProvider: {} }; + + try { + const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record< + string, + unknown + >[]; + const excludedIds = new Set(); + const exclusionsByProvider: Record = {}; + 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[]; + const hasSupportedProvider = providers.some( + (p) => IGNORED_PROVIDERS.has(p) || !disabledSet.has(p) + ); - if (p.isCancel(migrateWithoutPassword)) { - return null; // User cancelled + if (!hasSupportedProvider) { + excludedIds.add(user.id as string); + const disabledForUser = providers.filter((p) => disabledSet.has(p)); + for (const provider of disabledForUser) { + exclusionsByProvider[provider] = + (exclusionsByProvider[provider] || 0) + 1; + } + } } - return migrateWithoutPassword; + return { excludedIds, exclusionsByProvider }; + } catch { + return { excludedIds: new Set(), exclusionsByProvider: {} }; } +} - return false; // All users have passwords, no need for skipPasswordRequirement +// --- Cross-Reference Display --- + +interface ReadinessItem { + label: string; + userCount: number; + clerkEnabled: boolean | null; // null = Clerk config not available + clerkRequired: boolean | null; // null = Clerk config not available + section: 'identifiers' | 'auth' | 'social' | 'model'; +} + +interface ConfigStatus { + clerk: 'loaded' | 'failed' | 'skipped'; + supabase?: 'loaded' | 'failed' | 'skipped'; // undefined = not a supabase migration } /** - * Displays user model analysis (first/last name) and Dashboard configuration guidance + * Displays a unified migration readiness report. + * + * Combines configuration check status, validation results, and user data + * analysis into a single report. Sections: + * + * 1. **Clerk Configuration** — whether the config was loaded, failed, or skipped, + * with actionable guidance for non-success cases + * 2. **Supabase Configuration** (Supabase migrations only) — same pattern + * 3. **Import File** — total users, validation failures, and per-field readiness: + * - ✓ enabled in Clerk (green) + * - ✗ not enabled in Clerk (red, needs attention) + * - ⚠ required in Clerk but not all users have it (yellow, will cause failures) + * - ○ status unknown, enable in Dashboard (yellow, no Clerk config available) * - * Shows how many users have first and last names and provides recommendations - * for Dashboard configuration (required vs optional vs disabled). + * For identifiers (email, phone, username), provides actionable guidance on + * whether the field can safely be required based on user coverage. * - * @param analysis - The field analysis results - * @returns true if users have name data and confirmation is needed, false otherwise + * @param items - List of readiness items to display + * @param analysis - Field analysis results for total user count and identifier check + * @param configStatus - Whether Clerk/Supabase config checks succeeded, failed, or were skipped + * @param validation - Validation results: failure count and log file path (0 failures hides the line) */ -export 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`; +export function displayCrossReference( + items: ReadinessItem[], + analysis: FieldAnalysis, + configStatus: ConfigStatus = { clerk: 'skipped' }, + validation: { validationFailed: number; logFile: string } = { + validationFailed: 0, + logFile: '', + } +): void { + let message = ''; + + // --- Clerk Configuration section --- + message += color.bold(color.whiteBright('Clerk Configuration\n')); + if (configStatus.clerk === 'loaded') { + message += ` ${color.green('✓')} ${color.dim('Configuration loaded from Clerk')}\n`; + } else if (configStatus.clerk === 'failed') { + message += ` ${color.yellow('⚠')} ${color.yellow('Could not fetch Clerk configuration')}\n`; + message += ` ${color.dim(' Verify your Clerk Dashboard settings match the report below')}\n`; } else { - nameMessage += `${color.dim('○')} No users have first and last names\n`; + message += ` ${color.yellow('○')} ${color.dim('Add CLERK_PUBLISHABLE_KEY to .env and restart to enable automatic checking,')}\n`; + message += ` ${color.dim(' or verify your Clerk Dashboard settings match the report below')}\n`; + } + message += '\n'; + + // --- Supabase Configuration section (only for Supabase migrations) --- + if (configStatus.supabase !== undefined) { + message += color.bold(color.whiteBright('Supabase Configuration\n')); + if (configStatus.supabase === 'loaded') { + message += ` ${color.green('✓')} ${color.dim('Auth settings loaded from Supabase')}\n`; + } else if (configStatus.supabase === 'failed') { + message += ` ${color.yellow('⚠')} ${color.yellow('Could not fetch Supabase auth settings')}\n`; + message += ` ${color.dim(' Social connections were not checked')}\n`; + } else { + message += ` ${color.yellow('○')} ${color.dim('Add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY to .env')}\n`; + message += ` ${color.dim(' and restart to check social connections')}\n`; + } + message += '\n'; } - nameMessage += '\n'; - nameMessage += DASHBOARD_CONFIGURATION; + // --- Import File section --- + const total = analysis.totalUsers; + message += color.bold(color.whiteBright('Import File\n')); + message += ` ${color.dim(`${total} user${total === 1 ? '' : 's'} in file`)}\n`; - 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`; + if (validation.validationFailed > 0) { + message += ` ${color.yellow(`${validation.validationFailed} user${validation.validationFailed === 1 ? '' : 's'} failed validation and will be skipped`)} — ${color.dim(`see logs/${validation.logFile}`)}\n`; } + message += '\n'; - p.note(nameMessage.trim(), 'User Model'); + // --- Field readiness sections --- + const sections: Partial> = {}; + for (const item of items) { + if (!sections[item.section]) sections[item.section] = []; + sections[item.section].push(item); + } - // Return true if confirmation is needed (when users have name data) - return someUsersHaveNames; -}; + const needsAttention: ReadinessItem[] = []; -/** - * Displays analysis of other fields (excluding identifiers, password, and names) - * - * Shows fields like TOTP Secret that are present on all or some users, - * with Dashboard configuration guidance. - * - * @param analysis - The field analysis results - * @returns true if there are other fields to display, false otherwise - */ -export const displayOtherFieldsAnalysis = ( - 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) - ); + const sectionLabels: Record = { + identifiers: 'Identifiers', + auth: 'Authentication', + social: 'Social Connections', + model: 'User Model', + }; - let fieldsMessage = ''; + const sectionOrder = ['identifiers', 'auth', 'social', '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)}`; + 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 allUsers = item.userCount === total; + const countStr = allUsers + ? 'all users' + : `${item.userCount}/${total} users`; + const isIdentifier = item.section === 'identifiers'; + + if (item.clerkEnabled === true) { + // Enabled + required but not all users have it → will cause import failures + if (isIdentifier && item.clerkRequired === true && !allUsers) { + const missing = total - item.userCount; + message += ` ${color.yellow('⚠')} ${item.label} — ${color.yellow('required in Clerk')} — ${color.dim(`${countStr} (${missing} will fail without ${item.label.toLowerCase()})`)}\n`; + needsAttention.push(item); + } else { + message += ` ${color.green('✓')} ${item.label} — ${color.dim(`enabled in Clerk — ${countStr}`)}\n`; + } + } else if (item.clerkEnabled === false) { + message += ` ${color.red('✗')} ${item.label} — ${color.red('not enabled in Clerk')} — ${color.dim(countStr)}\n`; + needsAttention.push(item); + } else if (isIdentifier && allUsers) { + // No Clerk config — all users have this identifier, safe to require + message += ` ${color.yellow('○')} ${item.label} — ${color.dim(`${countStr} — enable in Clerk Dashboard (can be required)`)}\n`; + } else if (isIdentifier) { + // No Clerk config — not all users have this identifier, requiring would cause failures + message += ` ${color.yellow('○')} ${item.label} — ${color.dim(`${countStr} — enable in Clerk Dashboard (do not require)`)}\n`; + } else { + // No Clerk config — non-identifier item + message += ` ${color.yellow('○')} ${item.label} — ${color.dim(`${countStr} — enable in Clerk Dashboard`)}\n`; + } } + + 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 < total) { + const missing = total - 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 @@ -1381,6 +1568,10 @@ export async function runCLI(cliArgs?: CLIArgs) { const analysis = analyzeFields(filteredUsers); + // Validate users and log errors so they're available before the readiness display. + // Users can cancel after seeing the results and review the log file. + const validation = validateUsers(filteredUsers, initialArgs.key); + // Step 4: Check instance type and validate const instanceType = detectInstanceType(); @@ -1417,9 +1608,6 @@ export async function runCLI(cliArgs?: CLIArgs) { } } - // Step 5: Display and confirm identifier settings - displayIdentifierAnalysis(analysis); - // Exit if no users have valid identifiers if (analysis.identifiers.hasAnyIdentifier === 0) { p.cancel( @@ -1428,81 +1616,221 @@ export async function runCLI(cliArgs?: CLIArgs) { process.exit(1); } - const confirmIdentifiers = await p.confirm({ - message: 'Have you configured the identifier settings in the Dashboard?', - initialValue: true, - }); + // Step 5: Fetch configurations for cross-reference + const isSupabase = initialArgs.key === 'supabase'; - if (p.isCancel(confirmIdentifiers) || !confirmIdentifiers) { - p.cancel( - 'Migration cancelled. Please configure identifier settings and try again.' - ); - process.exit(0); + 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); + // Determine config check outcomes for the readiness report + let clerkStatus: ConfigStatus['clerk'] = 'skipped'; + if (publishableKey) { + clerkStatus = clerkConfig ? 'loaded' : 'failed'; + } + const configStatus: ConfigStatus = { clerk: clerkStatus }; - if (skipPasswordRequirement === null) { - p.cancel('Migration cancelled.'); - process.exit(0); + if (isSupabase) { + if (supabaseUrl && supabaseApiKey) { + configStatus.supabase = supabaseProviders ? 'loaded' : 'failed'; + } else { + configStatus.supabase = 'skipped'; + } } - // 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, + configSpinner.stop('Configuration checked'); + + // Step 6: Build cross-reference items + const items: ReadinessItem[] = []; + + // 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, + clerkRequired: clerkConfig?.attributes.email_address?.required ?? 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, + clerkRequired: clerkConfig?.attributes.phone_number?.required ?? null, + section: 'identifiers', + }); } - // Step 7: Display user model analysis - const needsUserModelConfirmation = displayUserModelAnalysis(analysis); + if (analysis.identifiers.username > 0) { + items.push({ + label: 'Username', + userCount: analysis.identifiers.username, + clerkEnabled: clerkConfig?.attributes.username?.enabled ?? null, + clerkRequired: clerkConfig?.attributes.username?.required ?? null, + section: 'identifiers', + }); + } - if (needsUserModelConfirmation) { - const confirmUserModel = await p.confirm({ - message: - 'Have you configured first and last name settings in the Dashboard?', - initialValue: true, + // Authentication + const passwordCount = analysis.fieldCounts.password || 0; + if (passwordCount > 0) { + items.push({ + label: 'Password', + userCount: passwordCount, + clerkEnabled: clerkConfig?.attributes.password?.enabled ?? null, + clerkRequired: null, + section: 'auth', }); + } - if (p.isCancel(confirmUserModel) || !confirmUserModel) { - p.cancel( - 'Migration cancelled. Please configure user model settings 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, + clerkRequired: null, + section: 'social', + }); + + if (clerkEnabled === false) { + disabledProviders.push(provider); + } } } - // Step 8: Display and confirm other field settings (if any) - const hasOtherFields = displayOtherFieldsAnalysis(analysis); + // Find users to exclude (those whose only providers are disabled) + let excludedUserIds = new Set(); + let exclusionsByProvider: Record = {}; + if (isSupabase && disabledProviders.length > 0) { + const filePath = createImportFilePath(initialArgs.file); + const result = findUsersWithDisabledProviders(filePath, disabledProviders); + excludedUserIds = result.excludedIds; + exclusionsByProvider = result.exclusionsByProvider; + } + + // 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, + clerkRequired: null, + section: 'model', + }); + } - if (hasOtherFields) { - const confirmFields = await p.confirm({ - message: 'Have you configured the other field settings in the Dashboard?', - initialValue: true, + const lastNameCount = analysis.fieldCounts.lastName || 0; + if (lastNameCount > 0) { + items.push({ + label: 'Last Name', + userCount: lastNameCount, + clerkEnabled: clerkConfig?.attributes.last_name?.enabled ?? null, + clerkRequired: null, + section: 'model', }); + } - if (p.isCancel(confirmFields) || !confirmFields) { - p.cancel( - 'Migration cancelled. Please configure field settings and try again.' + // Step 7: Display unified cross-reference report + displayCrossReference(items, analysis, configStatus, validation); + + // Step 8: Show disabled providers and handle exclusion + let skipUnsupportedProviders: boolean | undefined; + if (excludedUserIds.size > 0) { + let providerInfo = ''; + for (const provider of disabledProviders) { + const label = OAUTH_PROVIDER_LABELS[provider] || provider; + const count = exclusionsByProvider[provider] || 0; + if (count > 0) { + providerInfo += ` ${color.yellow('•')} ${label} — ${count} user${count === 1 ? '' : 's'}\n`; + } + } + providerInfo += `\n ${excludedUserIds.size} user${excludedUserIds.size === 1 ? '' : 's'} only signed up via disabled provider${excludedUserIds.size === 1 ? '' : 's'}`; + + p.note(providerInfo.trim(), 'Disabled Social Providers'); + + if (cliArgs?.skipUnsupportedProviders) { + p.log.info( + `Skipping ${excludedUserIds.size} user${excludedUserIds.size === 1 ? '' : 's'} (--skip-unsupported-providers)` ); - process.exit(0); + } else { + const savedSkip = savedSettings.skipUnsupportedProviders; + const shouldSkip = await p.confirm({ + message: `Skip ${excludedUserIds.size} user${excludedUserIds.size === 1 ? '' : 's'} who only signed up via disabled providers?`, + initialValue: savedSkip ?? true, + }); + + if (p.isCancel(shouldSkip)) { + p.cancel('Migration cancelled.'); + process.exit(0); + } + + skipUnsupportedProviders = shouldSkip; + + if (!shouldSkip) { + excludedUserIds = new Set(); + } } } - // Step 9: Final confirmation + 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); + } + + 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: 'Begin Migration?', - initialValue: true, + message: confirmMessage, + initialValue: !hasIssues || excludedUserIds.size > 0, }); if (p.isCancel(beginMigration) || !beginMigration) { @@ -1510,16 +1838,23 @@ export async function runCLI(cliArgs?: CLIArgs) { 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, + ...(skipUnsupportedProviders !== undefined && { + skipUnsupportedProviders, + }), }); + // 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 d147e2b..a612009 100644 --- a/src/migrate/index.ts +++ b/src/migrate/index.ts @@ -50,6 +50,22 @@ async function main() { } } + // Exclude users with disabled social providers + if (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/transformers/supabase.ts b/src/transformers/supabase.ts index bd92ea3..2187679 100644 --- a/src/transformers/supabase.ts +++ b/src/transformers/supabase.ts @@ -76,6 +76,36 @@ 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; + let displayName = (meta.display_name ?? meta.first_name ?? meta.name) as + | string + | undefined; + if (typeof displayName === 'string' && displayName.trim()) { + // 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/src/types.ts b/src/types.ts index b505c08..27755bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -184,6 +184,7 @@ export type Settings = { key?: string; file?: string; firebaseHashConfig?: FirebaseHashConfig; + skipUnsupportedProviders?: boolean; }; /** diff --git a/src/utils.ts b/src/utils.ts index cfbff11..31f041b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -190,3 +190,74 @@ export function getRetryDelay( const delaySeconds = retryAfterSeconds || defaultDelayMs / 1000; return { delayMs, delaySeconds }; } + +/** + * Checks if a string is a valid Postgres connection string. + * + * Verifies the value starts with postgresql:// or postgres:// and is + * parseable as a URL. Passwords with special characters (like @, #, %) + * must be URL-encoded for the string to be valid. + * + * @param value - The string to check + * @returns true if the value is a parseable Postgres URL + */ +export const isValidConnectionString = (value: string): boolean => { + if (!value.startsWith('postgresql://') && !value.startsWith('postgres://')) { + return false; + } + try { + new URL(value); + return true; + } catch { + return false; + } +}; + +/** + * Resolves the database connection string from CLI args and environment variables. + * + * Priority: --db-url flag > SUPABASE_DB_URL env var > interactive prompt + * + * Returns the resolved URL and an optional warning if an env var was present + * but had an invalid format. + * + * @param cliArgs - Raw CLI arguments (process.argv.slice(2)) + * @param env - Environment variables to check + * @returns Object with resolved dbUrl (undefined if not found) and optional warning + */ +export function resolveConnectionString( + cliArgs: string[], + env: Record +): { + dbUrl: string | undefined; + outputFile: string; + warning: string | undefined; +} { + let dbUrl: string | undefined; + let outputFile = 'supabase-export.json'; + let warning: string | undefined; + + // Parse CLI flags + for (let i = 0; i < cliArgs.length; i++) { + if (cliArgs[i] === '--db-url' && cliArgs[i + 1]) { + dbUrl = cliArgs[i + 1]; + i++; + } else if (cliArgs[i] === '--output' && cliArgs[i + 1]) { + outputFile = cliArgs[i + 1]; + i++; + } + } + + // Fall back to env vars if no --db-url flag, validating format + if (!dbUrl) { + const envUrl = env.SUPABASE_DB_URL; + if (envUrl && isValidConnectionString(envUrl)) { + dbUrl = envUrl; + } else if (envUrl) { + warning = + 'Connection string from environment is not a valid Postgres URL — prompting instead.'; + } + } + + return { dbUrl, outputFile, warning }; +} diff --git a/supabase-export.json b/supabase-export.json new file mode 100644 index 0000000..e40100f --- /dev/null +++ b/supabase-export.json @@ -0,0 +1,55 @@ +[ + { + "id": "aa16f470-9851-48d5-8a5e-a617fd7c70a3", + "email": "royanger@gmail.com", + "email_confirmed_at": "2026-02-19T00:39:45.479Z", + "encrypted_password": "$2a$10$ijb754iZs7gepAKgZYBVjeW7Gr/rGxmudhLR9vlX1Kr.zTA/YoTDO", + "phone": null, + "phone_confirmed_at": null, + "first_name": null, + "last_name": null, + "raw_user_meta_data": { + "sub": "aa16f470-9851-48d5-8a5e-a617fd7c70a3", + "email": "royanger@gmail.com", + "email_verified": true, + "phone_verified": false + }, + "raw_app_meta_data": { + "provider": "email", + "providers": [ + "email" + ] + }, + "created_at": "2026-02-19T00:39:06.023Z" + }, + { + "id": "c5675a4c-b42f-445a-81c8-d158dec7f2c5", + "email": "roy@clerk.dev", + "email_confirmed_at": "2026-02-19T02:29:54.946Z", + "encrypted_password": "$2a$10$pkx6GxQIMMRC0XN4sAt.lus7u15pctzCfkRXP.WlDoMD6XeYA0OCy", + "phone": null, + "phone_confirmed_at": null, + "first_name": "Roy Anger", + "last_name": null, + "raw_user_meta_data": { + "iss": "https://api.github.com", + "sub": "24333362", + "name": "Roy Anger", + "email": "roy@royanger.com", + "full_name": "Roy Anger", + "user_name": "royanger", + "avatar_url": "https://avatars.githubusercontent.com/u/24333362?v=4", + "provider_id": "24333362", + "email_verified": true, + "phone_verified": false, + "preferred_username": "royanger" + }, + "raw_app_meta_data": { + "provider": "email", + "providers": [ + "github" + ] + }, + "created_at": "2026-02-19T02:29:38.771Z" + } +] diff --git a/tests/export.test.ts b/tests/export.test.ts new file mode 100644 index 0000000..b0bde20 --- /dev/null +++ b/tests/export.test.ts @@ -0,0 +1,262 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { isValidConnectionString, resolveConnectionString } from '../src/utils'; + +// Mock pg Client +const mockConnect = vi.fn(); +const mockQuery = vi.fn(); +const mockEnd = vi.fn(); + +vi.mock('pg', () => { + return { + Client: class MockClient { + connect = mockConnect; + query = mockQuery; + end = mockEnd; + }, + }; +}); + +// Mock fs.writeFileSync to avoid writing files during tests +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + default: { + ...(actual as Record), + writeFileSync: vi.fn(), + }, + }; +}); + +// ============================================================================ +// isValidConnectionString tests +// ============================================================================ + +describe('isValidConnectionString', () => { + test('accepts postgresql:// URLs', () => { + expect( + isValidConnectionString( + 'postgresql://postgres:password@db.abc.supabase.co:5432/postgres' + ) + ).toBe(true); + }); + + test('accepts postgres:// URLs', () => { + expect( + isValidConnectionString( + 'postgres://postgres.ref:password@aws-0-us-east-1.pooler.supabase.com:6543/postgres' + ) + ).toBe(true); + }); + + test('rejects non-postgres URLs', () => { + expect(isValidConnectionString('mysql://host:3306/db')).toBe(false); + }); + + test('rejects plain strings', () => { + expect(isValidConnectionString('not-a-url')).toBe(false); + }); + + test('rejects empty strings', () => { + expect(isValidConnectionString('')).toBe(false); + }); + + test('rejects http URLs', () => { + expect(isValidConnectionString('https://supabase.co')).toBe(false); + }); + + test('rejects postgres:// URLs that cannot be parsed', () => { + expect( + isValidConnectionString('postgresql://host with spaces:5432/db') + ).toBe(false); + }); + + test('accepts postgres:// URLs with URL-encoded special characters', () => { + expect( + isValidConnectionString( + 'postgresql://postgres:p%40ss%23word@db.abc.supabase.co:5432/postgres' + ) + ).toBe(true); + }); +}); + +// ============================================================================ +// resolveConnectionString tests +// ============================================================================ + +describe('resolveConnectionString', () => { + const validUrl = + 'postgresql://postgres:password@db.abc.supabase.co:5432/postgres'; + + test('uses --db-url flag when provided', () => { + const result = resolveConnectionString(['--db-url', validUrl], {}); + + expect(result.dbUrl).toBe(validUrl); + expect(result.warning).toBeUndefined(); + }); + + test('--db-url flag takes priority over env var', () => { + const envUrl = + 'postgresql://postgres:other@db.xyz.supabase.co:5432/postgres'; + const result = resolveConnectionString(['--db-url', validUrl], { + SUPABASE_DB_URL: envUrl, + }); + + expect(result.dbUrl).toBe(validUrl); + }); + + test('uses SUPABASE_DB_URL when no flag', () => { + const result = resolveConnectionString([], { + SUPABASE_DB_URL: validUrl, + }); + + expect(result.dbUrl).toBe(validUrl); + expect(result.warning).toBeUndefined(); + }); + + test('returns warning and undefined dbUrl when env var is not a valid Postgres URL', () => { + const result = resolveConnectionString([], { + SUPABASE_DB_URL: 'not-a-valid-url', + }); + + expect(result.dbUrl).toBeUndefined(); + expect(result.warning).toContain('not a valid Postgres URL'); + }); + + test('returns warning for invalid SUPABASE_DB_URL with https scheme', () => { + const result = resolveConnectionString([], { + SUPABASE_DB_URL: 'https://supabase.co/project', + }); + + expect(result.dbUrl).toBeUndefined(); + expect(result.warning).toContain('not a valid Postgres URL'); + }); + + test('returns undefined dbUrl and no warning when no env vars set', () => { + const result = resolveConnectionString([], {}); + + expect(result.dbUrl).toBeUndefined(); + expect(result.warning).toBeUndefined(); + }); + + test('parses --output flag', () => { + const result = resolveConnectionString( + ['--db-url', validUrl, '--output', 'custom.json'], + {} + ); + + expect(result.dbUrl).toBe(validUrl); + expect(result.outputFile).toBe('custom.json'); + }); + + test('defaults outputFile to supabase-export.json', () => { + const result = resolveConnectionString([], {}); + + expect(result.outputFile).toBe('supabase-export.json'); + }); +}); + +// ============================================================================ +// exportSupabaseUsers tests +// ============================================================================ + +describe('exportSupabaseUsers', () => { + const dbUrl = + 'postgresql://postgres:password@db.abc.supabase.co:5432/postgres'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Import dynamically so mocks are applied + async function getExportFn() { + const mod = await import('../src/export/supabase'); + return mod.exportSupabaseUsers; + } + + test('shows hostname hint for ENOTFOUND errors', async () => { + mockConnect.mockRejectedValue( + new Error('getaddrinfo ENOTFOUND db.abc.supabase.co') + ); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'hostname could not be resolved' + ); + }); + + test('shows IPv4 hint for ETIMEDOUT errors', async () => { + mockConnect.mockRejectedValue(new Error('connect ETIMEDOUT 1.2.3.4:5432')); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'IPv4 add-on' + ); + }); + + test('shows IPv4 hint for ENETUNREACH errors', async () => { + mockConnect.mockRejectedValue( + new Error('connect ENETUNREACH 1.2.3.4:5432') + ); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'IPv4 add-on' + ); + }); + + test('shows password hint for authentication errors', async () => { + mockConnect.mockRejectedValue( + new Error('password authentication failed for user "postgres"') + ); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'Check the password' + ); + }); + + test('shows generic hint for unknown connection errors', async () => { + mockConnect.mockRejectedValue(new Error('some unexpected error')); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'Verify your connection string' + ); + }); + + test('shows auth.users hint when table does not exist', async () => { + mockConnect.mockResolvedValue(undefined); + mockQuery.mockRejectedValue( + new Error('relation "auth.users" does not exist') + ); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'Auth is enabled in Supabase Dashboard' + ); + expect(mockEnd).toHaveBeenCalled(); + }); + + test('shows auth.users hint when permission is denied', async () => { + mockConnect.mockResolvedValue(undefined); + mockQuery.mockRejectedValue(new Error('permission denied for schema auth')); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'connecting with the postgres role' + ); + expect(mockEnd).toHaveBeenCalled(); + }); + + test('re-throws unrecognized query errors', async () => { + mockConnect.mockResolvedValue(undefined); + mockQuery.mockRejectedValue(new Error('syntax error in SQL')); + const exportSupabaseUsers = await getExportFn(); + + await expect(exportSupabaseUsers(dbUrl, 'out.json')).rejects.toThrow( + 'syntax error in SQL' + ); + expect(mockEnd).toHaveBeenCalled(); + }); +}); diff --git a/tests/migrate/cli.test.ts b/tests/migrate/cli.test.ts index 1df5c0b..dd97e5e 100644 --- a/tests/migrate/cli.test.ts +++ b/tests/migrate/cli.test.ts @@ -3,14 +3,15 @@ import fs from 'fs'; import path from 'path'; import { analyzeFields, + analyzeUserProviders, detectInstanceType, - displayIdentifierAnalysis, - displayOtherFieldsAnalysis, - formatCount, + displayCrossReference, + findUsersWithDisabledProviders, hasValue, loadRawUsers, loadSettings, saveSettings, + validateUsers, } from '../../src/migrate/cli'; // Mock modules @@ -55,6 +56,10 @@ vi.mock('picocolors', () => ({ }, })); +vi.mock('../../src/logger', () => ({ + validationLogger: vi.fn(), +})); + // Import the mocked module to get access to the mock import * as p from '@clack/prompts'; @@ -577,481 +582,748 @@ 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)); - 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'); - }); + const result = analyzeUserProviders('test.json'); - 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'); + expect(result).toEqual({ email: 3, discord: 1, google: 1 }); }); - test('handles large numbers', () => { - const result = formatCount(1234, 5678, 'verified emails'); - expect(result).toBe('1234 of 5678 users have verified emails'); + 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 equal to total of 1', () => { - const result = formatCount(1, 1, 'phone number'); - expect(result).toBe('All users have phone number'); + 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 }); }); }); // ============================================================================ -// loadRawUsers tests +// findUsersWithDisabledProviders tests // ============================================================================ -describe('loadRawUsers', () => { +describe('findUsersWithDisabledProviders', () => { beforeEach(() => { vi.clearAllMocks(); }); - test('loads and transforms JSON file with clerk transformer', async () => { - const mockJsonData = [ - { - id: 'user_123', - first_name: 'John', - last_name: 'Doe', - primary_email_address: 'john@example.com', - }, + test('does not exclude users who have a supported provider alongside a disabled one', () => { + const mockData = [ + { id: 'user-1', raw_app_meta_data: { providers: ['email'] } }, + { id: 'user-2', raw_app_meta_data: { providers: ['discord'] } }, { - id: 'user_456', - first_name: 'Jane', - last_name: 'Smith', - primary_email_address: 'jane@example.com', + 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)); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); - - const result = await loadRawUsers('users.json', 'clerk'); + const result = findUsersWithDisabledProviders('test.json', ['discord']); - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ - userId: 'user_123', - firstName: 'John', - lastName: 'Doe', - email: 'john@example.com', - }); - expect(result[1]).toEqual({ - userId: 'user_456', - firstName: 'Jane', - lastName: 'Smith', - email: 'jane@example.com', - }); + // user-3 has email (supported) + discord (disabled) → NOT excluded + expect(result.excludedIds).toEqual(new Set(['user-2'])); + expect(result.exclusionsByProvider).toEqual({ discord: 1 }); }); - test('filters out empty string values', async () => { - const mockJsonData = [ - { - id: 'user_123', - first_name: 'John', - last_name: '', - primary_email_address: 'john@example.com', - }, - ]; - - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); - - const result = await loadRawUsers('users.json', 'clerk'); + test('returns empty result when no disabled providers specified', () => { + const result = findUsersWithDisabledProviders('test.json', []); - expect(result[0]).toEqual({ - userId: 'user_123', - firstName: 'John', - email: 'john@example.com', - }); - expect(result[0]).not.toHaveProperty('lastName'); + expect(result.excludedIds).toEqual(new Set()); + expect(result.exclusionsByProvider).toEqual({}); + expect(fs.readFileSync).not.toHaveBeenCalled(); }); - test('filters out "{}" string values', async () => { - const mockJsonData = [ - { - id: 'user_123', - first_name: 'John', - public_metadata: '"{}"', - primary_email_address: 'john@example.com', - }, - ]; - - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); + test('returns empty result for invalid file', () => { + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('File not found'); + }); - const result = await loadRawUsers('users.json', 'clerk'); + const result = findUsersWithDisabledProviders('missing.json', ['discord']); - expect(result[0]).toEqual({ - userId: 'user_123', - firstName: 'John', - email: 'john@example.com', - }); - expect(result[0]).not.toHaveProperty('publicMetadata'); + expect(result.excludedIds).toEqual(new Set()); + expect(result.exclusionsByProvider).toEqual({}); }); - test('filters out null values', async () => { - const mockJsonData = [ + 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_123', - first_name: 'John', - last_name: null, - primary_email_address: 'john@example.com', + id: 'user-4', + raw_app_meta_data: { providers: ['email', 'google'] }, }, ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); - - const result = await loadRawUsers('users.json', 'clerk'); - - expect(result[0]).toEqual({ - userId: 'user_123', - firstName: 'John', - email: 'john@example.com', - }); - expect(result[0]).not.toHaveProperty('lastName'); - }); + const result = findUsersWithDisabledProviders('test.json', [ + 'discord', + 'twitter', + ]); - test('throws error when transformer is not found', async () => { - await expect( - loadRawUsers('users.json', 'invalid_transformer') - ).rejects.toThrow('Transformer not found for key: invalid_transformer'); + expect(result.excludedIds).toEqual(new Set(['user-2', 'user-3'])); + expect(result.exclusionsByProvider).toEqual({ discord: 1, twitter: 1 }); }); - test('loads and transforms with supabase transformer', async () => { - const mockJsonData = [ - { - id: 'uuid-123', - email: 'john@example.com', - email_confirmed_at: '2024-01-01 12:00:00+00', - encrypted_password: '$2a$10$hash', - }, + 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)); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); - - const result = await loadRawUsers('users.json', 'supabase'); + const result = findUsersWithDisabledProviders('test.json', ['discord']); - expect(result[0]).toEqual({ - userId: 'uuid-123', - email: 'john@example.com', - password: '$2a$10$hash', - }); + expect(result.excludedIds).toEqual(new Set(['user-2'])); + expect(result.exclusionsByProvider).toEqual({ discord: 1 }); }); - test('loads and transforms with auth0 transformer', async () => { - const mockJsonData = [ - { - user_id: 'auth0|abc123', - email: 'john@example.com', - email_verified: true, - username: 'johndoe', - given_name: 'John', - family_name: 'Doe', - phone_number: '+1234567890', - phone_verified: true, - created_at: '2025-01-15T10:30:00.000Z', - }, + test('excludes users with only disabled providers', () => { + const mockData = [ + { id: 'user-1', raw_app_meta_data: { providers: ['github'] } }, ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); - - const result = await loadRawUsers('users.json', 'auth0'); + const result = findUsersWithDisabledProviders('test.json', ['github']); - // postTransform removes emailVerified/phoneVerified after processing - expect(result[0]).toEqual({ - userId: 'auth0|abc123', - email: 'john@example.com', - username: 'johndoe', - firstName: 'John', - lastName: 'Doe', - phone: '+1234567890', - createdAt: '2025-01-15T10:30:00.000Z', - }); + expect(result.excludedIds).toEqual(new Set(['user-1'])); + expect(result.exclusionsByProvider).toEqual({ github: 1 }); }); - test('loads and transforms with authjs transformer', async () => { - const mockJsonData = [ + test('excludes users when all providers are disabled', () => { + const mockData = [ { - id: '1', - email: 'john@example.com', - email_verified: '2024-01-15T10:30:00.000Z', - name: 'John Doe', - created_at: '2024-01-15T10:30:00.000Z', + id: 'user-1', + raw_app_meta_data: { providers: ['github', 'discord'] }, }, ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); - - const result = await loadRawUsers('users.json', 'authjs'); + const result = findUsersWithDisabledProviders('test.json', [ + 'github', + 'discord', + ]); - // postTransform should: - // - Split name into firstName and lastName - // - Keep email (since email_verified is truthy) - // - Remove email_verified field - // - Remove name field - expect(result[0]).toEqual({ - userId: '1', - email: 'john@example.com', - firstName: 'John', - lastName: 'Doe', - createdAt: '2024-01-15T10:30:00.000Z', - }); + expect(result.excludedIds).toEqual(new Set(['user-1'])); + expect(result.exclusionsByProvider).toEqual({ github: 1, discord: 1 }); }); - test('authjs transformer handles unverified emails and single-word names', async () => { - const mockJsonData = [ - { - id: '1', - email: 'unverified@example.com', - email_verified: null, // Unverified email - name: 'Madonna', // Single word name - created_at: '2024-01-15T10:30:00.000Z', - }, + test('does not exclude users when one social provider is enabled', () => { + const mockData = [ { - id: '2', - email: 'verified@example.com', - email_verified: '2024-01-15T10:30:00.000Z', - name: null, // Null name + id: 'user-1', + raw_app_meta_data: { providers: ['github', 'google'] }, }, ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); - - const result = await loadRawUsers('users.json', 'authjs'); - - // First user: unverified email + single-word name (discarded) - expect(result[0]).toEqual({ - userId: '1', - unverifiedEmailAddresses: 'unverified@example.com', - createdAt: '2024-01-15T10:30:00.000Z', - }); - expect(result[0]).not.toHaveProperty('email'); - expect(result[0]).not.toHaveProperty('firstName'); - expect(result[0]).not.toHaveProperty('lastName'); + const result = findUsersWithDisabledProviders('test.json', ['github']); - // Second user: verified email + null name - expect(result[1]).toEqual({ - userId: '2', - email: 'verified@example.com', - }); - expect(result[1]).not.toHaveProperty('firstName'); - expect(result[1]).not.toHaveProperty('lastName'); + // google is not in the disabled list → user has a supported provider + expect(result.excludedIds).toEqual(new Set()); + expect(result.exclusionsByProvider).toEqual({}); }); - test('keeps unmapped keys unchanged', async () => { - const mockJsonData = [ + test('does not exclude users with phone provider alongside disabled social', () => { + const mockData = [ { - id: 'user_123', - customField: 'custom value', - primary_email_address: 'john@example.com', + id: 'user-1', + raw_app_meta_data: { providers: ['phone', 'discord'] }, }, ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); - - const result = await loadRawUsers('users.json', 'clerk'); + const result = findUsersWithDisabledProviders('test.json', ['discord']); - expect(result[0]).toEqual({ - userId: 'user_123', - customField: 'custom value', - email: 'john@example.com', - }); + // phone is an ignored provider (always supported) + expect(result.excludedIds).toEqual(new Set()); + expect(result.exclusionsByProvider).toEqual({}); }); }); // ============================================================================ -// displayIdentifierAnalysis tests +// displayCrossReference tests // ============================================================================ -describe('displayIdentifierAnalysis', () => { +describe('displayCrossReference', () => { beforeEach(() => { vi.clearAllMocks(); }); - test('calls p.note with analysis message', () => { + test('calls p.note with Migration Readiness title', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; const analysis = { presentOnAll: [], presentOnSome: [], identifiers: { verifiedEmails: 10, unverifiedEmails: 0, - verifiedPhones: 10, + verifiedPhones: 0, unverifiedPhones: 0, - username: 10, + username: 0, hasAnyIdentifier: 10, }, totalUsers: 10, fieldCounts: {}, }; - displayIdentifierAnalysis(analysis); + displayCrossReference(items, analysis); - expect(p.note).toHaveBeenCalledWith(expect.any(String), 'Identifiers'); + expect(p.note).toHaveBeenCalledWith( + expect.any(String), + 'Migration Readiness' + ); }); - test('handles analysis with all users having identifiers', () => { + test('shows enabled items with green check', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; const analysis = { presentOnAll: [], presentOnSome: [], identifiers: { - verifiedEmails: 5, + verifiedEmails: 10, unverifiedEmails: 0, - verifiedPhones: 5, + verifiedPhones: 0, unverifiedPhones: 0, - username: 5, - hasAnyIdentifier: 5, + username: 0, + hasAnyIdentifier: 10, }, - totalUsers: 5, + totalUsers: 10, fieldCounts: {}, }; - // Should not throw - expect(() => displayIdentifierAnalysis(analysis)).not.toThrow(); + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('enabled in Clerk'), + 'Migration Readiness' + ); }); - test('handles analysis with missing identifiers', () => { + test('shows disabled items with red cross', () => { + const items = [ + { + label: 'Discord', + userCount: 5, + clerkEnabled: false as boolean | null, + clerkRequired: null as boolean | null, + section: 'social' as const, + }, + ]; const analysis = { presentOnAll: [], presentOnSome: [], identifiers: { - verifiedEmails: 3, + verifiedEmails: 10, unverifiedEmails: 0, - verifiedPhones: 2, + verifiedPhones: 0, unverifiedPhones: 0, - username: 1, - hasAnyIdentifier: 8, + username: 0, + hasAnyIdentifier: 10, }, totalUsers: 10, fieldCounts: {}, }; - // Should not throw - expect(() => displayIdentifierAnalysis(analysis)).not.toThrow(); + 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('handles analysis with unverified identifiers', () => { + test('shows unknown items with yellow circle when no Clerk config', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'identifiers' as const, + }, + ]; const analysis = { presentOnAll: [], presentOnSome: [], identifiers: { - verifiedEmails: 5, - unverifiedEmails: 3, - verifiedPhones: 5, - unverifiedPhones: 2, - username: 5, - hasAnyIdentifier: 5, - }, - totalUsers: 5, - fieldCounts: {}, - }; - - // Should not throw - expect(() => displayIdentifierAnalysis(analysis)).not.toThrow(); - }); + 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' + ); + }); - 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 + test('shows "can be required" hint for identifiers when all users have it and no Clerk config', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'identifiers' as const, + }, + ]; const analysis = { presentOnAll: [], presentOnSome: [], identifiers: { - verifiedEmails: 3309, - unverifiedEmails: 259, + verifiedEmails: 10, + unverifiedEmails: 0, verifiedPhones: 0, unverifiedPhones: 0, username: 0, - hasAnyIdentifier: 3309, // Only users with verified email can be imported + hasAnyIdentifier: 10, }, - totalUsers: 3568, // Total includes users who will fail validation + totalUsers: 10, fieldCounts: {}, }; - displayIdentifierAnalysis(analysis); + displayCrossReference(items, 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.stringContaining('can be required'), + 'Migration Readiness' ); + }); + + test('shows "do not require" hint for identifiers when not all users have it and no Clerk config', () => { + const items = [ + { + label: 'Email', + userCount: 7, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 7, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + expect(p.note).toHaveBeenCalledWith( - expect.stringContaining('email'), - 'Identifiers' + expect.stringContaining('do not require'), + 'Migration Readiness' ); }); - test('recommends identifiers as optional when not all importable users have them', () => { - // Scenario: 50 users have email, 50 users have phone, 100 total importable + test('warns when identifier is required in Clerk but not all users have it', () => { + const items = [ + { + label: 'Email', + userCount: 7, + clerkEnabled: true as boolean | null, + clerkRequired: true as boolean | null, + section: 'identifiers' as const, + }, + ]; const analysis = { presentOnAll: [], presentOnSome: [], identifiers: { - verifiedEmails: 50, + verifiedEmails: 7, unverifiedEmails: 0, - verifiedPhones: 50, + verifiedPhones: 0, unverifiedPhones: 0, username: 0, - hasAnyIdentifier: 100, // 50 with email + 50 with phone = 100 importable + hasAnyIdentifier: 10, }, - totalUsers: 100, + totalUsers: 10, fieldCounts: {}, }; - displayIdentifierAnalysis(analysis); + displayCrossReference(items, 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' + expect.stringContaining('required in Clerk'), + 'Migration Readiness' + ); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('3 will fail without email'), + 'Migration Readiness' + ); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('1 setting'), + 'Migration Readiness' ); }); -}); -// ============================================================================ -// displayOtherFieldsAnalysis tests -// ============================================================================ + test('shows green check when identifier is required and all users have it', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: true as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; -describe('displayOtherFieldsAnalysis', () => { - beforeEach(() => { - vi.clearAllMocks(); + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('enabled in Clerk'), + 'Migration Readiness' + ); + expect(p.note).not.toHaveBeenCalledWith( + expect.stringContaining('will fail'), + 'Migration Readiness' + ); }); - test('returns false when no fields are analyzed', () => { + test('does not show require hints for non-identifier sections', () => { + const items = [ + { + label: 'Password', + userCount: 10, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'auth' as const, + }, + ]; const analysis = { presentOnAll: [], presentOnSome: [], identifiers: { - verifiedEmails: 0, + verifiedEmails: 10, unverifiedEmails: 0, verifiedPhones: 0, unverifiedPhones: 0, username: 0, - hasAnyIdentifier: 0, + hasAnyIdentifier: 10, }, - totalUsers: 0, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + const noteCall = vi.mocked(p.note).mock.calls[0][0] as string; + expect(noteCall).not.toContain('can be required'); + expect(noteCall).not.toContain('do not require'); + }); + + test('shows total user count', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('10 users in file'), + 'Migration Readiness' + ); + }); + + test('shows count/total format for partial coverage', () => { + const items = [ + { + label: 'Email', + userCount: 15, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 15, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 30, + }, + totalUsers: 30, fieldCounts: {}, }; - const result = displayOtherFieldsAnalysis(analysis); + displayCrossReference(items, analysis); - expect(result).toBe(false); - expect(p.note).not.toHaveBeenCalled(); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('15/30 users'), + 'Migration Readiness' + ); + }); + + test('shows validation failure count and log file reference', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference( + items, + analysis, + { clerk: 'loaded' }, + { + validationFailed: 3, + logFile: 'migration-2025-01-01-120000.log', + } + ); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('3 users failed validation'), + 'Migration Readiness' + ); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('migration-2025-01-01-120000.log'), + 'Migration Readiness' + ); + }); + + test('does not show validation section when no failures', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference( + items, + analysis, + { clerk: 'loaded' }, + { + validationFailed: 0, + logFile: '', + } + ); + + const noteCall = vi.mocked(p.note).mock.calls[0][0] as string; + expect(noteCall).not.toContain('failed validation'); + }); + + test('shows Clerk Configuration loaded when config succeeded', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis, { clerk: 'loaded' }); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('Configuration loaded from Clerk'), + 'Migration Readiness' + ); + }); + + test('shows Clerk Configuration error guidance when config failed', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis, { clerk: 'failed' }); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('Could not fetch Clerk configuration'), + 'Migration Readiness' + ); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('Verify your Clerk Dashboard settings'), + 'Migration Readiness' + ); }); - test('returns true when fields are present on all users', () => { + test('shows Clerk Configuration skipped guidance when no publishable key', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: null as boolean | null, + clerkRequired: null as boolean | null, + section: 'identifiers' as const, + }, + ]; const analysis = { - presentOnAll: ['TOTP Secret'], + presentOnAll: [], presentOnSome: [], identifiers: { verifiedEmails: 10, @@ -1065,16 +1337,27 @@ describe('displayOtherFieldsAnalysis', () => { fieldCounts: {}, }; - const result = displayOtherFieldsAnalysis(analysis); + displayCrossReference(items, analysis, { clerk: 'skipped' }); - expect(result).toBe(true); - expect(p.note).toHaveBeenCalledWith(expect.any(String), 'Other Fields'); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('CLERK_PUBLISHABLE_KEY'), + 'Migration Readiness' + ); }); - test('returns true when fields are present on some users', () => { + test('shows Supabase Configuration section only for supabase migrations', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; const analysis = { presentOnAll: [], - presentOnSome: ['TOTP Secret'], + presentOnSome: [], identifiers: { verifiedEmails: 10, unverifiedEmails: 0, @@ -1087,15 +1370,29 @@ describe('displayOtherFieldsAnalysis', () => { fieldCounts: {}, }; - const result = displayOtherFieldsAnalysis(analysis); + displayCrossReference(items, analysis, { + clerk: 'loaded', + supabase: 'loaded', + }); - expect(result).toBe(true); - expect(p.note).toHaveBeenCalledWith(expect.any(String), 'Other Fields'); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('Supabase Configuration'), + 'Migration Readiness' + ); }); - test('returns true when both presentOnAll and presentOnSome have fields', () => { + test('does not show Supabase section for non-supabase migrations', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; const analysis = { - presentOnAll: ['TOTP Secret'], + presentOnAll: [], presentOnSome: [], identifiers: { verifiedEmails: 10, @@ -1109,9 +1406,373 @@ describe('displayOtherFieldsAnalysis', () => { fieldCounts: {}, }; - const result = displayOtherFieldsAnalysis(analysis); + displayCrossReference(items, analysis, { clerk: 'loaded' }); + + const noteCall = vi.mocked(p.note).mock.calls[0][0] as string; + expect(noteCall).not.toContain('Supabase Configuration'); + }); - expect(result).toBe(true); - expect(p.note).toHaveBeenCalledWith(expect.any(String), 'Other Fields'); + test('shows Import File section with total users', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + clerkRequired: false as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis, { clerk: 'loaded' }); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('Import File'), + 'Migration Readiness' + ); + }); +}); + +// ============================================================================ +// validateUsers tests +// ============================================================================ + +describe('validateUsers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('returns 0 failures for valid users', () => { + const users = [ + { userId: 'user_1', email: 'a@test.com' }, + { userId: 'user_2', email: 'b@test.com' }, + ]; + + const result = validateUsers(users, 'clerk'); + + expect(result.validationFailed).toBe(0); + expect(result.logFile).toMatch(/^migration-.*\.log$/); + }); + + test('counts validation failures for users missing identifiers', () => { + const users = [ + { userId: 'user_1', email: 'a@test.com' }, + { userId: 'user_2' }, // no identifier + { userId: 'user_3' }, // no identifier + ]; + + const result = validateUsers(users, 'clerk'); + + expect(result.validationFailed).toBe(2); + }); + + test('applies transformer defaults before validating', () => { + // Supabase transformer adds passwordHasher: "bcrypt" as default + // Without defaults, users with password but no hasher would fail + const users = [ + { userId: 'user_1', email: 'a@test.com', password: '$2a$10$hash' }, + ]; + + const result = validateUsers(users, 'supabase'); + + expect(result.validationFailed).toBe(0); + }); + + test('fails validation for users with password but no hasher when no defaults', () => { + const users = [ + { userId: 'user_1', email: 'a@test.com', password: 'somepassword' }, + ]; + + const result = validateUsers(users, 'clerk'); + + expect(result.validationFailed).toBe(1); + }); + + test('logs validation errors via validationLogger', async () => { + const { validationLogger } = await import('../../src/logger'); + const users = [ + { userId: 'user_1' }, // no identifier + ]; + + validateUsers(users, 'clerk'); + + expect(validationLogger).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user_1', + row: 0, + }), + expect.any(String) + ); + }); +}); + +// ============================================================================ +// loadRawUsers tests +// ============================================================================ + +describe('loadRawUsers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('loads and transforms JSON file with clerk transformer', async () => { + const mockJsonData = [ + { + id: 'user_123', + first_name: 'John', + last_name: 'Doe', + primary_email_address: 'john@example.com', + }, + { + id: 'user_456', + first_name: 'Jane', + last_name: 'Smith', + primary_email_address: 'jane@example.com', + }, + ]; + + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); + + const result = await loadRawUsers('users.json', 'clerk'); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + userId: 'user_123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + }); + expect(result[1]).toEqual({ + userId: 'user_456', + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@example.com', + }); + }); + + test('filters out empty string values', async () => { + const mockJsonData = [ + { + id: 'user_123', + first_name: 'John', + last_name: '', + primary_email_address: 'john@example.com', + }, + ]; + + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); + + const result = await loadRawUsers('users.json', 'clerk'); + + expect(result[0]).toEqual({ + userId: 'user_123', + firstName: 'John', + email: 'john@example.com', + }); + expect(result[0]).not.toHaveProperty('lastName'); + }); + + test('filters out "{}" string values', async () => { + const mockJsonData = [ + { + id: 'user_123', + first_name: 'John', + public_metadata: '"{}"', + primary_email_address: 'john@example.com', + }, + ]; + + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); + + const result = await loadRawUsers('users.json', 'clerk'); + + expect(result[0]).toEqual({ + userId: 'user_123', + firstName: 'John', + email: 'john@example.com', + }); + expect(result[0]).not.toHaveProperty('publicMetadata'); + }); + + test('filters out null values', async () => { + const mockJsonData = [ + { + id: 'user_123', + first_name: 'John', + last_name: null, + primary_email_address: 'john@example.com', + }, + ]; + + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); + + const result = await loadRawUsers('users.json', 'clerk'); + + expect(result[0]).toEqual({ + userId: 'user_123', + firstName: 'John', + email: 'john@example.com', + }); + expect(result[0]).not.toHaveProperty('lastName'); + }); + + test('throws error when transformer is not found', async () => { + await expect( + loadRawUsers('users.json', 'invalid_transformer') + ).rejects.toThrow('Transformer not found for key: invalid_transformer'); + }); + + test('loads and transforms with supabase transformer', async () => { + const mockJsonData = [ + { + id: 'uuid-123', + email: 'john@example.com', + email_confirmed_at: '2024-01-01 12:00:00+00', + encrypted_password: '$2a$10$hash', + }, + ]; + + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); + + const result = await loadRawUsers('users.json', 'supabase'); + + expect(result[0]).toEqual({ + userId: 'uuid-123', + email: 'john@example.com', + password: '$2a$10$hash', + }); + }); + + test('loads and transforms with auth0 transformer', async () => { + const mockJsonData = [ + { + user_id: 'auth0|abc123', + email: 'john@example.com', + email_verified: true, + username: 'johndoe', + given_name: 'John', + family_name: 'Doe', + phone_number: '+1234567890', + phone_verified: true, + created_at: '2025-01-15T10:30:00.000Z', + }, + ]; + + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); + + const result = await loadRawUsers('users.json', 'auth0'); + + // postTransform removes emailVerified/phoneVerified after processing + expect(result[0]).toEqual({ + userId: 'auth0|abc123', + email: 'john@example.com', + username: 'johndoe', + firstName: 'John', + lastName: 'Doe', + phone: '+1234567890', + createdAt: '2025-01-15T10:30:00.000Z', + }); + }); + + test('loads and transforms with authjs transformer', async () => { + const mockJsonData = [ + { + id: '1', + email: 'john@example.com', + email_verified: '2024-01-15T10:30:00.000Z', + name: 'John Doe', + created_at: '2024-01-15T10:30:00.000Z', + }, + ]; + + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); + + const result = await loadRawUsers('users.json', 'authjs'); + + // postTransform should: + // - Split name into firstName and lastName + // - Keep email (since email_verified is truthy) + // - Remove email_verified field + // - Remove name field + expect(result[0]).toEqual({ + userId: '1', + email: 'john@example.com', + firstName: 'John', + lastName: 'Doe', + createdAt: '2024-01-15T10:30:00.000Z', + }); + }); + + test('authjs transformer handles unverified emails and single-word names', async () => { + const mockJsonData = [ + { + id: '1', + email: 'unverified@example.com', + email_verified: null, // Unverified email + name: 'Madonna', // Single word name + created_at: '2024-01-15T10:30:00.000Z', + }, + { + id: '2', + email: 'verified@example.com', + email_verified: '2024-01-15T10:30:00.000Z', + name: null, // Null name + }, + ]; + + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); + + const result = await loadRawUsers('users.json', 'authjs'); + + // First user: unverified email + single-word name (discarded) + expect(result[0]).toEqual({ + userId: '1', + unverifiedEmailAddresses: 'unverified@example.com', + createdAt: '2024-01-15T10:30:00.000Z', + }); + expect(result[0]).not.toHaveProperty('email'); + expect(result[0]).not.toHaveProperty('firstName'); + expect(result[0]).not.toHaveProperty('lastName'); + + // Second user: verified email + null name + expect(result[1]).toEqual({ + userId: '2', + email: 'verified@example.com', + }); + expect(result[1]).not.toHaveProperty('firstName'); + expect(result[1]).not.toHaveProperty('lastName'); + }); + + test('keeps unmapped keys unchanged', async () => { + const mockJsonData = [ + { + id: 'user_123', + customField: 'custom value', + primary_email_address: 'john@example.com', + }, + ]; + + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); + + const result = await loadRawUsers('users.json', 'clerk'); + + expect(result[0]).toEqual({ + userId: 'user_123', + customField: 'custom value', + email: 'john@example.com', + }); }); });