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/package.json b/package.json index e3f995b..a10057a 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/src/export/index.ts b/src/export/index.ts new file mode 100644 index 0000000..e9e3172 --- /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 (alternative to --db-url flag) + */ +import 'dotenv/config'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; +import { displayExportSummary, exportSupabaseUsers } from './supabase'; + +async function main() { + p.intro(color.bgCyan(color.black('Supabase User Export'))); + + // Parse CLI flags + const args = process.argv.slice(2); + let dbUrl = process.env.SUPABASE_DB_URL; + let outputFile = 'supabase-export.json'; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--db-url' && args[i + 1]) { + dbUrl = args[i + 1]; + i++; + } else if (args[i] === '--output' && args[i + 1]) { + outputFile = args[i + 1]; + i++; + } + } + + // Prompt for DB URL if not provided + if (!dbUrl) { + p.note( + `Find this in the Supabase Dashboard by clicking the ${color.bold('Connect')} button.\n\n${color.bold('Direct connection')} (requires IPv6):\n ${color.dim('postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres')}\n\n${color.bold('Pooler connection')} (works on IPv4 — use this if direct fails):\n ${color.dim('postgres://postgres.[REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres')}\n\n${color.dim('Alternatively, run the export SQL in the Supabase SQL Editor and save the result as JSON.')}`, + 'Connection String' + ); + + const input = await p.text({ + message: 'Enter your Supabase Postgres connection string', + placeholder: + 'postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres', + validate: (value) => { + if (!value || value.trim() === '') { + return 'Connection string is required'; + } + if ( + !value.startsWith('postgresql://') && + !value.startsWith('postgres://') + ) { + return 'Must be a valid Postgres connection string (postgresql://...)'; + } + }, + }); + + if (p.isCancel(input)) { + p.cancel('Export cancelled.'); + process.exit(0); + } + + dbUrl = input; + } + + const spinner = p.spinner(); + spinner.start('Connecting to Supabase database...'); + + try { + const result = await exportSupabaseUsers(dbUrl, outputFile); + spinner.stop(`Found ${result.userCount} users`); + + displayExportSummary(result); + + p.log.info( + color.dim( + `Next step: run ${color.bold('bun run migrate')} and select "Supabase" with file "${outputFile}"` + ) + ); + + p.outro(color.green('Export complete!')); + } catch (err) { + spinner.stop('Export failed'); + const message = err instanceof Error ? err.message : String(err); + p.log.error(color.red(message)); + process.exit(1); + } +} + +void main(); diff --git a/src/export/supabase.ts b/src/export/supabase.ts new file mode 100644 index 0000000..75d9dcd --- /dev/null +++ b/src/export/supabase.ts @@ -0,0 +1,155 @@ +/** + * 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); + throw new Error( + `Failed to connect to Supabase database: ${message}\n\n` + + `Connection string format: postgresql://postgres:[PASSWORD]@db.[PROJECT_REF].supabase.co:5432/postgres\n` + + `Find this in Supabase Dashboard → Settings → Database → Connection string` + ); + } + + try { + interface SupabaseUserRow { + email: string | null; + email_confirmed_at: string | null; + encrypted_password: string | null; + phone: string | null; + first_name: string | null; + last_name: string | null; + [key: string]: unknown; + } + + const { rows } = await client.query(EXPORT_QUERY); + + // Calculate field coverage + const coverage = { + email: 0, + emailConfirmed: 0, + password: 0, + phone: 0, + firstName: 0, + lastName: 0, + }; + + for (const row of rows) { + if (row.email) coverage.email++; + if (row.email_confirmed_at) coverage.emailConfirmed++; + if (row.encrypted_password) coverage.password++; + if (row.phone) coverage.phone++; + if (row.first_name) coverage.firstName++; + if (row.last_name) coverage.lastName++; + } + + // Write output + const outputPath = path.isAbsolute(outputFile) + ? outputFile + : path.join(process.cwd(), outputFile); + + fs.writeFileSync(outputPath, JSON.stringify(rows, null, 2)); + + return { + userCount: rows.length, + outputPath, + fieldCoverage: coverage, + }; + } finally { + await client.end(); + } +} + +/** + * Displays a summary of the export results with field coverage stats. + */ +export function displayExportSummary(result: ExportResult): void { + const { userCount, outputPath, fieldCoverage } = result; + + const getIcon = (count: number, total: number): string => { + if (count === total) return color.green('●'); + if (count > 0) return color.yellow('○'); + return color.dim('○'); + }; + + let summary = ''; + summary += `${getIcon(fieldCoverage.email, userCount)} ${color.dim(`${fieldCoverage.email}/${userCount} have email`)}\n`; + summary += `${getIcon(fieldCoverage.emailConfirmed, userCount)} ${color.dim(`${fieldCoverage.emailConfirmed}/${userCount} email confirmed`)}\n`; + summary += `${getIcon(fieldCoverage.password, userCount)} ${color.dim(`${fieldCoverage.password}/${userCount} have password hash`)}\n`; + summary += `${getIcon(fieldCoverage.phone, userCount)} ${color.dim(`${fieldCoverage.phone}/${userCount} have phone`)}\n`; + summary += `${getIcon(fieldCoverage.firstName, userCount)} ${color.dim(`${fieldCoverage.firstName}/${userCount} have first name`)}\n`; + summary += `${getIcon(fieldCoverage.lastName, userCount)} ${color.dim(`${fieldCoverage.lastName}/${userCount} have last name`)}`; + + p.note(summary, 'Field Coverage'); + p.log.success( + `Exported ${color.bold(String(userCount))} users to ${color.dim(outputPath)}` + ); +} diff --git a/src/migrate/cli.ts b/src/migrate/cli.ts index 4d6b237..fdf0b60 100644 --- a/src/migrate/cli.ts +++ b/src/migrate/cli.ts @@ -408,6 +408,7 @@ export async function runNonInteractive(args: CLIArgs): Promise<{ instance: 'dev' | 'prod'; begin: boolean; skipPasswordRequirement: boolean; + excludedUserIds: Set; }> { // Handle help flag if (args.help) { @@ -558,13 +559,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 @@ -801,272 +798,320 @@ export function analyzeFields(users: Record[]): FieldAnalysis { return { presentOnAll, presentOnSome, identifiers, totalUsers, fieldCounts }; } -/** - * Formats a count statistic into a human-readable string - * - * @param count - The number of users who have the field - * @param total - The total number of users - * @param label - The label for the field - * @returns A formatted string like "All users have...", "No users have...", or "X of Y users have..." - */ -export function formatCount( - count: number, - total: number, - label: string -): string { - if (count === total) { - return `All users have ${label}`; - } else if (count === 0) { - return `No users have ${label}`; - } - return `${count} of ${total} users have ${label}`; +// 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', +}; + +// Non-OAuth entries in the Supabase external config to ignore +const IGNORED_PROVIDERS = new Set(['email', 'phone', 'anonymous_users']); + +interface SupabaseAuthSettings { + external?: Record; } /** - * Displays identifier analysis and Dashboard configuration guidance - * - * Shows: - * - Count of users with each identifier type (verified emails, verified phones, usernames) - * - Count of users with unverified identifiers (if any) - * - Whether all users have at least one valid identifier - * - Dashboard configuration recommendations (required vs optional identifiers) + * Fetches the Supabase project's auth settings to determine which OAuth providers are enabled. * - * Uses color coding: green for complete coverage, yellow for partial, red for missing. + * 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 analysis - The field analysis results + * @param supabaseUrl - The Supabase project URL (e.g., https://xxx.supabase.co) + * @param apiKey - Any valid Supabase API key (anon or service role) + * @returns List of enabled OAuth provider keys, or null if the fetch failed */ -export function displayIdentifierAnalysis(analysis: FieldAnalysis): void { - const { identifiers, totalUsers } = analysis; - - let identifierMessage = ''; - - // Show counts for each identifier type - identifierMessage += color.bold(color.whiteBright('Identifier Analysis:\n')); - - // Helper to get the correct icon based on coverage - const getIcon = (count: number, total: number): string => { - if (count === total) return color.bold(color.greenBright('●')); - if (count > 0) return color.bold(color.yellowBright('○')); - return color.red('○'); - }; - - identifierMessage += ` ${getIcon(identifiers.verifiedEmails, totalUsers)} ${color.dim(formatCount(identifiers.verifiedEmails, totalUsers, 'verified emails'))}\n`; - identifierMessage += ` ${getIcon(identifiers.verifiedPhones, totalUsers)} ${color.dim(formatCount(identifiers.verifiedPhones, totalUsers, 'verified phone numbers'))}\n`; - identifierMessage += ` ${getIcon(identifiers.username, totalUsers)} ${color.dim(formatCount(identifiers.username, totalUsers, 'a username'))}\n`; - - // Show unverified counts if present - if (identifiers.unverifiedEmails > 0) { - identifierMessage += ` ${getIcon(identifiers.unverifiedEmails, totalUsers)} ${color.dim(formatCount(identifiers.unverifiedEmails, totalUsers, 'unverified emails'))}\n`; - } - if (identifiers.unverifiedPhones > 0) { - identifierMessage += ` ${getIcon(identifiers.unverifiedPhones, totalUsers)} ${color.dim(formatCount(identifiers.unverifiedPhones, totalUsers, 'unverified phone numbers'))}\n`; - } - - // Check if all users have at least one identifier - identifierMessage += '\n'; - if (identifiers.hasAnyIdentifier === totalUsers) { - identifierMessage += color.green( - 'All users have at least one identifier (verified email, verified phone, or username).\n' - ); - } else { - const missing = totalUsers - identifiers.hasAnyIdentifier; - identifierMessage += color.red( - `${missing} user${missing === 1 ? ' does' : 's do'} not have a verified email, verified phone, or username.\n` - ); - identifierMessage += color.red('These users cannot be imported.\n'); - } - - // Dashboard configuration advice - identifierMessage += '\n'; - identifierMessage += DASHBOARD_CONFIGURATION; +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`; - - p.note(passwordMessage.trim(), 'Password'); - - // Ask if user wants to migrate users without passwords - if (usersWithPasswords < totalUsers) { - const migrateWithoutPassword = await p.confirm({ - message: "Do you want to migrate users who don't have a password?", - initialValue: true, - }); - - if (p.isCancel(migrateWithoutPassword)) { - return null; // User cancelled +/** + * Analyzes the raw export data to count users per auth provider. + * + * Reads raw_app_meta_data.providers from each user record in the JSON file. + * This runs on the raw (pre-transformation) data since the transformer + * doesn't map raw_app_meta_data. + * + * @param filePath - Path to the JSON export file + * @returns Map of provider name to user count (e.g., { email: 142, discord: 5 }) + */ +export function analyzeUserProviders(filePath: string): Record { + try { + const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record< + string, + unknown + >[]; + const counts: Record = {}; + + for (const user of raw) { + const appMeta = user.raw_app_meta_data as + | Record + | undefined; + if (!appMeta?.providers) continue; + + const providers = appMeta.providers as string[]; + for (const provider of providers) { + counts[provider] = (counts[provider] || 0) + 1; + } } - return migrateWithoutPassword; + return counts; + } catch { + return {}; } - - return false; // All users have passwords, no need for skipPasswordRequirement } /** - * Displays user model analysis (first/last name) and Dashboard configuration guidance + * Finds user IDs that have any of the specified disabled social providers. * - * Shows how many users have first and last names and provides recommendations - * for Dashboard configuration (required vs optional vs disabled). + * Reads the raw export file and checks each user's raw_app_meta_data.providers. + * If a user has any provider in the disabled list, their ID is included in the result. * - * @param analysis - The field analysis results - * @returns true if users have name data and confirmation is needed, false otherwise + * @param filePath - Path to the JSON export file + * @param disabledProviders - List of provider names not enabled in Clerk (e.g., ['discord']) + * @returns Set of user IDs to exclude from import */ -export const displayUserModelAnalysis = (analysis: FieldAnalysis): boolean => { - const { totalUsers, fieldCounts } = analysis; - const usersWithFirstName = fieldCounts.firstName || 0; - const usersWithLastName = fieldCounts.lastName || 0; - - // Count users who have BOTH first and last name - const usersWithBothNames = Math.min(usersWithFirstName, usersWithLastName); - const someUsersHaveNames = usersWithFirstName > 0 || usersWithLastName > 0; - const noUsersHaveNames = usersWithFirstName === 0 && usersWithLastName === 0; - - let nameMessage = ''; - - // Show combined first and last name stats - if (usersWithBothNames === totalUsers) { - nameMessage += `${color.green('●')} All users have first and last names\n`; - } else if (someUsersHaveNames && !noUsersHaveNames) { - nameMessage += `${color.yellow('○')} Some users have first and last names\n`; - } else { - nameMessage += `${color.dim('○')} No users have first and last names\n`; - } +export function findUsersWithDisabledProviders( + filePath: string, + disabledProviders: string[] +): Set { + if (disabledProviders.length === 0) return new Set(); - nameMessage += '\n'; - nameMessage += DASHBOARD_CONFIGURATION; + try { + const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record< + string, + unknown + >[]; + const excluded = new Set(); + const disabledSet = new Set(disabledProviders); + + for (const user of raw) { + const appMeta = user.raw_app_meta_data as + | Record + | undefined; + if (!appMeta?.providers) continue; + + const providers = appMeta.providers as string[]; + if (providers.some((p) => disabledSet.has(p))) { + excluded.add(user.id as string); + } + } - if (usersWithBothNames === totalUsers) { - nameMessage += ` ${color.green('●')} ${color.bold(color.whiteBright('First and last name'))}: Must be enabled in the Dashboard and could be required\n`; - } else if (someUsersHaveNames) { - nameMessage += ` ${color.yellow('○')} ${color.bold(color.whiteBright('First and last name'))}: Must be enabled in the Dashboard but not required\n`; - } else { - nameMessage += ` ${color.dim('○')} ${color.bold(color.whiteBright('First and last name'))}: Could be enabled or disabled in the Dashboard but cannot be required\n`; + return excluded; + } catch { + return new Set(); } +} - p.note(nameMessage.trim(), 'User Model'); +// --- Cross-Reference Display --- - // Return true if confirmation is needed (when users have name data) - return someUsersHaveNames; -}; +interface ReadinessItem { + label: string; + userCount: number; + clerkEnabled: boolean | null; // null = Clerk config not available + section: 'identifiers' | 'auth' | 'social' | 'model'; +} /** - * Displays analysis of other fields (excluding identifiers, password, and names) + * Displays a unified migration readiness report. + * + * Cross-references the Supabase auth config, Clerk instance config, and user + * data to show a single report with all configuration items, their status, + * and affected user counts. * - * Shows fields like TOTP Secret that are present on all or some users, - * with Dashboard configuration guidance. + * Items are grouped by section (Identifiers, Authentication, Social Connections, + * User Model) and each shows: + * - ✓ enabled in Clerk (green) + * - ✗ not enabled in Clerk (red, needs attention) + * - ○ status unknown, enable in Dashboard (yellow, no Clerk config available) * - * @param analysis - The field analysis results - * @returns true if there are other fields to display, false otherwise + * @param items - List of readiness items to display + * @param analysis - Field analysis results for total user count and identifier check */ -export const displayOtherFieldsAnalysis = ( +export function displayCrossReference( + items: ReadinessItem[], analysis: FieldAnalysis -): boolean => { - // Filter out password, firstName, and lastName since they have dedicated sections - const excludedFields = ['Password', 'First Name', 'Last Name']; - const filteredPresentOnAll = analysis.presentOnAll.filter( - (f) => !excludedFields.includes(f) - ); - const filteredPresentOnSome = analysis.presentOnSome.filter( - (f) => !excludedFields.includes(f) - ); +): void { + const sections: Partial> = {}; + for (const item of items) { + if (!sections[item.section]) sections[item.section] = []; + sections[item.section].push(item); + } - let fieldsMessage = ''; + let message = ''; + const needsAttention: ReadinessItem[] = []; - if (filteredPresentOnAll.length > 0) { - fieldsMessage += color.bold('Fields present on ALL users:\n'); - fieldsMessage += color.dim( - 'These fields must be enabled in the Clerk Dashboard and could be set as required.' - ); - for (const field of filteredPresentOnAll) { - fieldsMessage += `\n ${color.green('●')} ${color.reset(field)}`; + const sectionLabels: Record = { + identifiers: 'Identifiers', + auth: 'Authentication', + social: 'Social Connections', + model: 'User Model', + }; + + const sectionOrder = ['identifiers', 'auth', 'social', 'model']; + + for (const section of sectionOrder) { + const sectionItems = sections[section]; + if (!sectionItems || sectionItems.length === 0) continue; + + message += color.bold(color.whiteBright(`${sectionLabels[section]}\n`)); + + for (const item of sectionItems) { + const countStr = + item.userCount === analysis.totalUsers + ? 'all users' + : `${item.userCount} user${item.userCount === 1 ? '' : 's'}`; + + if (item.clerkEnabled === true) { + message += ` ${color.green('✓')} ${item.label} — ${color.dim(`enabled in Clerk — ${countStr}`)}\n`; + } else if (item.clerkEnabled === false) { + message += ` ${color.red('✗')} ${item.label} — ${color.red('not enabled in Clerk')} — ${color.dim(countStr)}\n`; + needsAttention.push(item); + } else { + message += ` ${color.yellow('○')} ${item.label} — ${color.dim(`${countStr} — enable in Clerk Dashboard`)}\n`; + } } + + message += '\n'; } - if (filteredPresentOnSome.length > 0) { - if (fieldsMessage) fieldsMessage += '\n\n'; - fieldsMessage += color.bold('Fields present on SOME users:\n'); - fieldsMessage += color.dim( - 'These fields must be enabled in the Clerk Dashboard but must be set as optional.' + // Check for users without any identifier + if (analysis.identifiers.hasAnyIdentifier < analysis.totalUsers) { + const missing = analysis.totalUsers - analysis.identifiers.hasAnyIdentifier; + message += color.red( + `⚠ ${missing} user${missing === 1 ? '' : 's'} without any identifier (cannot be imported)\n\n` ); - for (const field of filteredPresentOnSome) { - fieldsMessage += `\n ${color.yellow('○')} ${color.reset(field)}`; - } } - if (fieldsMessage) { - p.note(fieldsMessage.trim(), 'Other Fields'); - return true; + // Summary + if (needsAttention.length > 0) { + const totalAffected = needsAttention.reduce( + (sum, item) => sum + item.userCount, + 0 + ); + message += color.yellow( + `⚠ ${needsAttention.length} setting${needsAttention.length === 1 ? '' : 's'} need${needsAttention.length === 1 ? 's' : ''} attention${totalAffected > 0 ? ` (up to ${totalAffected} users affected)` : ''}` + ); + } else if (items.some((i) => i.clerkEnabled !== null)) { + message += color.green('All settings are configured in Clerk'); } - return false; -}; + p.note(message.trim(), 'Migration Readiness'); +} /** * Handles Firebase hash configuration collection and validation @@ -1416,9 +1461,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( @@ -1427,81 +1469,199 @@ 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, - }); - - if (p.isCancel(confirmIdentifiers) || !confirmIdentifiers) { - p.cancel( - 'Migration cancelled. Please configure identifier settings and try again.' - ); - process.exit(0); + // Step 5: Fetch configurations for cross-reference + const isSupabase = initialArgs.key === 'supabase'; + + const publishableKey = + process.env.CLERK_PUBLISHABLE_KEY || + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + const supabaseUrl = + process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL; + const supabaseApiKey = + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || + process.env.SUPABASE_ANON_KEY || + process.env.SUPABASE_SERVICE_ROLE_KEY; + + const configSpinner = p.spinner(); + configSpinner.start('Checking configuration...'); + + const [clerkConfig, supabaseProviders] = await Promise.all([ + publishableKey ? fetchClerkConfig(publishableKey) : Promise.resolve(null), + isSupabase && supabaseUrl && supabaseApiKey + ? fetchSupabaseProviders(supabaseUrl, supabaseApiKey) + : Promise.resolve(null), + ]); + + // Analyze raw provider counts (Supabase only, from raw export data) + let providerCounts: Record = {}; + if (isSupabase) { + const filePath = createImportFilePath(initialArgs.file); + providerCounts = analyzeUserProviders(filePath); + } + + 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, + section: 'identifiers', + }); } - // Step 6: Display password analysis and get migration preference - const skipPasswordRequirement = await displayPasswordAnalysis(analysis); + const phoneCount = + analysis.identifiers.verifiedPhones + analysis.identifiers.unverifiedPhones; + if (phoneCount > 0) { + items.push({ + label: 'Phone', + userCount: phoneCount, + clerkEnabled: clerkConfig?.attributes.phone_number?.enabled ?? null, + section: 'identifiers', + }); + } - if (skipPasswordRequirement === null) { - p.cancel('Migration cancelled.'); - process.exit(0); + if (analysis.identifiers.username > 0) { + items.push({ + label: 'Username', + userCount: analysis.identifiers.username, + clerkEnabled: clerkConfig?.attributes.username?.enabled ?? null, + section: 'identifiers', + }); } - // 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, + // Authentication + const passwordCount = analysis.fieldCounts.password || 0; + if (passwordCount > 0) { + items.push({ + label: 'Password', + userCount: passwordCount, + clerkEnabled: clerkConfig?.attributes.password?.enabled ?? null, + section: 'auth', }); + } - if (p.isCancel(confirmPassword) || !confirmPassword) { - p.cancel( - 'Migration cancelled. Please enable Password in the Dashboard and try again.' - ); - process.exit(0); + // Social connections (from Supabase config) + const disabledProviders: string[] = []; + if (supabaseProviders) { + for (const provider of supabaseProviders) { + const clerkKey = `oauth_${provider}`; + const clerkEnabled = clerkConfig + ? (clerkConfig.social[clerkKey]?.enabled ?? false) + : null; + + items.push({ + label: OAUTH_PROVIDER_LABELS[provider] || provider, + userCount: providerCounts[provider] || 0, + clerkEnabled, + section: 'social', + }); + + if (clerkEnabled === false) { + disabledProviders.push(provider); + } } } - // Step 7: Display user model analysis - const needsUserModelConfirmation = displayUserModelAnalysis(analysis); + // Find users to exclude (those with disabled social providers) + let excludedUserIds = new Set(); + if (isSupabase && disabledProviders.length > 0) { + const filePath = createImportFilePath(initialArgs.file); + excludedUserIds = findUsersWithDisabledProviders( + filePath, + disabledProviders + ); + } - if (needsUserModelConfirmation) { - const confirmUserModel = await p.confirm({ - message: - 'Have you configured first and last name settings in the Dashboard?', - initialValue: true, + // User model + const firstNameCount = analysis.fieldCounts.firstName || 0; + if (firstNameCount > 0) { + items.push({ + label: 'First Name', + userCount: firstNameCount, + clerkEnabled: clerkConfig?.attributes.first_name?.enabled ?? null, + section: 'model', }); + } - if (p.isCancel(confirmUserModel) || !confirmUserModel) { - p.cancel( - 'Migration cancelled. Please configure user model settings and try again.' - ); - process.exit(0); - } + const lastNameCount = analysis.fieldCounts.lastName || 0; + if (lastNameCount > 0) { + items.push({ + label: 'Last Name', + userCount: lastNameCount, + clerkEnabled: clerkConfig?.attributes.last_name?.enabled ?? null, + section: 'model', + }); } - // Step 8: Display and confirm other field settings (if any) - const hasOtherFields = displayOtherFieldsAnalysis(analysis); + // Step 7: Display unified cross-reference report + displayCrossReference(items, analysis); - if (hasOtherFields) { - const confirmFields = await p.confirm({ - message: 'Have you configured the other field settings in the Dashboard?', - initialValue: true, - }); + // Show hint if configs couldn't be loaded + if (!clerkConfig && !publishableKey) { + p.log.info( + color.dim( + 'Set CLERK_PUBLISHABLE_KEY in .env to enable automatic configuration checking.' + ) + ); + } else if (!clerkConfig && publishableKey) { + p.log.warn(color.yellow('Could not fetch Clerk instance configuration.')); + } - if (p.isCancel(confirmFields) || !confirmFields) { - p.cancel( - 'Migration cancelled. Please configure field settings and try again.' + if (isSupabase && !supabaseProviders) { + if (supabaseUrl && supabaseApiKey) { + p.log.warn( + color.yellow( + 'Could not fetch Supabase auth settings. Social connections not checked.' + ) + ); + } else { + p.log.info( + color.dim( + 'Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in .env to check social connections.' + ) ); - process.exit(0); } } - // Step 9: Final confirmation + // Step 8: Show exclusion info and confirm + if (excludedUserIds.size > 0) { + const providerNames = disabledProviders + .map((p) => OAUTH_PROVIDER_LABELS[p] || p) + .join(', '); + p.log.warn( + color.yellow( + `${excludedUserIds.size} user${excludedUserIds.size === 1 ? '' : 's'} will be excluded — signed up via disabled social connection${disabledProviders.length === 1 ? '' : 's'} (${providerNames})` + ) + ); + } + + const importCount = userCount - excludedUserIds.size; + const hasIssues = items.some((i) => i.clerkEnabled === false); + + if (importCount <= 0) { + p.cancel('No users can be imported after exclusions.'); + process.exit(0); + } + + 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) { @@ -1509,16 +1669,20 @@ 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, }); + // 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 3a1dc36..68a6ad7 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/tests/migrate/cli.test.ts b/tests/migrate/cli.test.ts index 1df5c0b..a2ed066 100644 --- a/tests/migrate/cli.test.ts +++ b/tests/migrate/cli.test.ts @@ -3,10 +3,10 @@ import fs from 'fs'; import path from 'path'; import { analyzeFields, + analyzeUserProviders, detectInstanceType, - displayIdentifierAnalysis, - displayOtherFieldsAnalysis, - formatCount, + displayCrossReference, + findUsersWithDisabledProviders, hasValue, loadRawUsers, loadSettings, @@ -577,38 +577,266 @@ describe('analyzeFields', () => { }); // ============================================================================ -// formatCount tests +// analyzeUserProviders tests // ============================================================================ -describe('formatCount', () => { - test('returns "All users have {label}" when count equals total', () => { - const result = formatCount(10, 10, 'email'); - expect(result).toBe('All users have email'); +describe('analyzeUserProviders', () => { + beforeEach(() => { + vi.clearAllMocks(); }); - test('returns "No users have {label}" when count is 0', () => { - const result = formatCount(0, 10, 'email'); - expect(result).toBe('No users have email'); + test('counts users per provider from raw_app_meta_data', () => { + const mockData = [ + { raw_app_meta_data: { providers: ['email'] } }, + { raw_app_meta_data: { providers: ['email'] } }, + { raw_app_meta_data: { providers: ['discord'] } }, + { raw_app_meta_data: { providers: ['email', 'google'] } }, + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = analyzeUserProviders('test.json'); + + expect(result).toEqual({ email: 3, discord: 1, google: 1 }); }); - test('returns "{count} of {total} users have {label}" for partial counts', () => { - const result = formatCount(5, 10, 'email'); - expect(result).toBe('5 of 10 users have email'); + test('returns empty object for invalid file', () => { + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('File not found'); + }); + + const result = analyzeUserProviders('missing.json'); + + expect(result).toEqual({}); }); - test('handles count of 1 out of many', () => { - const result = formatCount(1, 100, 'a username'); - expect(result).toBe('1 of 100 users have a username'); + test('skips users without raw_app_meta_data', () => { + const mockData = [ + { raw_app_meta_data: { providers: ['email'] } }, + { email: 'test@example.com' }, // no raw_app_meta_data + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = analyzeUserProviders('test.json'); + + expect(result).toEqual({ email: 1 }); }); +}); + +// ============================================================================ +// findUsersWithDisabledProviders tests +// ============================================================================ - test('handles large numbers', () => { - const result = formatCount(1234, 5678, 'verified emails'); - expect(result).toBe('1234 of 5678 users have verified emails'); +describe('findUsersWithDisabledProviders', () => { + beforeEach(() => { + vi.clearAllMocks(); }); - test('handles count equal to total of 1', () => { - const result = formatCount(1, 1, 'phone number'); - expect(result).toBe('All users have phone number'); + test('returns user IDs that have disabled providers', () => { + const mockData = [ + { id: 'user-1', raw_app_meta_data: { providers: ['email'] } }, + { id: 'user-2', raw_app_meta_data: { providers: ['discord'] } }, + { + id: 'user-3', + raw_app_meta_data: { providers: ['email', 'discord'] }, + }, + { id: 'user-4', raw_app_meta_data: { providers: ['google'] } }, + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = findUsersWithDisabledProviders('test.json', ['discord']); + + expect(result).toEqual(new Set(['user-2', 'user-3'])); + }); + + test('returns empty set when no disabled providers specified', () => { + const result = findUsersWithDisabledProviders('test.json', []); + + expect(result).toEqual(new Set()); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); + + test('returns empty set for invalid file', () => { + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('File not found'); + }); + + const result = findUsersWithDisabledProviders('missing.json', ['discord']); + + expect(result).toEqual(new Set()); + }); + + test('handles multiple disabled providers', () => { + const mockData = [ + { id: 'user-1', raw_app_meta_data: { providers: ['email'] } }, + { id: 'user-2', raw_app_meta_data: { providers: ['discord'] } }, + { id: 'user-3', raw_app_meta_data: { providers: ['twitter'] } }, + { + id: 'user-4', + raw_app_meta_data: { providers: ['email', 'google'] }, + }, + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = findUsersWithDisabledProviders('test.json', [ + 'discord', + 'twitter', + ]); + + expect(result).toEqual(new Set(['user-2', 'user-3'])); + }); + + test('skips users without raw_app_meta_data', () => { + const mockData = [ + { id: 'user-1', email: 'test@example.com' }, + { id: 'user-2', raw_app_meta_data: { providers: ['discord'] } }, + ]; + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const result = findUsersWithDisabledProviders('test.json', ['discord']); + + expect(result).toEqual(new Set(['user-2'])); + }); +}); + +// ============================================================================ +// displayCrossReference tests +// ============================================================================ + +describe('displayCrossReference', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('calls p.note with Migration Readiness title', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.any(String), + 'Migration Readiness' + ); + }); + + test('shows enabled items with green check', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: true as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('enabled in Clerk'), + 'Migration Readiness' + ); + }); + + test('shows disabled items with red cross', () => { + const items = [ + { + label: 'Discord', + userCount: 5, + clerkEnabled: false as boolean | null, + section: 'social' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('not enabled in Clerk'), + 'Migration Readiness' + ); + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('1 setting'), + 'Migration Readiness' + ); + }); + + test('shows unknown items with yellow circle when no Clerk config', () => { + const items = [ + { + label: 'Email', + userCount: 10, + clerkEnabled: null as boolean | null, + section: 'identifiers' as const, + }, + ]; + const analysis = { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 10, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 10, + }, + totalUsers: 10, + fieldCounts: {}, + }; + + displayCrossReference(items, analysis); + + expect(p.note).toHaveBeenCalledWith( + expect.stringContaining('enable in Clerk Dashboard'), + 'Migration Readiness' + ); }); }); @@ -869,249 +1097,3 @@ describe('loadRawUsers', () => { }); }); }); - -// ============================================================================ -// displayIdentifierAnalysis tests -// ============================================================================ - -describe('displayIdentifierAnalysis', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('calls p.note with analysis message', () => { - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 10, - unverifiedEmails: 0, - verifiedPhones: 10, - unverifiedPhones: 0, - username: 10, - hasAnyIdentifier: 10, - }, - totalUsers: 10, - fieldCounts: {}, - }; - - displayIdentifierAnalysis(analysis); - - expect(p.note).toHaveBeenCalledWith(expect.any(String), 'Identifiers'); - }); - - test('handles analysis with all users having identifiers', () => { - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 5, - unverifiedEmails: 0, - verifiedPhones: 5, - unverifiedPhones: 0, - username: 5, - hasAnyIdentifier: 5, - }, - totalUsers: 5, - fieldCounts: {}, - }; - - // Should not throw - expect(() => displayIdentifierAnalysis(analysis)).not.toThrow(); - }); - - test('handles analysis with missing identifiers', () => { - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 3, - unverifiedEmails: 0, - verifiedPhones: 2, - unverifiedPhones: 0, - username: 1, - hasAnyIdentifier: 8, - }, - totalUsers: 10, - fieldCounts: {}, - }; - - // Should not throw - expect(() => displayIdentifierAnalysis(analysis)).not.toThrow(); - }); - - test('handles analysis with unverified identifiers', () => { - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 5, - unverifiedEmails: 3, - verifiedPhones: 5, - unverifiedPhones: 2, - username: 5, - hasAnyIdentifier: 5, - }, - totalUsers: 5, - fieldCounts: {}, - }; - - // Should not throw - expect(() => displayIdentifierAnalysis(analysis)).not.toThrow(); - }); - - test('recommends email as required when all importable users have email (even if some users lack identifiers)', () => { - // Scenario: 3309 users have email, 259 users have no identifier (will fail validation) - // All importable users (3309) have email, so email should be required - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 3309, - unverifiedEmails: 259, - verifiedPhones: 0, - unverifiedPhones: 0, - username: 0, - hasAnyIdentifier: 3309, // Only users with verified email can be imported - }, - totalUsers: 3568, // Total includes users who will fail validation - fieldCounts: {}, - }; - - displayIdentifierAnalysis(analysis); - - // Verify p.note was called with a message that includes email as required - expect(p.note).toHaveBeenCalledWith( - expect.stringContaining('Enable and optionally require'), - 'Identifiers' - ); - expect(p.note).toHaveBeenCalledWith( - expect.stringContaining('email'), - 'Identifiers' - ); - }); - - test('recommends identifiers as optional when not all importable users have them', () => { - // Scenario: 50 users have email, 50 users have phone, 100 total importable - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 50, - unverifiedEmails: 0, - verifiedPhones: 50, - unverifiedPhones: 0, - username: 0, - hasAnyIdentifier: 100, // 50 with email + 50 with phone = 100 importable - }, - totalUsers: 100, - fieldCounts: {}, - }; - - displayIdentifierAnalysis(analysis); - - // Both should be optional since not all importable users have each identifier - expect(p.note).toHaveBeenCalledWith( - expect.stringContaining('Enable in the Dashboard but do not require'), - 'Identifiers' - ); - }); -}); - -// ============================================================================ -// displayOtherFieldsAnalysis tests -// ============================================================================ - -describe('displayOtherFieldsAnalysis', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('returns false when no fields are analyzed', () => { - const analysis = { - presentOnAll: [], - presentOnSome: [], - identifiers: { - verifiedEmails: 0, - unverifiedEmails: 0, - verifiedPhones: 0, - unverifiedPhones: 0, - username: 0, - hasAnyIdentifier: 0, - }, - totalUsers: 0, - fieldCounts: {}, - }; - - const result = displayOtherFieldsAnalysis(analysis); - - expect(result).toBe(false); - expect(p.note).not.toHaveBeenCalled(); - }); - - test('returns true when fields are present on all users', () => { - const analysis = { - presentOnAll: ['TOTP Secret'], - presentOnSome: [], - identifiers: { - verifiedEmails: 10, - unverifiedEmails: 0, - verifiedPhones: 0, - unverifiedPhones: 0, - username: 0, - hasAnyIdentifier: 10, - }, - totalUsers: 10, - fieldCounts: {}, - }; - - const result = displayOtherFieldsAnalysis(analysis); - - expect(result).toBe(true); - expect(p.note).toHaveBeenCalledWith(expect.any(String), 'Other Fields'); - }); - - test('returns true when fields are present on some users', () => { - const analysis = { - presentOnAll: [], - presentOnSome: ['TOTP Secret'], - identifiers: { - verifiedEmails: 10, - unverifiedEmails: 0, - verifiedPhones: 0, - unverifiedPhones: 0, - username: 0, - hasAnyIdentifier: 10, - }, - totalUsers: 10, - fieldCounts: {}, - }; - - const result = displayOtherFieldsAnalysis(analysis); - - expect(result).toBe(true); - expect(p.note).toHaveBeenCalledWith(expect.any(String), 'Other Fields'); - }); - - test('returns true when both presentOnAll and presentOnSome have fields', () => { - const analysis = { - presentOnAll: ['TOTP Secret'], - presentOnSome: [], - identifiers: { - verifiedEmails: 10, - unverifiedEmails: 0, - verifiedPhones: 0, - unverifiedPhones: 0, - username: 0, - hasAnyIdentifier: 10, - }, - totalUsers: 10, - fieldCounts: {}, - }; - - const result = displayOtherFieldsAnalysis(analysis); - - expect(result).toBe(true); - expect(p.note).toHaveBeenCalledWith(expect.any(String), 'Other Fields'); - }); -});