diff --git a/.env.example b/.env.example index 88134cb..217320f 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,43 @@ -CLERK_SECRET_KEY=sk_live_ +# ============================================================================ +# REQUIRED: Clerk Secret Key +# ============================================================================ +# Get your secret key from the Clerk Dashboard: https://dashboard.clerk.com +# Format: sk_test_... (development) or sk_live_... (production) +CLERK_SECRET_KEY=sk_ -# By default you can not import users to your development instance -# Change this to 'true' if you want to import to a development instance -IMPORT_TO_DEV_INSTANCE=false +# ============================================================================ +# OPTIONAL: Rate Limit Override +# ============================================================================ +# Rate limit in requests per second for user creation +# +# Auto-configured based on your CLERK_SECRET_KEY: +# - Production (sk_live_*): 100 requests/second (Clerk limit: 1000 req/10s) +# - Development (sk_test_*): 10 requests/second (Clerk limit: 100 req/10s) +# +# Only set this if you need to reduce the rate for safety or testing, or if +# have a rate limit exception and can increase the speed +# Example: RATE_LIMIT=50 +# RATE_LIMIT= -# Delay between createUser requests -# Clerk's normal rate limit is 1 request/second -DELAY=1050 +# ============================================================================ +# OPTIONAL: Concurrency Limit Override +# ============================================================================ +# Number of concurrent API requests during migration/deletion +# +# Auto-calculated to achieve ~95% of rate limit (assumes 100ms API latency): +# - Production (100 req/s): 9 concurrent = ~90-95 req/s throughput +# - Development (10 req/s): 1 concurrent = ~9-10 req/s throughput +# +# Increase this value if: +# - Your API responses are slower (>100ms) and you want faster throughput +# - You want to process users faster (higher concurrency = faster, but may hit rate limits) +# +# Decrease this value if: +# - You're hitting rate limits (429 errors) +# - You want to be more conservative +# +# Examples: +# - CONCURRENCY_LIMIT=15 (faster, ~150 req/s, may hit some rate limits) +# - CONCURRENCY_LIMIT=5 (slower, ~50 req/s, very safe) +# CONCURRENCY_LIMIT= diff --git a/.gitignore b/.gitignore index ddff63b..60a7829 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ node_modules .env -users.json -migration-log.json -bun.lockb +.settings package-lock.json yarn.lock pnpm-lock.yaml +logs +testing/ +.claude diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..44e5701 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +logs/** +samples/** +testing/** +**/*.json +**/*.csv +**/*.log +node_modules +bun.lock + + diff --git a/.prettierrc.mjs b/.prettierrc.mjs new file mode 100644 index 0000000..ae47161 --- /dev/null +++ b/.prettierrc.mjs @@ -0,0 +1,11 @@ +export default { + trailingComma: 'es5', + tabWidth: 2, + semi: true, + singleQuote: true, + printWidth: 80, + bracketSpacing: true, + arrowParens: 'always', + endOfLine: 'lf', + useTabs: true, +}; diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..016bc9c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,210 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +This is a CLI tool for migrating users from various authentication platforms (Clerk, Auth0, Supabase, AuthJS) to a Clerk instance. It handles rate limiting, validates user data with Zod schemas, and provides comprehensive logging of successes and failures. + +## Common Commands + +### Development Commands + +- `bun migrate` - Start the migration process (interactive CLI) +- `bun delete` - Delete all migrated users (uses externalId to identify users) +- `bun clean-logs` - Remove all log files from the `./logs` folder +- `bun run test` - Run all tests with Vitest +- `bun lint` - Run ESLint +- `bun lint:fix` - Auto-fix ESLint issues +- `bun format` - Format code with Prettier +- `bun format:test` - Check formatting without making changes + +### Testing + +- `bun run test` - Run all test files +- `bun run test ` - Run a specific test file (e.g., `bun run test validator.test.ts`) +- `bun run test --watch` - Run tests in watch mode + +## Architecture + +### Transformer System + +The migration tool uses a **transformer pattern** to support different source platforms. Each transformer defines: + +1. **Field Transformer**: Maps source platform fields to Clerk's schema + - Example: Auth0's `_id.$oid` → Clerk's `userId` + - Example: Supabase's `encrypted_password` → Clerk's `password` + - Handles nested field flattening (see `flattenObjectSelectively` in `src/migrate/functions.ts`) + +2. **Optional Default Fields**: Applied to all users from that platform + - Example: Supabase defaults `passwordHasher` to `"bcrypt"` + +3. **Optional Post-Transform**: Custom logic applied after field mapping + - Example: Auth0 converts metadata from string to objects + +**Transformer locations**: `src/migrate/transformers/` + +- `clerk.ts` - Clerk-to-Clerk migrations +- `auth0.ts` - Auth0 migrations +- `supabase.ts` - Supabase migrations +- `authjs.ts` - AuthJS migrations +- `index.ts` - Exports all transformers as array + +**Adding a new transformer**: + +1. Create a new file in `src/migrate/transformers/` with transformer config +2. Export it in `src/migrate/transformers/index.ts` +3. The CLI will automatically include it in the platform selection + +### Data Flow + +``` +User File (CSV/JSON) + ↓ +loadUsersFromFile (functions.ts) + ↓ Parse file + ↓ Apply transformer defaults + ↓ +transformUsers (functions.ts) + ↓ Transform field names via transformer + ↓ Apply transformer postTransform + ↓ Validate with Zod schema + ↓ Log validation errors + ↓ +importUsers (import-users.ts) + ↓ Process sequentially with rate limiting + ↓ +createUser (import-users.ts) + ↓ Create user with primary email/phone + ↓ Add additional emails/phones + ↓ Handle errors and logging +``` + +### Schema Validation + +User validation is centralized in `src/migrate/validator.ts`: + +- Uses Zod for schema validation +- Enforces: at least one verified identifier (email or phone) +- Enforces: passwordHasher required when password is present +- Fields can be single values or arrays (e.g., `email: string | string[]`) +- All fields except `userId` are optional + +**Adding a new field**: Edit `userSchema` in `src/migrate/validator.ts` + +### Rate Limiting + +Rate limits are auto-configured based on instance type (detected from `CLERK_SECRET_KEY`): + +- **Production** (`sk_live_*`): 100 requests/second (Clerk's limit: 1000 req/10s) +- **Development** (`sk_test_*`): 10 requests/second (Clerk's limit: 100 req/10s) + +Configuration in `src/envs-constants.ts`: + +- `RATE_LIMIT` - Requests per second (auto-configured based on instance type) +- `CONCURRENCY_LIMIT` - Number of concurrent requests (defaults to ~95% of rate limit) + - Production: 9 concurrent (assumes 100ms API latency → ~90-95 req/s throughput) + - Development: 1 concurrent (assumes 100ms API latency → ~9-10 req/s throughput) +- Override defaults via `.env` file with `RATE_LIMIT` or `CONCURRENCY_LIMIT` + +The script uses **p-limit for concurrency control** across all API calls: + +- Limits the number of simultaneously executing API calls +- Formula: `CONCURRENCY_LIMIT = RATE_LIMIT * 0.095` (assumes 100ms latency) +- With X concurrent requests and 100ms latency: throughput ≈ X \* 10 req/s +- Shared limiter across ALL operations (user creation, email creation, phone creation) + +**Performance**: + +- Production: ~3,500 users in ~35 seconds (assuming 1 email per user) +- Development: ~3,500 users in ~350 seconds +- Users can increase `CONCURRENCY_LIMIT` for faster processing (may hit some rate limits) + +**Retry logic**: + +- If a 429 occurs, uses Retry-After value from API response +- Falls back to 10 second default if Retry-After not available +- Centralized in `getRetryDelay()` function in `src/utils.ts` +- The script automatically retries up to 5 times (configurable via MAX_RETRIES) + +### Logging System + +All operations create timestamped logs in `./logs/`: + +- `{timestamp}-import.log` - Success/failure for each user +- `{timestamp}-import-errors.log` - Detailed error information +- `{timestamp}-delete.log` - User deletion results +- `{timestamp}-delete-errors.log` - Deletion errors + +Logger functions in `src/logger.ts`: + +- `importLogger()` - Log import attempt +- `errorLogger()` - Log creation errors +- `validationLogger()` - Log validation errors +- `deleteLogger()` - Log deletion attempt +- `deleteErrorLogger()` - Log deletion errors + +### CLI Analysis Features + +The CLI (in `src/migrate/cli.ts`) analyzes the import file before migration and provides: + +1. **Identifier Analysis**: Shows which users have emails, phones, usernames +2. **Password Analysis**: Prompts whether to migrate users without passwords +3. **User Model Analysis**: Shows first/last name coverage +4. **Dashboard Configuration Guidance**: Tells user which fields to enable/require in Clerk Dashboard +5. **Instance Type Detection**: Prevents importing >500 users to dev instances + +**Key CLI functions**: + +- `runCLI()` - Main CLI orchestrator +- `analyzeFields()` - Analyzes user data for field coverage +- `displayIdentifierAnalysis()` - Shows identifier stats + Dashboard guidance +- `displayPasswordAnalysis()` - Shows password stats + prompts for skipPasswordRequirement +- `loadSettings()` / `saveSettings()` - Persists CLI choices in `.settings` file + +### Error Handling + +The codebase uses a consistent error handling pattern: + +- `tryCatch()` utility (in `src/utils.ts`) - Returns `[result, error]` (error is null on success) +- Used extensively to make additional emails/phones non-fatal +- Rate limit errors (429) trigger automatic retry with delay +- Validation errors are logged but don't stop the migration + +## Important Implementation Notes + +### Clerk-to-Clerk Migrations + +When migrating from Clerk to Clerk (`key === "clerk"`), the transformer consolidates email and phone arrays: + +- Merges `email`, `emailAddresses`, `unverifiedEmailAddresses` into single array +- Merges `phone`, `phoneNumbers`, `unverifiedPhoneNumbers` into single array +- First item becomes primary, rest are added as additional identifiers +- See `transformUsers()` in `src/migrate/functions.ts` around line 129 + +### Password Hasher Validation + +Invalid password hashers cause immediate failure: + +- Valid hashers are defined in `PASSWORD_HASHERS` constant (`src/types.ts`) +- Detection logic in `transformUsers()` checks if hasher exists but is invalid +- Throws detailed error with user ID, row number, and list of valid hashers + +### User Creation Multi-Step Process + +Creating a user involves multiple API calls, all managed by the shared concurrency limiter: + +1. Create user with primary email/phone + core fields (rate-limited) +2. Add additional emails (each rate-limited individually, non-fatal) +3. Add additional phones (each rate-limited individually, non-fatal) + +This is necessary because Clerk's API only accepts one primary identifier per creation call. All API calls share the same concurrency pool, maximizing throughput across all operations. + +### Environment Variable Detection + +The script auto-detects instance type from `CLERK_SECRET_KEY`: + +- Checks if key contains `"live"` → production +- Otherwise → development +- Used to set default delays and enforce user limits +- See `detectInstanceType()` and `createEnvSchema()` in `src/envs-constants.ts` diff --git a/LICENSE.Apache-2.0.md b/LICENSE.Apache-2.0.md index 559cd29..db2b9a9 100644 --- a/LICENSE.Apache-2.0.md +++ b/LICENSE.Apache-2.0.md @@ -2,180 +2,180 @@ Version 2.0, January 2004 http://www.apache.org/licenses/ - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" @@ -186,16 +186,16 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 Clerk Inc +Copyright 2023 Clerk Inc - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index 57111ed..46327e3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Clerk User Import Script +# Clerk User Migration Script ## Description @@ -13,48 +13,22 @@ git clone git@github.com:clerk/migration-script cd migration-script -npm install +bun install ``` -### Users.json file -Create a `users.json` file. This file should be populated with all the users that need to be imported. The users should pass this schema: +### Users file +The script is designed to import from multiple sources, including moving users from one Clerk instance to another. You may need to edit the transformer for your source. Please see below for more information on that. -```ts -[ - { - "userId": "string", - "email": "email", - "firstName": "string (optional)", - "lastName": "string (optional)", - "password": "string (optional)", - "passwordHasher": "argon2 | argon | bcrypt | md5 | pbkdf2_sha256 | pbkdf2_sha256_django | pbkdf2_sha1 | scrypt_firebase", - } -] -``` +The script will import from a CSV or JSON. It accounts for empty fields in a CSV and will remove them when converting from CSV to a javascript object. -The only required fields are `userId` and `email`. First and last names can be added if available. Clerk will also accept hashed password values along with the hashing algorithm used (the default is `bcrypt`). +The only required fields are `userId` and an identifier (one of `email`, `phone` or `username`). -Here are a couple examples. +#### Samples -```json -[ - { - "userId": "1", - "email": "dev@clerk.com", - "firstName": "Dev", - "lastName": "Agrawal" - }, - { - "userId": "2", - "email": "john@blurp.com", - "password": "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", - "passwordHasher": "bcrypt" // default value - } -] -``` +The samples/ folder contains some samples you can test with. The samples include issues that will produce errors when running the import. -The samples/ folder contains some samples, including issues that will produce errors when running the import. +Some sample users have passwords. The password is `Kk4aPMeiaRpAs2OeX1NE`. ### Secret Key @@ -67,23 +41,65 @@ CLERK_SECRET_KEY=your-secret-key ### Run the script ```bash -npm start +bun migrate ``` -The script will begin process the users and attempting to import them into Clerk. The script has a built in delay to respect the rate limits for the Clerk Backend API. If the script does hit a rate limit then it will wait the required 10 seconds and resume. Any errors will be logged to a `migration-log.json` file. +The script will begin processing users and attempting to import them into Clerk. The script respects rate limits for the Clerk Backend API. If the script hits a rate limit, it will wait 10 seconds and retry (up to 5 times). Any errors will be logged to timestamped log files in the `./logs` folder. + +The script can be run on the same data multiple times. Clerk automatically uses the email as a unique key so users won't be created again. -The script can be run on the same data multiple times, Clerk automatically uses the email as a unique key so users can't be created again. +**Error Handling & Resuming**: If the migration stops for any reason (error, interruption, etc.), the script will display the last processed user ID. You can resume the migration from that point by providing the user ID when prompted, or by using: + +```bash +bun migrate --resume-after="user_xxx" +``` ### Configuration The script can be configured through the following environment variables: -| Variable | Description | Default | -| -------- | ----------- | ------- | -| `CLERK_SECRET_KEY` | Your Clerk secret key | `undefined` | -| `DELAY_MS` | Delay between requests to respect rate limits | `1000` | -| `RETRY_DELAY_MS` | Delay when the rate limit is hit | `10000` | -| `OFFSET` | Offset to start migration (number of users to skip) | `0` | +| Variable | Description | +| ------------------- | ------------------------------------------------------------------------- | +| `CLERK_SECRET_KEY` | Your Clerk secret key | +| `RATE_LIMIT` | Rate limit in requests/second (auto-configured: 100 for prod, 10 for dev) | +| `CONCURRENCY_LIMIT` | Number of concurrent requests (auto-configured: ~9 for prod, ~1 for dev) | + +The script automatically detects production vs development instances from your `CLERK_SECRET_KEY` and sets appropriate rate limits and concurrency: + +- **Production** (`sk_live_*`): + - Rate limit: 100 requests/second (Clerk's limit: 1000 requests per 10 seconds) + - Concurrency: 9 concurrent requests (~95% of rate limit with 100ms API latency) + - Typical migration speed: ~3,500 users in ~35 seconds +- **Development** (`sk_test_*`): + - Rate limit: 10 requests/second (Clerk's limit: 100 requests per 10 seconds) + - Concurrency: 1 concurrent request (~95% of rate limit with 100ms API latency) + - Typical migration speed: ~3,500 users in ~350 seconds + +You can override these values by setting `RATE_LIMIT` or `CONCURRENCY_LIMIT` in your `.env` file. + +**Tuning Concurrency**: If you want faster migrations, you can increase `CONCURRENCY_LIMIT` (e.g., `CONCURRENCY_LIMIT=15` for ~150 req/s). Note that higher concurrency may trigger rate limit errors (429), which are automatically retried. + +## Other commands + +### Delete users + +``` +bun delete +``` + +This will delete all migrated users from the instance. It should not delete pre-existing users, but it is not recommended to use this with a production instance that has pre-existing users. Please use caution with this command. + +### Clean logs + +``` +bun clean-logs +``` + +All migrations and deletions will create logs in the `./logs` folder. This command will delete those logs. + +## 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. ## Handling the Foreign Key constraint @@ -93,7 +109,7 @@ If you were using a database, you will have data tied to your previous auth syst Our sessions allow for conditional expressions. This would allow you add a session claim that will return either the `externalId` (the previous id for your user) when it exists, or the `userId` from Clerk. This will result in your imported users returning their `externalId` while newer users will return the Clerk `userId`. -In your Dashboard, go to Sessions -> Edit. Add the following: +In your Dashboard, go to Sessions -> Edit. Add the following: ```json { @@ -102,12 +118,14 @@ In your Dashboard, go to Sessions -> Edit. Add the following: ``` You can now access this value using the following: -```ts + +```ts const { sessionClaims } = auth(); -console.log(sessionClaims.userId) +console.log(sessionClaims.userId); ``` -You can add the following for typescript: +You can add the following for typescript: + ```js // types/global.d.ts @@ -126,3 +144,236 @@ You could continue to generate unique ids for the database as done previously, a You could add a column in your user table inside of your database called `ClerkId`. Use that column to store the userId from Clerk directly into your database. +## Supported Schema Fields + +The migration script validates all user data against a Zod schema defined in `src/migrate/validator.ts`. Below is a complete list of supported fields. + +### Required Fields + +| Field | Type | Description | +| -------- | -------- | ------------------------------------------------------------------ | +| `userId` | `string` | Unique identifier for the user (required for tracking and logging) | + +### Identifier Fields + +At least one verified identifier (email or phone) is required. + +| Field | Type | Description | +| -------------------------- | -------------------- | ----------------------------------- | +| `email` | `string \| string[]` | Primary verified email address(es) | +| `emailAddresses` | `string \| string[]` | Additional verified email addresses | +| `unverifiedEmailAddresses` | `string \| string[]` | Unverified email addresses | +| `phone` | `string \| string[]` | Primary verified phone number(s) | +| `phoneNumbers` | `string \| string[]` | Additional verified phone numbers | +| `unverifiedPhoneNumbers` | `string \| string[]` | Unverified phone numbers | +| `username` | `string` | Username for the user | + +### User Information + +| Field | Type | Description | +| ----------- | -------- | ----------------- | +| `firstName` | `string` | User's first name | +| `lastName` | `string` | User's last name | + +### Password Fields + +| Field | Type | Description | +| ---------------- | -------- | ----------------------------------------------------------- | +| `password` | `string` | Hashed password from source platform | +| `passwordHasher` | `enum` | Hashing algorithm used (required when password is provided) | + +**Supported Password Hashers:** + +- `argon2i`, `argon2id` +- `bcrypt`, `bcrypt_peppered`, `bcrypt_sha256_django` +- `hmac_sha256_utf16_b64` +- `md5`, `md5_salted`, `md5_phpass` +- `pbkdf2_sha1`, `pbkdf2_sha256`, `pbkdf2_sha256_django`, `pbkdf2_sha512` +- `scrypt_firebase`, `scrypt_werkzeug` +- `sha256`, `sha256_salted`, `sha512_symfony` +- `ldap_ssha` + +### Two-Factor Authentication + +| Field | Type | Description | +| -------------------- | ---------- | -------------------------------- | +| `totpSecret` | `string` | TOTP secret for 2FA | +| `backupCodesEnabled` | `boolean` | Whether backup codes are enabled | +| `backupCodes` | `string[]` | Array of backup codes | + +### Metadata + +| Field | Type | Description | +| ----------------- | ----- | ------------------------------------------------------------ | +| `unsafeMetadata` | `any` | Publicly accessible metadata (readable by client and server) | +| `publicMetadata` | `any` | Publicly accessible metadata (readable by client and server) | +| `privateMetadata` | `any` | Server-side only metadata (not accessible to client) | + +### Clerk API Configuration Fields + +| Field | Type | Description | +| --------------------------- | --------- | ----------------------------------------------- | +| `bypassClientTrust` | `boolean` | Skip client trust verification | +| `createOrganizationEnabled` | `boolean` | Whether user can create organizations | +| `createOrganizationsLimit` | `number` | Maximum number of organizations user can create | +| `createdAt` | `string` | Custom creation timestamp | +| `deleteSelfEnabled` | `boolean` | Whether user can delete their own account | +| `legalAcceptedAt` | `string` | Timestamp when legal terms were accepted | +| `skipLegalChecks` | `boolean` | Skip legal acceptance checks | +| `skipPasswordChecks` | `boolean` | Skip password requirements during import | + +## Creating a Custom Transformer + +Transformers map your source platform's user data format to Clerk's expected schema. Each transformer is defined in `src/migrate/transformers/`. + +### Transformer Structure + +A transformer is an object with the following properties: + +```typescript +{ + key: string, // Unique identifier for CLI selection + value: string, // Internal value (usually same as key) + label: string, // Display name shown in CLI + description: string, // Detailed description shown in CLI + transformer: object, // Field mapping configuration + postTransform?: function, // Optional: Custom transformation logic + defaults?: object // Optional: Default values for all users +} +``` + +### Example: Basic Transformer + +Here's a simple transformer for a fictional platform: + +```typescript +// src/migrate/transformers/myplatform.ts +const myPlatformTransformer = { + key: 'myplatform', + value: 'myplatform', + label: 'My Platform', + description: + 'Use this transformer when migrating from My Platform. It handles standard user fields and bcrypt passwords.', + transformer: { + // Source field → Target Clerk field + user_id: 'userId', + email_address: 'email', + first: 'firstName', + last: 'lastName', + phone_number: 'phone', + hashed_password: 'password', + }, + defaults: { + passwordHasher: 'bcrypt', + }, +}; + +export default myPlatformTransformer; +``` + +### Example: Advanced Transformer with Nested Fields + +For platforms with nested data structures: + +```typescript +const advancedTransformer = { + key: 'advanced', + value: 'advanced', + label: 'Advanced Platform', + description: + 'Use this for platforms with nested user data structures. Supports dot notation for extracting nested fields.', + transformer: { + // Supports dot notation for nested fields + 'user._id.$oid': 'userId', // Extracts user._id.$oid + 'profile.email': 'email', // Extracts profile.email + 'profile.name.first': 'firstName', + 'profile.name.last': 'lastName', + 'auth.passwordHash': 'password', + 'metadata.public': 'publicMetadata', + }, + defaults: { + passwordHasher: 'bcrypt', + }, +}; + +export default advancedTransformer; +``` + +### Example: Transformer with Post-Transform Logic + +For complex transformations like handling verification status: + +```typescript +const verificationTransformer = { + key: 'verification', + value: 'verification', + label: 'Platform with Verification', + description: + 'Use this for platforms that track email verification status. Automatically routes emails to verified or unverified fields.', + transformer: { + id: 'userId', + email: 'email', + email_verified: 'emailVerified', + password_hash: 'password', + }, + postTransform: (user: Record) => { + // Route email based on verification status + const emailVerified = user.emailVerified as boolean | undefined; + const email = user.email as string | undefined; + + if (email) { + if (emailVerified === true) { + // Keep verified email in email field + user.email = email; + } else { + // Move unverified email to unverifiedEmailAddresses + user.unverifiedEmailAddresses = email; + delete user.email; + } + } + + // Clean up temporary field + delete user.emailVerified; + }, + defaults: { + passwordHasher: 'sha256', + }, +}; + +export default verificationTransformer; +``` + +### Registering Your Transformer + +After creating your transformer file: + +1. Create the transformer file in `src/migrate/transformers/myplatform.ts` +2. Export it in `src/migrate/transformers/index.ts`: + +```typescript +import clerkTransformer from './clerk'; +import auth0Transformer from './auth0'; +import supabaseTransformer from './supabase'; +import authjsTransformer from './authjs'; +import myPlatformTransformer from './myplatform'; // Add your import + +export const transformers = [ + clerkTransformer, + auth0Transformer, + supabaseTransformer, + authjsTransformer, + myPlatformTransformer, // Add to array +]; +``` + +The CLI will automatically detect and display your transformer in the platform selection menu. + +### Transformer Best Practices + +1. **Field Mapping**: Map source fields to valid Clerk schema fields (see Supported Schema Fields above) +2. **Nested Fields**: Use dot notation (e.g., `'user.profile.email'`) for nested source data +3. **Verification Status**: Use `postTransform` to route emails/phones to verified or unverified arrays +4. **Password Hashers**: Always specify the correct `passwordHasher` in defaults if passwords are included +5. **Metadata**: Map platform-specific data to `publicMetadata` or `privateMetadata` +6. **Required Identifier**: Ensure at least one verified email or phone is mapped +7. **Cleanup**: Remove temporary fields in `postTransform` that aren't part of the schema diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..9fb035a --- /dev/null +++ b/bun.lock @@ -0,0 +1,581 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "clerk-user-migration", + "dependencies": { + "@clack/prompts": "^1.0.0-alpha.9", + "@clerk/backend": "^2.29.3", + "@clerk/types": "^4.101.11", + "bun": "^1.3.6", + "csv-parser": "^3.2.0", + "dotenv": "16.6.1", + "mime-types": "^3.0.2", + "p-limit": "^7.2.0", + "picocolors": "^1.1.1", + "zod": "^4.3.5", + }, + "devDependencies": { + "@types/mime-types": "^3.0.1", + "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/parser": "^8.53.1", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", + "prettier": "^3.8.0", + "vitest": "^4.0.17", + }, + }, + }, + "packages": { + "@clack/core": ["@clack/core@1.0.0-alpha.7", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-3vdh6Ar09D14rVxJZIm3VQJkU+ZOKKT5I5cC0cOVazy70CNyYYjiwRj9unwalhESndgxx6bGc/m6Hhs4EKF5XQ=="], + + "@clack/prompts": ["@clack/prompts@1.0.0-alpha.9", "", { "dependencies": { "@clack/core": "1.0.0-alpha.7", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-sKs0UjiHFWvry4SiRfBi5Qnj0C/6AYx8aKkFPZQSuUZXgAram25ZDmhQmP7vj1aFyLpfHWtLQjWvOvcat0TOLg=="], + + "@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/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=="], + + "@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=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="], + + "@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@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=="], + + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-nqtr+pTsHqusYpG2OZc6s+AmpWDB/FmBvstrK0y5zkti4OqnCuu7Ev2xNjS7uyb47NrAFF40pWqkpaio5XEd7w=="], + + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-YaQEAYjBanoOOtpqk/c5GGcfZIyxIIkQ2m1TbHjedRmJNwxzWBhGinSARFkrRIc3F8pRIGAopXKvJ/2rjN1LzQ=="], + + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-FR+iJt17rfFgYgpxL3M67AUwujOgjw52ZJzB9vElI5jQXNjTyOKf8eH4meSk4vjlYF3h/AjKYd6pmN0OIUlVKQ=="], + + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-egfngj0dfJ868cf30E7B+ye9KUWSebYxOG4l9YP5eWeMXCtenpenx0zdKtAn9qxJgEJym5AN6trtlk+J6x8Lig=="], + + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-jRmnX18ak8WzqLrex3siw0PoVKyIeI5AiCv4wJLgSs7VKfOqrPycfHIWfIX2jdn7ngqbHFPzI09VBKANZ4Pckg=="], + + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YeXcJ9K6vJAt1zSkeA21J6pTe7PgDMLTHKGI3nQBiMYnYf7Ob3K+b/ChSCznrJG7No5PCPiQPg4zTgA+BOTmSA=="], + + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.6", "", { "os": "linux", "cpu": "x64" }, "sha512-7FjVnxnRTp/AgWqSQRT/Vt9TYmvnZ+4M+d9QOKh/Lf++wIFXFGSeAgD6bV1X/yr2UPVmZDk+xdhr2XkU7l2v3w=="], + + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-Sr1KwUcbB0SEpnSPO22tNJppku2khjFluEst+mTGhxHzAGQTQncNeJxDnt3F15n+p9Q+mlcorxehd68n1siikQ=="], + + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.6", "", { "os": "win32", "cpu": "x64" }, "sha512-PFUa7JL4lGoyyppeS4zqfuoXXih+gSE0XxhDMrCPVEUev0yhGNd/tbWBvcdpYnUth80owENoGjc8s5Knopv9wA=="], + + "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="], + + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], + + "@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=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.53.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.53.1", "@typescript-eslint/types": "^8.53.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.53.1", "", { "dependencies": { "@typescript-eslint/types": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1" } }, "sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.53.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.53.1", "", { "dependencies": { "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/utils": "8.53.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.53.1", "", {}, "sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.53.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.53.1", "@typescript-eslint/tsconfig-utils": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.53.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.53.1", "", { "dependencies": { "@typescript-eslint/types": "8.53.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg=="], + + "@vitest/expect": ["@vitest/expect@4.0.17", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.17", "@vitest/utils": "4.0.17", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.0.17", "", { "dependencies": { "@vitest/spy": "4.0.17", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.17", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw=="], + + "@vitest/runner": ["@vitest/runner@4.0.17", "", { "dependencies": { "@vitest/utils": "4.0.17", "pathe": "^2.0.3" } }, "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.17", "", { "dependencies": { "@vitest/pretty-format": "4.0.17", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ=="], + + "@vitest/spy": ["@vitest/spy@4.0.17", "", {}, "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew=="], + + "@vitest/utils": ["@vitest/utils@4.0.17", "", { "dependencies": { "@vitest/pretty-format": "4.0.17", "tinyrainbow": "^3.0.3" } }, "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "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=="], + + "bun": ["bun@1.3.6", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.6", "@oven/bun-darwin-x64": "1.3.6", "@oven/bun-darwin-x64-baseline": "1.3.6", "@oven/bun-linux-aarch64": "1.3.6", "@oven/bun-linux-aarch64-musl": "1.3.6", "@oven/bun-linux-x64": "1.3.6", "@oven/bun-linux-x64-baseline": "1.3.6", "@oven/bun-linux-x64-musl": "1.3.6", "@oven/bun-linux-x64-musl-baseline": "1.3.6", "@oven/bun-windows-x64": "1.3.6", "@oven/bun-windows-x64-baseline": "1.3.6" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-Tn98GlZVN2WM7+lg/uGn5DzUao37Yc0PUz7yzYHdeF5hd+SmHQGbCUIKE4Sspdgtxn49LunK3mDNBC2Qn6GJjw=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "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=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + + "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=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "csv-parser": ["csv-parser@3.2.0", "", { "bin": { "csv-parser": "bin/csv-parser" } }, "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="], + + "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + + "eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], + + "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "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=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@7.2.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + + "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=="], + + "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=="], + + "prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "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=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "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=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "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=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "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=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + + "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "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=="], + + "synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vitest": ["vitest@4.0.17", "", { "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17", "@vitest/pretty-format": "4.0.17", "@vitest/runner": "4.0.17", "@vitest/snapshot": "4.0.17", "@vitest/spy": "4.0.17", "@vitest/utils": "4.0.17", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.17", "@vitest/browser-preview": "4.0.17", "@vitest/browser-webdriverio": "4.0.17", "@vitest/ui": "4.0.17", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "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=="], + + "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=="], + + "@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=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "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=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..f310fc8 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,108 @@ +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; +import prettier from 'eslint-config-prettier'; + +export default [ + // Base config for all files + { + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { + ...globals.browser, + ...globals.es2021, + ...globals.node, + }, + }, + linterOptions: { + reportUnusedDisableDirectives: true, + }, + }, + + // TypeScript files configuration + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parser: tsParser, + parserOptions: { + project: './tsconfig.json', + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + }, + rules: { + // ESLint recommended rules + ...tsPlugin.configs['recommended'].rules, + ...tsPlugin.configs['recommended-requiring-type-checking'].rules, + + // TypeScript-specific rules + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/consistent-type-imports': [ + 'warn', + { + prefer: 'type-imports', + fixStyle: 'separate-type-imports', + }, + ], + '@typescript-eslint/no-unnecessary-condition': 'warn', + '@typescript-eslint/no-non-null-assertion': 'warn', + + // General best practices + 'no-console': 'warn', + 'no-debugger': 'error', + 'prefer-const': 'error', + 'no-var': 'error', + eqeqeq: ['error', 'always', { null: 'ignore' }], + 'no-throw-literal': 'error', + 'prefer-template': 'warn', + 'object-shorthand': ['warn', 'always'], + 'no-nested-ternary': 'warn', + + // Code quality + complexity: ['warn', 15], + 'max-depth': ['warn', 4], + 'no-else-return': 'warn', + 'prefer-arrow-callback': 'warn', + 'no-lonely-if': 'warn', + + // Import organization + 'sort-imports': [ + 'warn', + { + ignoreCase: true, + ignoreDeclarationSort: true, + }, + ], + }, + }, + + // Test files configuration - disable unsafe-* rules for mock/test code + { + files: ['**/*.test.ts', '**/*.test.tsx'], + rules: { + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + + // Prettier config (must be last to override other configs) + prettier, +]; diff --git a/index.ts b/index.ts deleted file mode 100755 index 9409d20..0000000 --- a/index.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { config } from "dotenv"; -config(); - -import * as fs from "fs"; -import * as z from "zod"; -import clerkClient from "@clerk/clerk-sdk-node"; -import ora, { Ora } from "ora"; - -const SECRET_KEY = process.env.CLERK_SECRET_KEY; -const DELAY = parseInt(process.env.DELAY_MS ?? `1_000`); -const RETRY_DELAY = parseInt(process.env.RETRY_DELAY_MS ?? `10_000`); -const IMPORT_TO_DEV = process.env.IMPORT_TO_DEV_INSTANCE ?? "false"; -const OFFSET = parseInt(process.env.OFFSET ?? `0`); - -if (!SECRET_KEY) { - throw new Error( - "CLERK_SECRET_KEY is required. Please copy .env.example to .env and add your key." - ); -} - -if (SECRET_KEY.split("_")[1] !== "live" && IMPORT_TO_DEV === "false") { - throw new Error( - "The Clerk Secret Key provided is for a development instance. Development instances are limited to 500 users and do not share their userbase with production instances. If you want to import users to your development instance, please set 'IMPORT_TO_DEV_INSTANCE' in your .env to 'true'." - ); -} - -const userSchema = z.object({ - /** The ID of the user as used in your external systems or your previous authentication solution. Must be unique across your instance. */ - userId: z.string(), - /** Email address to set as User's primary email address. */ - email: z.string().email(), - /** The first name to assign to the user */ - firstName: z.string().optional(), - /** The last name to assign to the user */ - lastName: z.string().optional(), - /** The plaintext password to give the user. Must be at least 8 characters long, and can not be in any list of hacked passwords. */ - password: z.string().optional(), - /** The hashing algorithm that was used to generate the password digest. - * @see https://clerk.com/docs/reference/backend-api/tag/Users#operation/CreateUser!path=password_hasher&t=request - */ - passwordHasher: z - .enum([ - "argon2i", - "argon2id", - "bcrypt", - "bcrypt_sha256_django", - "ldap_ssha", - "md5", - "md5_phpass", - "pbkdf2_sha256", - "pbkdf2_sha256_django", - "pbkdf2_sha1", - "phpass", - "scrypt_firebase", - "scrypt_werkzeug", - "sha256", - ]) - .optional(), - /** Metadata saved on the user, that is visible to both your Frontend and Backend APIs */ - public_metadata: z.record(z.string(), z.unknown()).optional(), - /** Metadata saved on the user, that is only visible to your Backend APIs */ - private_metadata: z.record(z.string(), z.unknown()).optional(), - /** Metadata saved on the user, that can be updated from both the Frontend and Backend APIs. Note: Since this data can be modified from the frontend, it is not guaranteed to be safe. */ - unsafe_metadata: z.record(z.string(), z.unknown()).optional(), - createdAt: z.string().optional(), -}); - -type User = z.infer; - -const createUser = (userData: User) => - userData.password - ? clerkClient.users.createUser({ - externalId: userData.userId, - emailAddress: [userData.email], - firstName: userData.firstName, - lastName: userData.lastName, - passwordDigest: userData.password, - passwordHasher: userData.passwordHasher, - privateMetadata: userData.private_metadata, - publicMetadata: userData.public_metadata, - unsafeMetadata: userData.unsafe_metadata, - createdAt: userData.createdAt, - }) - : clerkClient.users.createUser({ - externalId: userData.userId, - emailAddress: [userData.email], - firstName: userData.firstName, - lastName: userData.lastName, - skipPasswordRequirement: true, - privateMetadata: userData.private_metadata, - publicMetadata: userData.public_metadata, - unsafeMetadata: userData.unsafe_metadata, - createdAt: userData.createdAt, - }); - -const now = new Date().toISOString().split(".")[0]; // YYYY-MM-DDTHH:mm:ss -function appendLog(payload: any) { - fs.appendFileSync( - `./migration-log-${now}.json`, - `\n${JSON.stringify(payload, null, 2)}` - ); -} - -let migrated = 0; -let alreadyExists = 0; - -async function processUserToClerk(userData: User, spinner: Ora) { - const txt = spinner.text; - try { - const parsedUserData = userSchema.safeParse(userData); - if (!parsedUserData.success) { - throw parsedUserData.error; - } - await createUser(parsedUserData.data); - - migrated++; - } catch (error) { - if (error.status === 422) { - appendLog({ userId: userData.userId, ...error }); - alreadyExists++; - return; - } - - // Keep cooldown in case rate limit is reached as a fallback if the thread blocking fails - if (error.status === 429) { - spinner.text = `${txt} - rate limit reached, waiting for ${RETRY_DELAY} ms`; - await rateLimitCooldown(); - spinner.text = txt; - return processUserToClerk(userData, spinner); - } - - appendLog({ userId: userData.userId, ...error }); - } -} - -async function cooldown() { - await new Promise((r) => setTimeout(r, DELAY)); -} - -async function rateLimitCooldown() { - await new Promise((r) => setTimeout(r, RETRY_DELAY)); -} - -async function main() { - console.log(`Clerk User Migration Utility`); - - const inputFileName = process.argv[2] ?? "users.json"; - - console.log(`Fetching users from ${inputFileName}`); - - const parsedUserData: any[] = JSON.parse( - fs.readFileSync(inputFileName, "utf-8") - ); - const offsetUsers = parsedUserData.slice(OFFSET); - console.log( - `users.json found and parsed, attempting migration with an offset of ${OFFSET}` - ); - - let i = 0; - const spinner = ora(`Migrating users`).start(); - - for (const userData of offsetUsers) { - spinner.text = `Migrating user ${i}/${offsetUsers.length}, cooldown`; - await cooldown(); - i++; - spinner.text = `Migrating user ${i}/${offsetUsers.length}`; - await processUserToClerk(userData, spinner); - } - - spinner.succeed(`Migration complete`); - return; -} - -main().then(() => { - console.log(`${migrated} users migrated`); - console.log(`${alreadyExists} users failed to upload`); -}); diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index e50a911..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1005 +0,0 @@ -{ - "name": "clerk-user-migration", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "clerk-user-migration", - "version": "0.0.1", - "license": "ISC", - "dependencies": { - "@clerk/clerk-sdk-node": "^4.12.21", - "bun": "^1.0.12", - "dotenv": "^16.3.1", - "ora": "^7.0.1", - "zod": "^3.22.4" - }, - "bin": { - "clerk-user-migration": "index.ts" - } - }, - "node_modules/@clerk/backend": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-0.34.1.tgz", - "integrity": "sha512-I6u7vb7XHA0kNek5Ez4VVqBDZKxLepR6wJXlYUy5lGwsTdaQiFwy5Q0nKP2GdQQYtlKpXSAryLu19Cq5zaaNYg==", - "dependencies": { - "@clerk/shared": "1.1.0", - "@clerk/types": "3.58.0", - "@peculiar/webcrypto": "1.4.1", - "@types/node": "16.18.6", - "cookie": "0.5.0", - "deepmerge": "4.2.2", - "node-fetch-native": "1.0.1", - "snakecase-keys": "5.4.4", - "tslib": "2.4.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@clerk/backend/node_modules/snakecase-keys": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/snakecase-keys/-/snakecase-keys-5.4.4.tgz", - "integrity": "sha512-YTywJG93yxwHLgrYLZjlC75moVEX04LZM4FHfihjHe1FCXm+QaLOFfSf535aXOAd0ArVQMWUAe8ZPm4VtWyXaA==", - "dependencies": { - "map-obj": "^4.1.0", - "snake-case": "^3.0.4", - "type-fest": "^2.5.2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@clerk/backend/node_modules/tslib": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", - "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" - }, - "node_modules/@clerk/backend/node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@clerk/clerk-sdk-node": { - "version": "4.12.21", - "resolved": "https://registry.npmjs.org/@clerk/clerk-sdk-node/-/clerk-sdk-node-4.12.21.tgz", - "integrity": "sha512-43MdviLlAG3naNzRyxF/Io8YYQBnFEIQiqYFVHzKzZGEsbPST9lBfeFxJZKrCqSE8K7gMx3+3D87bveXq6a7cA==", - "dependencies": { - "@clerk/backend": "0.34.1", - "@clerk/shared": "1.1.0", - "@clerk/types": "3.58.0", - "@types/cookies": "0.7.7", - "@types/express": "4.17.14", - "@types/node-fetch": "2.6.2", - "camelcase-keys": "6.2.2", - "snakecase-keys": "3.2.1", - "tslib": "2.4.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@clerk/clerk-sdk-node/node_modules/tslib": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", - "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" - }, - "node_modules/@clerk/shared": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-1.1.0.tgz", - "integrity": "sha512-rxQ6bxAERZsf/dzCU35qt3gRp9+a035Vrre8j8tyT60dbP8PQhXUbeNu+oVqqjpHWeyoWWt6fZGLXbDTXdXx7g==", - "dependencies": { - "glob-to-regexp": "0.4.1", - "js-cookie": "3.0.1", - "swr": "2.2.0" - }, - "peerDependencies": { - "react": ">=16" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - } - }, - "node_modules/@clerk/types": { - "version": "3.58.0", - "resolved": "https://registry.npmjs.org/@clerk/types/-/types-3.58.0.tgz", - "integrity": "sha512-fIsvEM3nYQwViOuYxNVcwEl0WkXW6AdYpSghNBKfOge1kriSSHP++T5rRMJBXy6asl2AEydVlUBKx9drAzqKoA==", - "dependencies": { - "csstype": "3.1.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@clerk/types/node_modules/csstype": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", - "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" - }, - "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.0.12.tgz", - "integrity": "sha512-e/iNyt8HXlvDTzyvKUyq+vIUVyID9WykyDvNEcz5jM9bcdwimiAo+VGvRhAWnRkazhDBY5H3DL+ixEGy0ljIGw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.0.12.tgz", - "integrity": "sha512-CWfuYPJ1oObCKskOZeg7aM6ToJgt1LEpIIyaqRiYiVji3lrEcnNVPFUJqj7JlQrchZrcrqRr0duKypVCQ+8Jig==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.0.12.tgz", - "integrity": "sha512-E/0pWuimJlrSzbk6TLgHHvJ0YkRv6oUT1grvgbJz1zyY5/86tAzbc8N6i37kot3jvJ/qF4pF98DkAK+V5TKOMg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-linux-aarch64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.0.12.tgz", - "integrity": "sha512-0az/FbWNerffUw4ik2VYq/L1m+YncV1uRj59YJMVgB7Eyo1ykgGAmKM/7bUFNrwO1c8Ydz0vj2oOXeYJzWc1Tg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.0.12.tgz", - "integrity": "sha512-A5PP4JpKVwqtj31ZPOHJlerFyw8zOJKRk6ssk1m0jRaFm0/4tEcpqQzX/pPmZcoFhWKcKDnwSJDUIT5vR0q24w==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.0.12.tgz", - "integrity": "sha512-/sSpuNXbCnNoZ3HHL2veGZWmBqIEeM4skaAMp4rSD+Yf5NbHZXeB4qhj7bp7DTMyRESkScMir1DpJifqNhNd/Q==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz", - "integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==", - "dependencies": { - "asn1js": "^3.0.5", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2" - } - }, - "node_modules/@peculiar/json-schema": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", - "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@peculiar/webcrypto": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.4.1.tgz", - "integrity": "sha512-eK4C6WTNYxoI7JOabMoZICiyqRRtJB220bh0Mbj5RwRycleZf9BPyZoxsTvpP0FpmVS2aS13NKOuh5/tN3sIRw==", - "dependencies": { - "@peculiar/asn1-schema": "^2.3.0", - "@peculiar/json-schema": "^1.1.12", - "pvtsutils": "^1.3.2", - "tslib": "^2.4.1", - "webcrypto-core": "^1.7.4" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cookies": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.7.7.tgz", - "integrity": "sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==", - "dependencies": { - "@types/connect": "*", - "@types/express": "*", - "@types/keygrip": "*", - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz", - "integrity": "sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.18", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.17.41", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz", - "integrity": "sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" - }, - "node_modules/@types/keygrip": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.5.tgz", - "integrity": "sha512-M+BUYYOXgiYoab5L98VpOY1PzmDwWcTkqqu4mdluez5qOTDV0MVPChxhRIPeIFxQgSi3+6qjg1PnGFaGlW373g==" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" - }, - "node_modules/@types/node": { - "version": "16.18.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.6.tgz", - "integrity": "sha512-vmYJF0REqDyyU0gviezF/KHq/fYaUbFhkcNbQCuPGFQj6VTbXuHZoxs/Y7mutWe73C8AC6l9fFu8mSYiBAqkGA==" - }, - "node_modules/@types/node-fetch": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz", - "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==", - "dependencies": { - "@types/node": "*", - "form-data": "^3.0.0" - } - }, - "node_modules/@types/qs": { - "version": "6.9.10", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.10.tgz", - "integrity": "sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" - }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", - "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", - "dependencies": { - "@types/http-errors": "*", - "@types/mime": "*", - "@types/node": "*" - } - }, - "node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/asn1js": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", - "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", - "dependencies": { - "pvtsutils": "^1.3.2", - "pvutils": "^1.1.3", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/bl": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", - "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", - "dependencies": { - "buffer": "^6.0.3", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/bun": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/bun/-/bun-1.0.12.tgz", - "integrity": "sha512-I0CAJJ0HQcu+hdid1jPpRuG1qAyiToZD2eJ0jOX9FLPvhyQQcul6DjRAlW+N1gk9brovK82sba4GvEQxVdCyUA==", - "cpu": [ - "arm64", - "x64" - ], - "hasInstallScript": true, - "os": [ - "darwin", - "linux" - ], - "bin": { - "bun": "bin/bun", - "bunx": "bin/bun" - }, - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "1.0.12", - "@oven/bun-darwin-x64": "1.0.12", - "@oven/bun-darwin-x64-baseline": "1.0.12", - "@oven/bun-linux-aarch64": "1.0.12", - "@oven/bun-linux-x64": "1.0.12", - "@oven/bun-linux-x64-baseline": "1.0.12" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-keys": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", - "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", - "dependencies": { - "camelcase": "^5.3.1", - "map-obj": "^4.0.0", - "quick-lru": "^4.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "dependencies": { - "restore-cursor": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.1.tgz", - "integrity": "sha512-jHgecW0pxkonBJdrKsqxgRX9AcG+u/5k0Q7WPDfi8AogLAdwxEkyYYNWwZ5GvVFoFx2uiY1eNcSK00fh+1+FyQ==", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/dotenv": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/motdotla/dotenv?sponsor=1" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" - }, - "node_modules/emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==" - }, - "node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/js-cookie": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.1.tgz", - "integrity": "sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==", - "engines": { - "node": ">=12" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "license": "MIT", - "peer": true - }, - "node_modules/log-symbols": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-5.1.0.tgz", - "integrity": "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==", - "dependencies": { - "chalk": "^5.0.0", - "is-unicode-supported": "^1.1.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "license": "MIT", - "peer": true, - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/map-obj": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", - "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/node-fetch-native": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.0.1.tgz", - "integrity": "sha512-VzW+TAk2wE4X9maiKMlT+GsPU4OMmR1U9CrHSmd3DFLn2IcZ9VJ6M6BBugGfYUnPCLSYxXdZy17M0BEJyhUTwg==" - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-7.0.1.tgz", - "integrity": "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw==", - "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^4.0.0", - "cli-spinners": "^2.9.0", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^1.3.0", - "log-symbols": "^5.1.0", - "stdin-discarder": "^0.1.0", - "string-width": "^6.1.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pvtsutils": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", - "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", - "dependencies": { - "tslib": "^2.6.1" - } - }, - "node_modules/pvutils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", - "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/quick-lru": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", - "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", - "engines": { - "node": ">=8" - } - }, - "node_modules/react": { - "version": "18.2.0", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, - "node_modules/snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/snakecase-keys": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/snakecase-keys/-/snakecase-keys-3.2.1.tgz", - "integrity": "sha512-CjU5pyRfwOtaOITYv5C8DzpZ8XA/ieRsDpr93HI2r6e3YInC6moZpSQbmUtg8cTk58tq2x3jcG2gv+p1IZGmMA==", - "dependencies": { - "map-obj": "^4.1.0", - "to-snake-case": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/stdin-discarder": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", - "integrity": "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==", - "dependencies": { - "bl": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-6.1.0.tgz", - "integrity": "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ==", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^10.2.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/swr": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.0.tgz", - "integrity": "sha512-AjqHOv2lAhkuUdIiBu9xbuettzAzWXmCEcLONNKJRba87WAefz8Ca9d6ds/SzrPc235n1IxWYdhJ2zF3MNUaoQ==", - "dependencies": { - "use-sync-external-store": "^1.2.0" - }, - "peerDependencies": { - "react": "^16.11.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/to-no-case": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/to-no-case/-/to-no-case-1.0.2.tgz", - "integrity": "sha512-Z3g735FxuZY8rodxV4gH7LxClE4H0hTIyHNIHdk+vpQxjLm0cwnKXq/OFVZ76SOQmto7txVcwSCwkU5kqp+FKg==" - }, - "node_modules/to-snake-case": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-snake-case/-/to-snake-case-1.0.0.tgz", - "integrity": "sha512-joRpzBAk1Bhi2eGEYBjukEWHOe/IvclOkiJl3DtA91jV6NwQ3MwXA4FHYeqk8BNp/D8bmi9tcNbRu/SozP0jbQ==", - "dependencies": { - "to-space-case": "^1.0.0" - } - }, - "node_modules/to-space-case": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-space-case/-/to-space-case-1.0.0.tgz", - "integrity": "sha512-rLdvwXZ39VOn1IxGL3V6ZstoTbwLRckQmn/U8ZDLuWwIXNpuZDhQ3AiRUlhTbOXFVE9C+dR51wM0CBDhk31VcA==", - "dependencies": { - "to-no-case": "^1.0.0" - } - }, - "node_modules/tslib": { - "version": "2.6.2", - "license": "0BSD" - }, - "node_modules/use-sync-external-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/webcrypto-core": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.7.7.tgz", - "integrity": "sha512-7FjigXNsBfopEj+5DV2nhNpfic2vumtjjgPmeDKk45z+MJwXKKfhPB7118Pfzrmh4jqOMST6Ch37iPAHoImg5g==", - "dependencies": { - "@peculiar/asn1-schema": "^2.3.6", - "@peculiar/json-schema": "^1.1.12", - "asn1js": "^3.0.1", - "pvtsutils": "^1.3.2", - "tslib": "^2.4.0" - } - }, - "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/package.json b/package.json index 060996b..75d8a0b 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,43 @@ "keywords": [], "license": "ISC", "scripts": { - "start": "bun index.ts" + "migrate": "bun ./src/migrate/index.ts", + "delete": "bun ./src/delete/index.ts", + "clean-logs": "bun ./src/clean-logs/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier . --write", + "format:test": "prettier . --check", + "test": "vitest", + "prepare": "husky" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx,json,css,scss,md}": [ + "prettier --write" + ] }, "dependencies": { - "@clerk/clerk-sdk-node": "^4.12.21", - "bun": "^1.0.12", - "dotenv": "^16.3.1", - "ora": "^7.0.1", - "zod": "^3.22.4" + "@clack/prompts": "^1.0.0-alpha.9", + "@clerk/backend": "^2.29.3", + "@clerk/types": "^4.101.11", + "bun": "^1.3.6", + "csv-parser": "^3.2.0", + "dotenv": "16.6.1", + "mime-types": "^3.0.2", + "p-limit": "^7.2.0", + "picocolors": "^1.1.1", + "zod": "^4.3.5" + }, + "devDependencies": { + "@types/mime-types": "^3.0.1", + "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/parser": "^8.53.1", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", + "prettier": "^3.8.0", + "vitest": "^4.0.17" } } diff --git a/samples/auth0.json b/samples/auth0.json new file mode 100644 index 0000000..1f0b6f7 --- /dev/null +++ b/samples/auth0.json @@ -0,0 +1,566 @@ +[ + { + "_id": { + "$oid": "657376510000000000000001" + }, + "email": "jane.doe@test.com", + "given_name": "Jane", + "family_name": "Doe", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "passwordHash": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "janedoe@test.com", + "verified": true + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000002" + }, + "email": "johndoe@test.com", + "given_name": "John", + "family_name": "Doe", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "johndoe@test.com", + "verified": true + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000003" + }, + "email": "johnhancock@test.com", + "given_name": "John", + "family_name": "Hancock", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "passwordHash": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "johnhancock@test.com", + "verified": true + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000004" + }, + "email": "janehancock@test.com", + "given_name": "Jane", + "family_name": "Hancock", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "janehancock@test.com", + "verified": true + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000005" + }, + "email": "alicesmith@test.com", + "given_name": "Alice", + "family_name": "Smith", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "passwordHash": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "alicesmith@test.com", + "verified": true + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000006" + }, + "email": "bobjohnson@test.com", + "given_name": "Bob", + "family_name": "Johnson", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "bobjohnson@test.com", + "verified": true + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000007" + }, + "email": "carolwilliams@test.com", + "given_name": "Carol", + "family_name": "Williams", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "carolwilliams@test.com", + "verified": true + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000008" + }, + "email": "davidbrown@test.com", + "given_name": "David", + "family_name": "Brown", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "passwordHash": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "davidbrown@test.com", + "verified": true + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000009" + }, + "email": "emmajones@test.com", + "given_name": "Emma", + "family_name": "Jones", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "emmajones@test.com", + "verified": true + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000010" + }, + "email": "frankgarcia@test.com", + "given_name": "Frank", + "family_name": "Garcia", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "frankgarcia@test.com", + "verified": true + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000011" + }, + "email": "sconnor@test.com", + "username": "sconnor", + "given_name": "Sarah", + "family_name": "Connor", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "passwordHash": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "sconnor@test.com", + "verified": true + }, + { + "type": "username", + "value": "sconnor" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000012" + }, + "email": "mscott@test.com", + "username": "mscott", + "given_name": "Michael", + "family_name": "Scott", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "passwordHash": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "mscott@test.com", + "verified": true + }, + { + "type": "username", + "value": "mscott" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000013" + }, + "email": "lknope@test.com", + "username": "lknope", + "given_name": "Leslie", + "family_name": "Knope", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "passwordHash": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "lknope@test.com", + "verified": true + }, + { + "type": "username", + "value": "lknope" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000014" + }, + "email": "rswanson@test.com", + "username": "rswanson", + "given_name": "Ron", + "family_name": "Swanson", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "passwordHash": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "rswanson@test.com", + "verified": true + }, + { + "type": "username", + "value": "rswanson" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000015" + }, + "email": "aludgate@test.com", + "username": "aludgate", + "given_name": "April", + "family_name": "Ludgate", + "email_verified": true, + "tenant": "dev-test", + "connection": "Username-Password-Authentication", + "passwordHash": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "email", + "value": "aludgate@test.com", + "verified": true + }, + { + "type": "username", + "value": "aludgate" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000016" + }, + "username": "phoneuser1", + "phone_number": "+12125550100", + "phone_verified": true, + "tenant": "dev-test", + "connection": "sms", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "phone_number", + "value": "+12125550100", + "verified": true + }, + { + "type": "username", + "value": "phoneuser1" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000017" + }, + "username": "phoneuser2", + "phone_number": "+12125550101", + "phone_verified": true, + "tenant": "dev-test", + "connection": "sms", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "phone_number", + "value": "+12125550101", + "verified": true + }, + { + "type": "username", + "value": "phoneuser2" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000018" + }, + "username": "phoneuser3", + "phone_number": "+12125550102", + "phone_verified": true, + "tenant": "dev-test", + "connection": "sms", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "phone_number", + "value": "+12125550102", + "verified": true + }, + { + "type": "username", + "value": "phoneuser3" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000019" + }, + "username": "phoneuser4", + "phone_number": "+12125550103", + "phone_verified": true, + "tenant": "dev-test", + "connection": "sms", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "phone_number", + "value": "+12125550103", + "verified": true + }, + { + "type": "username", + "value": "phoneuser4" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000020" + }, + "username": "phoneuser5", + "phone_number": "+12125550104", + "phone_verified": true, + "tenant": "dev-test", + "connection": "sms", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "phone_number", + "value": "+12125550104", + "verified": true + }, + { + "type": "username", + "value": "phoneuser5" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000021" + }, + "username": "phoneuser6", + "phone_number": "+12125550105", + "phone_verified": true, + "tenant": "dev-test", + "connection": "sms", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "phone_number", + "value": "+12125550105", + "verified": true + }, + { + "type": "username", + "value": "phoneuser6" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000022" + }, + "username": "phoneuser7", + "phone_number": "+12125550106", + "phone_verified": true, + "tenant": "dev-test", + "connection": "sms", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "phone_number", + "value": "+12125550106", + "verified": true + }, + { + "type": "username", + "value": "phoneuser7" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000023" + }, + "username": "phoneuser8", + "phone_number": "+12125550107", + "phone_verified": true, + "tenant": "dev-test", + "connection": "sms", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "phone_number", + "value": "+12125550107", + "verified": true + }, + { + "type": "username", + "value": "phoneuser8" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000024" + }, + "username": "phoneuser9", + "phone_number": "+12125550108", + "phone_verified": true, + "tenant": "dev-test", + "connection": "sms", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "phone_number", + "value": "+12125550108", + "verified": true + }, + { + "type": "username", + "value": "phoneuser9" + } + ] + }, + { + "_id": { + "$oid": "657376510000000000000025" + }, + "username": "phoneuser10", + "phone_number": "+12125550109", + "phone_verified": true, + "tenant": "dev-test", + "connection": "sms", + "_tmp_is_unique": true, + "version": "1.1", + "identifiers": [ + { + "type": "phone_number", + "value": "+12125550109", + "verified": true + }, + { + "type": "username", + "value": "phoneuser10" + } + ] + } +] diff --git a/samples/authjs.json b/samples/authjs.json index ce784d7..932bc0e 100644 --- a/samples/authjs.json +++ b/samples/authjs.json @@ -1,811 +1,162 @@ [ { - "userId": "1", - "email": "john@example.com", - "firstName": "John", - "lastName": "Doe", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" + "id": "cljn3x7y00000356z8f4g2h1i", + "name": "Jane Doe", + "email": "jane.doe@test.com", + "email_verified": "2024-01-15T10:30:00.000Z", + "created_at": "2024-01-15T10:30:00.000Z", + "updated_at": "2024-01-15T10:30:00.000Z" }, - { - "userId": "2", - "email": "alice@example.com", - "firstName": "Alice", - "lastName": "Smith", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "3", - "email": "bob@example.com", - "firstName": "Bob", - "lastName": "Johnson", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "4", - "email": "emma@example.com", - "firstName": "Emma", - "lastName": "Davis", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "5", - "email": "charlie@example.com", - "firstName": "Charlie", - "lastName": "Brown", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "6", - "email": "sophie@example.com", - "firstName": "Sophie", - "lastName": "Wilson", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "7", - "email": "jack@example.com", - "firstName": "Jack", - "lastName": "Thompson", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "8", - "email": "olivia@example.com", - "firstName": "Olivia", - "lastName": "Miller", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "9", - "email": "daniel@example.com", - "firstName": "Daniel", - "lastName": "Taylor", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "10", - "email": "mia@example.com", - "firstName": "Mia", - "lastName": "Anderson", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "11", - "email": "user11@example.com", - "firstName": "User", - "lastName": "Eleven", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "12", - "email": "user12@example.com", - "firstName": "User", - "lastName": "Twelve", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "13", - "email": "user13@example.com", - "firstName": "User", - "lastName": "Thirteen", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "14", - "email": "user14@example.com", - "firstName": "User", - "lastName": "Fourteen", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "15", - "email": "user15@example.com", - "firstName": "User", - "lastName": "Fifteen", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "16", - "email": "user16@example.com", - "firstName": "User", - "lastName": "Sixteen", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "17", - "email": "user17@example.com", - "firstName": "User", - "lastName": "Seventeen", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "18", - "email": "user18@example.com", - "firstName": "User", - "lastName": "Eighteen", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "19", - "email": "user19@example.com", - "firstName": "User", - "lastName": "Nineteen", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "20", - "email": "user20@example.com", - "firstName": "User", - "lastName": "Twenty", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "21", - "email": "user21@example.com", - "firstName": "User", - "lastName": "TwentyOne", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "22", - "email": "user22@example.com", - "firstName": "User", - "lastName": "TwentyTwo", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "23", - "email": "user23@example.com", - "firstName": "User", - "lastName": "TwentyThree", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "24", - "email": "user24@example.com", - "firstName": "User", - "lastName": "TwentyFour", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "25", - "email": "user25@example.com", - "firstName": "User", - "lastName": "TwentyFive", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "26", - "email": "user26@example.com", - "firstName": "User", - "lastName": "TwentySix", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "27", - "email": "user27@example.com", - "firstName": "User", - "lastName": "TwentySeven", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "28", - "email": "user28@example.com", - "firstName": "User", - "lastName": "TwentyEight", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "29", - "email": "user29@example.com", - "firstName": "User", - "lastName": "TwentyNine", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "30", - "email": "user30@example.com", - "firstName": "User", - "lastName": "Thirty", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "31", - "email": "user31@example.com", - "firstName": "User31", - "lastName": "Lastname31", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "32", - "email": "user32@example.com", - "firstName": "User32", - "lastName": "Lastname32", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "33", - "email": "user33@example.com", - "firstName": "User33", - "lastName": "Lastname33", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "34", - "email": "user34@example.com", - "firstName": "User34", - "lastName": "Lastname34", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "35", - "email": "user35@example.com", - "firstName": "User35", - "lastName": "Lastname35", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "36", - "email": "user36@example.com", - "firstName": "User36", - "lastName": "Lastname36", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "37", - "email": "user37@example.com", - "firstName": "User37", - "lastName": "Lastname37", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "38", - "email": "user38@example.com", - "firstName": "User38", - "lastName": "Lastname38", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "39", - "email": "user39@example.com", - "firstName": "User39", - "lastName": "Lastname39", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "40", - "email": "user40@example.com", - "firstName": "User40", - "lastName": "Lastname40", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "41", - "email": "user41@example.com", - "firstName": "User41", - "lastName": "Doe41", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "42", - "email": "user42@example.com", - "firstName": "User42", - "lastName": "Doe42", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "43", - "email": "user43@example.com", - "firstName": "User43", - "lastName": "Doe43", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "44", - "email": "user44@example.com", - "firstName": "User44", - "lastName": "Doe44", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "45", - "email": "user45@example.com", - "firstName": "User45", - "lastName": "Doe45", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "46", - "email": "user46@example.com", - "firstName": "User46", - "lastName": "Doe46", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "47", - "email": "user47@example.com", - "firstName": "User47", - "lastName": "Doe47", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "48", - "email": "user48@example.com", - "firstName": "User48", - "lastName": "Doe48", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" + { + "id": "cljn3x7y00001356z8f4g2h1j", + "name": "John Doe", + "email": "johndoe@test.com", + "email_verified": "2024-01-15T10:31:00.000Z", + "created_at": "2024-01-15T10:31:00.000Z", + "updated_at": "2024-01-15T10:31:00.000Z" }, - { - "userId": "49", - "email": "user49@example.com", - "firstName": "User49", - "lastName": "Doe49", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "50", - "email": "user50@example.com", - "firstName": "User50", - "lastName": "Doe50", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "51", - "email": "john@example.com", - "firstName": "John", - "lastName": "Doe", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "52", - "email": "alice@example.com", - "firstName": "Alice", - "lastName": "Smith", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "53", - "email": "bob@example.com", - "firstName": "Bob", - "lastName": "Johnson", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "54", - "email": "carol@example.com", - "firstName": "Carol", - "lastName": "Williams", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "55", - "email": "david@example.com", - "firstName": "David", - "lastName": "Brown", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "56", - "email": "emma@example.com", - "firstName": "Emma", - "lastName": "Miller", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" + { + "id": "cljn3x7y00002356z8f4g2h1k", + "name": "John Hancock", + "email": "johnhancock@test.com", + "email_verified": "2024-01-15T10:32:00.000Z", + "created_at": "2024-01-15T10:32:00.000Z", + "updated_at": "2024-01-15T10:32:00.000Z" }, - { - "userId": "57", - "email": "frank@example.com", - "firstName": "Frank", - "lastName": "Taylor", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" + { + "id": "cljn3x7y00003356z8f4g2h1l", + "name": "Jane Hancock", + "email": "janehancock@test.com", + "email_verified": null, + "created_at": "2024-01-15T10:33:00.000Z", + "updated_at": "2024-01-15T10:33:00.000Z" }, - { - "userId": "58", - "email": "grace@example.com", - "firstName": "Grace", - "lastName": "Anderson", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" + { + "id": "cljn3x7y00004356z8f4g2h1m", + "name": "Alice Smith", + "email": "alicesmith@test.com", + "email_verified": "2024-01-16T09:15:00.000Z", + "created_at": "2024-01-16T09:15:00.000Z", + "updated_at": "2024-01-16T09:15:00.000Z" }, - { - "userId": "59", - "email": "henry@example.com", - "firstName": "Henry", - "lastName": "Moore", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" + { + "id": "cljn3x7y00005356z8f4g2h1n", + "name": "Bob Johnson", + "email": "bobjohnson@test.com", + "email_verified": "2024-01-16T09:20:00.000Z", + "created_at": "2024-01-16T09:20:00.000Z", + "updated_at": "2024-01-16T09:20:00.000Z" }, - { - "userId": "60", - "email": "isabel@example.com", - "firstName": "Isabel", - "lastName": "Clark", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" + { + "id": "cljn3x7y00006356z8f4g2h1o", + "name": "Carol Williams", + "email": "carolwilliams@test.com", + "email_verified": "2024-01-16T14:45:00.000Z", + "created_at": "2024-01-16T14:45:00.000Z", + "updated_at": "2024-01-16T14:45:00.000Z" + }, + { + "id": "cljn3x7y00007356z8f4g2h1p", + "name": "David Brown", + "email": "davidbrown@test.com", + "email_verified": "2024-01-17T08:30:00.000Z", + "created_at": "2024-01-17T08:30:00.000Z", + "updated_at": "2024-01-17T08:30:00.000Z" + }, + { + "id": "cljn3x7y00008356z8f4g2h1q", + "name": "Emma Jones", + "email": "emmajones@test.com", + "email_verified": "2024-01-17T11:00:00.000Z", + "created_at": "2024-01-17T11:00:00.000Z", + "updated_at": "2024-01-17T11:00:00.000Z" + }, + { + "id": "cljn3x7y00009356z8f4g2h1r", + "name": "Frank Garcia", + "email": "frankgarcia@test.com", + "email_verified": "2024-01-17T13:30:00.000Z", + "created_at": "2024-01-17T13:30:00.000Z", + "updated_at": "2024-01-17T13:30:00.000Z" + }, + { + "id": "cljn3x7y00010356z8f4g2h1s", + "name": "Sarah Connor", + "email": "sconnor@test.com", + "email_verified": "2024-01-18T10:00:00.000Z", + "created_at": "2024-01-18T10:00:00.000Z", + "updated_at": "2024-01-18T10:00:00.000Z" + }, + { + "id": "cljn3x7y00011356z8f4g2h1t", + "name": "Michael Scott", + "email": "mscott@test.com", + "email_verified": "2024-01-18T11:15:00.000Z", + "created_at": "2024-01-18T11:15:00.000Z", + "updated_at": "2024-01-18T11:15:00.000Z" + }, + { + "id": "cljn3x7y00012356z8f4g2h1u", + "name": "Leslie Knope", + "email": "lknope@test.com", + "email_verified": "2024-01-18T14:30:00.000Z", + "created_at": "2024-01-18T14:30:00.000Z", + "updated_at": "2024-01-18T14:30:00.000Z" }, { - "userId": "61", - "email": "alice@example.com", - "firstName": "Alice", - "lastName": "Smith", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, + "id": "cljn3x7y00013356z8f4g2h1v", + "name": "Ron Swanson", + "email": "rswanson@test.com", + "email_verified": "2024-01-19T09:00:00.000Z", + "created_at": "2024-01-19T09:00:00.000Z", + "updated_at": "2024-01-19T09:00:00.000Z" + }, { - "userId": "62", - "email": "bob@example.com", - "firstName": "Bob", - "lastName": "Johnson", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, + "id": "cljn3x7y00014356z8f4g2h1w", + "name": "April Ludgate", + "email": "aludgate@test.com", + "email_verified": "2024-01-19T10:30:00.000Z", + "created_at": "2024-01-19T10:30:00.000Z", + "updated_at": "2024-01-19T10:30:00.000Z" + }, { - "userId": "63", - "email": "carol@example.com", - "firstName": "Carol", - "lastName": "Williams", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, + "id": "cljn3x7y00015356z8f4g2h1x", + "name": null, + "email": "noprofile@test.com", + "email_verified": "2024-01-20T08:00:00.000Z", + "created_at": "2024-01-20T08:00:00.000Z", + "updated_at": "2024-01-20T08:00:00.000Z" + }, { - "userId": "64", - "email": "david@example.com", - "firstName": "David", - "lastName": "Brown", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, + "id": "cljn3x7y00016356z8f4g2h1y", + "name": "Grace Hopper", + "email": "ghopper@test.com", + "email_verified": null, + "created_at": "2024-01-20T12:00:00.000Z", + "updated_at": "2024-01-20T12:00:00.000Z" + }, { - "userId": "65", - "email": "emma@example.com", - "firstName": "Emma", - "lastName": "Jones", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, + "id": "cljn3x7y00017356z8f4g2h1z", + "name": "Ada Lovelace", + "email": "alovelace@test.com", + "email_verified": "2024-01-20T15:00:00.000Z", + "created_at": "2024-01-20T15:00:00.000Z", + "updated_at": "2024-01-20T15:00:00.000Z" + }, { - "userId": "66", - "email": "frank@example.com", - "firstName": "Frank", - "lastName": "Davis", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "67", - "email": "grace@example.com", - "firstName": "Grace", - "lastName": "Miller", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "68", - "email": "henry@example.com", - "firstName": "Henry", - "lastName": "Wilson", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "69", - "email": "isabel@example.com", - "firstName": "Isabel", - "lastName": "Moore", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "70", - "email": "jack@example.com", - "firstName": "Jack", - "lastName": "Taylor", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "71", - "email": "user71@example.com", - "firstName": "Alice", - "lastName": "Smith", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "72", - "email": "user72@example.com", - "firstName": "Bob", - "lastName": "Johnson", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "73", - "email": "user73@example.com", - "firstName": "Charlie", - "lastName": "Brown", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "74", - "email": "user74@example.com", - "firstName": "David", - "lastName": "Miller", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "75", - "email": "user75@example.com", - "firstName": "Eva", - "lastName": "Davis", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "76", - "email": "user76@example.com", - "firstName": "Frank", - "lastName": "White", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "77", - "email": "user77@example.com", - "firstName": "Grace", - "lastName": "Anderson", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "78", - "email": "user78@example.com", - "firstName": "Harry", - "lastName": "Moore", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "79", - "email": "user79@example.com", - "firstName": "Ivy", - "lastName": "Taylor", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "" - }, - { - "userId": "80", - "email": "user80@example.com", - "firstName": "Jack", - "lastName": "Harris", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "81", - "email": "user81@example.com", - "firstName": "User81", - "lastName": "Lastname81", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "82", - "email": "user82@example.com", - "firstName": "User82", - "lastName": "Lastname82", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "83", - "email": "user83@example.com", - "firstName": "User83", - "lastName": "Lastname83", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "84", - "email": "user84@example.com", - "firstName": "User84", - "lastName": "Lastname84", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "85", - "email": "user85@example.com", - "firstName": "User85", - "lastName": "Lastname85", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "86", - "email": "user86@example.com", - "firstName": "User86", - "lastName": "Lastname86", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "87", - "email": "user87@example.com", - "firstName": "User87", - "lastName": "Lastname87", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "88", - "email": "user88@example.com", - "firstName": "User88", - "lastName": "Lastname88", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "89", - "email": "user89@example.com", - "firstName": "User89", - "lastName": "Lastname89", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "90", - "email": "user90@example.com", - "firstName": "User90", - "lastName": "Lastname90", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "91", - "email": "user91@example.com", - "firstName": "User", - "lastName": "Ninety-One", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "92", - "email": "user92@example.com", - "firstName": "User", - "lastName": "Ninety-Two", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "93", - "email": "user93@example.com", - "firstName": "User", - "lastName": "Ninety-Three", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "94", - "email": "user94@example.com", - "firstName": "User", - "lastName": "Ninety-Four", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "95", - "email": "user95@example.com", - "firstName": "User", - "lastName": "Ninety-Five", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "96", - "email": "user96@example.com", - "firstName": "User", - "lastName": "Ninety-Six", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "97", - "email": "user97@example.com", - "firstName": "User", - "lastName": "Ninety-Seven", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "98", - "email": "user98@example.com", - "firstName": "User", - "lastName": "Ninety-Eight", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "99", - "email": "user99@example.com", - "firstName": "User", - "lastName": "Ninety-Nine", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, - { - "userId": "100", - "email": "user100@example.com", - "firstName": "User", - "lastName": "One Hundred", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - }, + "id": "cljn3x7y00018356z8f4g2h20", + "name": "Alan Turing", + "email": "aturing@test.com", + "email_verified": "2024-01-21T09:30:00.000Z", + "created_at": "2024-01-21T09:30:00.000Z", + "updated_at": "2024-01-21T09:30:00.000Z" + }, { - "userId": "101", - "email": "user101@example.com", - "firstName": "User", - "lastName": "One Hundred and One", - "password": "$2a$12$9HhLqMJxqBKhlZasxjlhger67GFcC4aOAtpcU.THpcSLiQve4mq6.", - "passwordHasher": "bcrypt" - "createdAt": "2025-03-13T18:47:11.37331+00:00", + "id": "cljn3x7y00019356z8f4g2h21", + "name": "Margaret Hamilton", + "email": "mhamilton@test.com", + "email_verified": "2024-01-21T11:00:00.000Z", + "created_at": "2024-01-21T11:00:00.000Z", + "updated_at": "2024-01-21T11:00:00.000Z" } ] diff --git a/samples/clerk.csv b/samples/clerk.csv new file mode 100644 index 0000000..1b81922 --- /dev/null +++ b/samples/clerk.csv @@ -0,0 +1,71 @@ +# Password for users with passwords: Kk4aPMeiaRpAs2OeX1NE +id,first_name,last_name,username,primary_email_address,primary_phone_number,verified_email_addresses,unverified_email_addresses,verified_phone_numbers,unverified_phone_numbers,totp_secret,password_digest,password_hasher +user_2YDryYFVMM1W1plDDKz7Gzf4we6,Jane,Doe,,jane.doe@test.com,,janedoe@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2ZZCgLE7kJG2CRBxTZ6YUIvzS10,John,Doe,,johndoe@test.com,,johndoe@test.com,,,,,, +user_2cWszPHuo6P2lCdnhhZbVMfbAIC,John,Hancock,,johnhancock@test.com,,johnhancock@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2cukOsyNsh0J3MCEvrgM6PkoB0I,Jane,Hancock,,janehancock@test.com,,janehancock@test.com,,,,,, +user_2dA1B2C3D4E5F6G7H8I9J0K1L2M,Alice,Smith,,alicesmith@test.com,,alicesmith@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2dB2C3D4E5F6G7H8I9J0K1L2M3N,Bob,Johnson,,bobjohnson@test.com,,bobjohnson@test.com,,,,,, +user_2dC3D4E5F6G7H8I9J0K1L2M3N4O,Carol,Williams,,carolwilliams@test.com,,carolwilliams@test.com,,,,,, +user_2dD4E5F6G7H8I9J0K1L2M3N4O5P,David,Brown,,davidbrown@test.com,,davidbrown@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2dE5F6G7H8I9J0K1L2M3N4O5P6Q,Emma,Jones,,emmajones@test.com,,emmajones@test.com,,,,,, +user_2dF6G7H8I9J0K1L2M3N4O5P6Q7R,Frank,Garcia,,frankgarcia@test.com,,frankgarcia@test.com,,,,,, +user_2dG7H8I9J0K1L2M3N4O5P6Q7R8S,Grace,Miller,,gracemiller@test.com,,gracemiller@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2dH8I9J0K1L2M3N4O5P6Q7R8S9T,Henry,Davis,,henrydavis@test.com,,henrydavis@test.com,,,,,, +user_2dI9J0K1L2M3N4O5P6Q7R8S9T0U,Ivy,Rodriguez,,ivyrodriguez@test.com,,ivyrodriguez@test.com,,,,,, +user_2dJ0K1L2M3N4O5P6Q7R8S9T0U1V,Jack,Martinez,,jackmartinez@test.com,,jackmartinez@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2dK1L2M3N4O5P6Q7R8S9T0U1V2W,Kate,Hernandez,,katehernandez@test.com,,katehernandez@test.com,,,,,, +user_2dL2M3N4O5P6Q7R8S9T0U1V2W3X,Liam,Lopez,,liamlope@test.com,,liamlope@test.com,,,,,, +user_2dM3N4O5P6Q7R8S9T0U1V2W3X4Y,Mia,Gonzalez,,miagonzalez@test.com,,miagonzalez@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2dN4O5P6Q7R8S9T0U1V2W3X4Y5Z,Noah,Wilson,,noahwilson@test.com,,noahwilson@test.com,,,,,, +user_2dO5P6Q7R8S9T0U1V2W3X4Y5Z6A,Olivia,Anderson,,oliviaanderson@test.com,,oliviaanderson@test.com,,,,,, +user_2dP6Q7R8S9T0U1V2W3X4Y5Z6A7B,Peter,Thomas,,peterthomas@test.com,,peterthomas@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2dQ7R8S9T0U1V2W3X4Y5Z6A7B8C,Quinn,Taylor,,quinntaylor@test.com,,quinntaylor@test.com,,,,,, +user_2dR8S9T0U1V2W3X4Y5Z6A7B8C9D,Rachel,Moore,,rachelmoore@test.com,,rachelmoore@test.com,,,,,, +user_2dS9T0U1V2W3X4Y5Z6A7B8C9D0E,Sam,Jackson,,samjackson@test.com,,samjackson@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2dT0U1V2W3X4Y5Z6A7B8C9D0E1F,Tina,Martin,,tinamartin@test.com,,tinamartin@test.com,,,,,, +user_2dU1V2W3X4Y5Z6A7B8C9D0E1F2G,Uma,Lee,,umalee@test.com,,umalee@test.com,,,,,, +user_2dV2W3X4Y5Z6A7B8C9D0E1F2G3H,Victor,Perez,,victorperez@test.com,,victorperez@test.com,,,,,, +user_2dW3X4Y5Z6A7B8C9D0E1F2G3H4I,Wendy,Thompson,,wendythompson@test.com,,wendythompson@test.com,,,,,, +user_2dX4Y5Z6A7B8C9D0E1F2G3H4I5J,Xavier,White,,xavierwhite@test.com,,xavierwhite@test.com,,,,,, +user_2dY5Z6A7B8C9D0E1F2G3H4I5J6K,Yara,Harris,,yaraharris@test.com,,yaraharris@test.com,,,,,, +user_2dZ6A7B8C9D0E1F2G3H4I5J6K7L,Zach,Sanchez,,zachsanchez@test.com,,zachsanchez@test.com,,,,,, +user_2eA7B8C9D0E1F2G3H4I5J6K7L8M,Amy,Clark,,amyclark@test.com,,amyclark@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2eB8C9D0E1F2G3H4I5J6K7L8M9N,Brian,Ramirez,,brianramirez@test.com,,brianramirez@test.com,,,,,, +user_2eC9D0E1F2G3H4I5J6K7L8M9N0O,Chloe,Lewis,,chloelewis@test.com,,chloelewis@test.com,,,,,, +user_2eD0E1F2G3H4I5J6K7L8M9N0O1P,Derek,Robinson,,derekrobinson@test.com,,derekrobinson@test.com,,,,,, +user_2eE1F2G3H4I5J6K7L8M9N0O1P2Q,Elena,Walker,,elenawalker@test.com,,elenawalker@test.com,,,,,, +user_2eF2G3H4I5J6K7L8M9N0O1P2Q3R,Felix,Young,,felixyoung@test.com,,felixyoung@test.com,,,,,, +user_2eG3H4I5J6K7L8M9N0O1P2Q3R4S,Gina,Allen,,ginaallen@test.com,,ginaallen@test.com,,,,,, +user_2eH4I5J6K7L8M9N0O1P2Q3R4S5T,Hugo,King,,hugoking@test.com,,hugoking@test.com,,,,,, +user_2eI5J6K7L8M9N0O1P2Q3R4S5T6U,Iris,Wright,,iriswright@test.com,,iriswright@test.com,,,,,, +user_2eJ6K7L8M9N0O1P2Q3R4S5T6U7V,James,Scott,,jamesscott@test.com,,jamesscott@test.com,,,,,, +user_2eK7L8M9N0O1P2Q3R4S5T6U7V8W,Kelly,Torres,,kellytorres@test.com,,kellytorres@test.com,,,,,, +user_2eL8M9N0O1P2Q3R4S5T6U7V8W9X,Leo,Nguyen,,leonguyen@test.com,,leonguyen@test.com,,,,,, +user_2eM9N0O1P2Q3R4S5T6U7V8W9X0Y,Maya,Hill,,mayahill@test.com,,mayahill@test.com,,,,,, +user_2eN0O1P2Q3R4S5T6U7V8W9X0Y1Z,Nate,Flores,,nateflores@test.com,,nateflores@test.com,,,,,, +user_2eO1P2Q3R4S5T6U7V8W9X0Y1Z2A,Ophelia,Green,,opheliagreen@test.com,,opheliagreen@test.com,,,,,, +user_2eP2Q3R4S5T6U7V8W9X0Y1Z2A3B,Paul,Adams,,pauladams@test.com,,pauladams@test.com,,,,,, +user_2eQ3R4S5T6U7V8W9X0Y1Z2A3B4C,Queenie,Nelson,,queenienelson@test.com,,queenienelson@test.com,,,,,, +user_2eR4S5T6U7V8W9X0Y1Z2A3B4C5D,Ryan,Baker,,ryanbaker@test.com,,ryanbaker@test.com,,,,,, +user_2eS5T6U7V8W9X0Y1Z2A3B4C5D6E,Sara,Hall,,sarahall@test.com,,sarahall@test.com,,,,,, +user_2eT6U7V8W9X0Y1Z2A3B4C5D6E7F,Tom,Rivera,,tomrivera@test.com,,tomrivera@test.com,,,,,, +user_2eG7H8I9J0K1L2M3N4O5P6Q7R8S,Sarah,Connor,sconnor,sconnor@test.com,,sconnor@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2eH8I9J0K1L2M3N4O5P6Q7R8S9T,Michael,Scott,mscott,mscott@test.com,,mscott@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2eI9J0K1L2M3N4O5P6Q7R8S9T0U,Leslie,Knope,lknope,lknope@test.com,,lknope@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2eJ0K1L2M3N4O5P6Q7R8S9T0U1V,Ron,Swanson,rswanson,rswanson@test.com,,rswanson@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_2eK1L2M3N4O5P6Q7R8S9T0U1V2W,April,Ludgate,aludgate,aludgate@test.com,,aludgate@test.com,,,,,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,bcrypt +user_3fL2M3N4O5P6Q7R8S9T0U1V2W3X,,,phoneuser1,,+12125550100,,,+12125550100,,, +user_3fM3N4O5P6Q7R8S9T0U1V2W3X4Y,,,phoneuser2,,+12125550101,,,+12125550101,,, +user_3fN4O5P6Q7R8S9T0U1V2W3X4Y5Z,,,phoneuser3,,+12125550102,,,+12125550102,,, +user_3fO5P6Q7R8S9T0U1V2W3X4Y5Z6A,,,phoneuser4,,+12125550103,,,+12125550103,,, +user_3fP6Q7R8S9T0U1V2W3X4Y5Z6A7B,,,phoneuser5,,+12125550104,,,+12125550104,,, +user_3fQ7R8S9T0U1V2W3X4Y5Z6A7B8C,,,phoneuser6,,+12125550105,,,+12125550105,,, +user_3fR8S9T0U1V2W3X4Y5Z6A7B8C9D,,,phoneuser7,,+12125550106,,,+12125550106,,, +user_3fS9T0U1V2W3X4Y5Z6A7B8C9D0E,,,phoneuser8,,+12125550107,,,+12125550107,,, +user_3fT0U1V2W3X4Y5Z6A7B8C9D0E1F,,,phoneuser9,,+12125550108,,,+12125550108,,, +user_3fU1V2W3X4Y5Z6A7B8C9D0E1F2G,,,phoneuser10,,+12125550109,,,+12125550109,,, +user_pipe_email_test,Pipe,Email,pipeemail,primary@test.com,,primary@test.com|secondary@test.com,,,,,, +user_pipe_phone_test,Pipe,Phone,pipephone,,+12125550200,,,,+12125550200|+12125550201,,, +user_mixed_separator_test,Mixed,Separator,mixedsep,first@test.com,,first@test.com,second@test.com|third@test.com,,,,, +user_mixed_phone_separator_test,Mixed,Phone,,mixedphone@test.com,+12125550300,mixedphone@test.com,,,+12125550300|+12125550301,,, diff --git a/samples/clerk.json b/samples/clerk.json new file mode 100644 index 0000000..ab0d2b9 --- /dev/null +++ b/samples/clerk.json @@ -0,0 +1,552 @@ +[ + { + "id": "user_2YDryYFVMM1W1plDDKz7Gzf4we6", + "first_name": "Jane", + "last_name": "Doe", + "username": null, + "primary_email_address": "jane.doe@test.com", + "email_addresses": [ + "janedoe@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "password_hasher": "bcrypt", + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2ZZCgLE7kJG2CRBxTZ6YUIvzS10", + "first_name": "John", + "last_name": "Doe", + "username": null, + "primary_email_address": "johndoe@test.com", + "email_addresses": [ + "johndoe@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2cWszPHuo6P2lCdnhhZbVMfbAIC", + "first_name": "John", + "last_name": "Hancock", + "username": null, + "primary_email_address": "johnhancock@test.com", + "email_addresses": [ + "johnhancock@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "password_hasher": "bcrypt", + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2cukOsyNsh0J3MCEvrgM6PkoB0I", + "first_name": "Jane", + "last_name": "Hancock", + "username": null, + "primary_email_address": "janehancock@test.com", + "email_addresses": [ + "janehancock@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2dA1B2C3D4E5F6G7H8I9J0K1L2M", + "first_name": "Alice", + "last_name": "Smith", + "username": null, + "primary_email_address": "alicesmith@test.com", + "email_addresses": [ + "alicesmith@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "password_hasher": "bcrypt", + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2dB2C3D4E5F6G7H8I9J0K1L2M3N", + "first_name": "Bob", + "last_name": "Johnson", + "username": null, + "primary_email_address": "bobjohnson@test.com", + "email_addresses": [ + "bobjohnson@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2dC3D4E5F6G7H8I9J0K1L2M3N4O", + "first_name": "Carol", + "last_name": "Williams", + "username": null, + "primary_email_address": "carolwilliams@test.com", + "email_addresses": [ + "carolwilliams@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2dD4E5F6G7H8I9J0K1L2M3N4O5P", + "first_name": "David", + "last_name": "Brown", + "username": null, + "primary_email_address": "davidbrown@test.com", + "email_addresses": [ + "davidbrown@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "password_hasher": "bcrypt", + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2dE5F6G7H8I9J0K1L2M3N4O5P6Q", + "first_name": "Emma", + "last_name": "Jones", + "username": null, + "primary_email_address": "emmajones@test.com", + "email_addresses": [ + "emmajones@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2dF6G7H8I9J0K1L2M3N4O5P6Q7R", + "first_name": "Frank", + "last_name": "Garcia", + "username": null, + "primary_email_address": "frankgarcia@test.com", + "email_addresses": [ + "frankgarcia@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2eG7H8I9J0K1L2M3N4O5P6Q7R8S", + "first_name": "Sarah", + "last_name": "Connor", + "username": "sconnor", + "primary_email_address": "sconnor@test.com", + "email_addresses": [ + "sconnor@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "password_hasher": "bcrypt", + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2eH8I9J0K1L2M3N4O5P6Q7R8S9T", + "first_name": "Michael", + "last_name": "Scott", + "username": "mscott", + "primary_email_address": "mscott@test.com", + "email_addresses": [ + "mscott@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "password_hasher": "bcrypt", + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2eI9J0K1L2M3N4O5P6Q7R8S9T0U", + "first_name": "Leslie", + "last_name": "Knope", + "username": "lknope", + "primary_email_address": "lknope@test.com", + "email_addresses": [ + "lknope@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "password_hasher": "bcrypt", + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2eJ0K1L2M3N4O5P6Q7R8S9T0U1V", + "first_name": "Ron", + "last_name": "Swanson", + "username": "rswanson", + "primary_email_address": "rswanson@test.com", + "email_addresses": [ + "rswanson@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "password_hasher": "bcrypt", + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_2eK1L2M3N4O5P6Q7R8S9T0U1V2W", + "first_name": "April", + "last_name": "Ludgate", + "username": "aludgate", + "primary_email_address": "aludgate@test.com", + "email_addresses": [ + "aludgate@test.com" + ], + "primary_phone_number": null, + "phone_numbers": null, + "password_digest": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "password_hasher": "bcrypt", + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_3fL2M3N4O5P6Q7R8S9T0U1V2W3X", + "first_name": null, + "last_name": null, + "username": "phoneuser1", + "primary_email_address": null, + "email_addresses": null, + "primary_phone_number": "+12125550100", + "phone_numbers": [ + "+12125550100" + ], + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_3fM3N4O5P6Q7R8S9T0U1V2W3X4Y", + "first_name": null, + "last_name": null, + "username": "phoneuser2", + "primary_email_address": null, + "email_addresses": null, + "primary_phone_number": "+12125550101", + "phone_numbers": [ + "+12125550101" + ], + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_3fN4O5P6Q7R8S9T0U1V2W3X4Y5Z", + "first_name": null, + "last_name": null, + "username": "phoneuser3", + "primary_email_address": null, + "email_addresses": null, + "primary_phone_number": "+12125550102", + "phone_numbers": [ + "+12125550102" + ], + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_3fO5P6Q7R8S9T0U1V2W3X4Y5Z6A", + "first_name": null, + "last_name": null, + "username": "phoneuser4", + "primary_email_address": null, + "email_addresses": null, + "primary_phone_number": "+12125550103", + "phone_numbers": [ + "+12125550103" + ], + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_3fP6Q7R8S9T0U1V2W3X4Y5Z6A7B", + "first_name": null, + "last_name": null, + "username": "phoneuser5", + "primary_email_address": null, + "email_addresses": null, + "primary_phone_number": "+12125550104", + "phone_numbers": [ + "+12125550104" + ], + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_3fQ7R8S9T0U1V2W3X4Y5Z6A7B8C", + "first_name": null, + "last_name": null, + "username": "phoneuser6", + "primary_email_address": null, + "email_addresses": null, + "primary_phone_number": "+12125550105", + "phone_numbers": [ + "+12125550105" + ], + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_3fR8S9T0U1V2W3X4Y5Z6A7B8C9D", + "first_name": null, + "last_name": null, + "username": "phoneuser7", + "primary_email_address": null, + "email_addresses": null, + "primary_phone_number": "+12125550106", + "phone_numbers": [ + "+12125550106" + ], + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_3fS9T0U1V2W3X4Y5Z6A7B8C9D0E", + "first_name": null, + "last_name": null, + "username": "phoneuser8", + "primary_email_address": null, + "email_addresses": null, + "primary_phone_number": "+12125550107", + "phone_numbers": [ + "+12125550107" + ], + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_3fT0U1V2W3X4Y5Z6A7B8C9D0E1F", + "first_name": null, + "last_name": null, + "username": "phoneuser9", + "primary_email_address": null, + "email_addresses": null, + "primary_phone_number": "+12125550108", + "phone_numbers": [ + "+12125550108" + ], + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + }, + { + "id": "user_3fU1V2W3X4Y5Z6A7B8C9D0E1F2G", + "first_name": null, + "last_name": null, + "username": "phoneuser10", + "primary_email_address": null, + "email_addresses": null, + "primary_phone_number": "+12125550109", + "phone_numbers": [ + "+12125550109" + ], + "password_digest": null, + "password_hasher": null, + "unsafe_metadata": {}, + "public_metadata": {}, + "private_metadata": {}, + "has_image": false, + "image_url": null, + "backup_codes_enabled": false, + "backup_codes": null, + "totp_secret": null + } +] diff --git a/samples/supabase.csv b/samples/supabase.csv new file mode 100644 index 0000000..417a9ba --- /dev/null +++ b/samples/supabase.csv @@ -0,0 +1,27 @@ +instance_id,id,aud,role,email,encrypted_password,email_confirmed_at,invited_at,confirmation_token,confirmation_sent_at,recovery_token,recovery_sent_at,email_change_token_new,email_change,email_change_sent_at,last_sign_in_at,raw_app_meta_data,raw_user_meta_data,is_super_admin,created_at,updated_at,phone,phone_confirmed_at,phone_change,phone_change_token,phone_change_sent_at,confirmed_at,email_change_token_current,email_change_confirm_status,banned_until,reauthentication_token,reauthentication_sent_at,is_sso_user,deleted_at,first_name,last_name +00000000-0000-0000-0000-000000000000,user_2YDryYFVMM1W1plDDKz7Gzf4we6,authenticated,authenticated,jane.doe@test.com,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,2024-01-15 10:30:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:30:00.000000+00,,0,,,false,,Jane,Doe +00000000-0000-0000-0000-000000000000,user_2ZZCgLE7kJG2CRBxTZ6YUIvzS10,authenticated,authenticated,johndoe@test.com,,2024-01-15 10:31:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:31:00.000000+00,,0,,,false,,John,Doe +00000000-0000-0000-0000-000000000000,user_2cWszPHuo6P2lCdnhhZbVMfbAIC,authenticated,authenticated,johnhancock@test.com,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,2024-01-15 10:32:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:32:00.000000+00,,0,,,false,,John,Hancock +00000000-0000-0000-0000-000000000000,user_2cukOsyNsh0J3MCEvrgM6PkoB0I,authenticated,authenticated,janehancock@test.com,,2024-01-15 10:33:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:33:00.000000+00,,0,,,false,,Jane,Hancock +00000000-0000-0000-0000-000000000000,user_2dA1B2C3D4E5F6G7H8I9J0K1L2M,authenticated,authenticated,alicesmith@test.com,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,2024-01-15 10:34:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:34:00.000000+00,,0,,,false,,Alice,Smith +00000000-0000-0000-0000-000000000000,user_2dB2C3D4E5F6G7H8I9J0K1L2M3N,authenticated,authenticated,bobjohnson@test.com,,2024-01-15 10:35:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:35:00.000000+00,,0,,,false,,Bob,Johnson +00000000-0000-0000-0000-000000000000,user_2dC3D4E5F6G7H8I9J0K1L2M3N4O,authenticated,authenticated,carolwilliams@test.com,,2024-01-15 10:36:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:36:00.000000+00,,0,,,false,,Carol,Williams +00000000-0000-0000-0000-000000000000,user_2dD4E5F6G7H8I9J0K1L2M3N4O5P,authenticated,authenticated,davidbrown@test.com,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,2024-01-15 10:37:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:37:00.000000+00,,0,,,false,,David,Brown +00000000-0000-0000-0000-000000000000,user_2dE5F6G7H8I9J0K1L2M3N4O5P6Q,authenticated,authenticated,emmajones@test.com,,2024-01-15 10:38:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:38:00.000000+00,,0,,,false,,Emma,Jones +00000000-0000-0000-0000-000000000000,user_2dF6G7H8I9J0K1L2M3N4O5P6Q7R,authenticated,authenticated,frankgarcia@test.com,,2024-01-15 10:39:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:39:00.000000+00,,0,,,false,,Frank,Garcia +00000000-0000-0000-0000-000000000000,user_2eG7H8I9J0K1L2M3N4O5P6Q7R8S,authenticated,authenticated,sconnor@test.com,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,2024-01-15 10:40:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:40:00.000000+00,,0,,,false,,Sarah,Connor +00000000-0000-0000-0000-000000000000,user_2eH8I9J0K1L2M3N4O5P6Q7R8S9T,authenticated,authenticated,mscott@test.com,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,2024-01-15 10:41:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:41:00.000000+00,,0,,,false,,Michael,Scott +00000000-0000-0000-0000-000000000000,user_2eI9J0K1L2M3N4O5P6Q7R8S9T0U,authenticated,authenticated,lknope@test.com,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,2024-01-15 10:42:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:42:00.000000+00,,0,,,false,,Leslie,Knope +00000000-0000-0000-0000-000000000000,user_2eJ0K1L2M3N4O5P6Q7R8S9T0U1V,authenticated,authenticated,rswanson@test.com,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,2024-01-15 10:43:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:43:00.000000+00,,0,,,false,,Ron,Swanson +00000000-0000-0000-0000-000000000000,user_2eK1L2M3N4O5P6Q7R8S9T0U1V2W,authenticated,authenticated,aludgate@test.com,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,2024-01-15 10:44:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:44:00.000000+00,,0,,,false,,April,Ludgate +00000000-0000-0000-0000-000000000000,user_3fL2M3N4O5P6Q7R8S9T0U1V2W3X,authenticated,authenticated,,,,,,,,,,,,,"{\""provider\"":\""phone\"",\""providers\"":[\""phone\""]}",{},2024-01-15 10:45:00.000000+00,2024-01-15 10:45:00.000000+00,+12125550100,2024-01-15 10:45:00.000000+00,,,2024-01-15 10:45:00.000000+00,,0,,,false,,, +00000000-0000-0000-0000-000000000000,user_3fM3N4O5P6Q7R8S9T0U1V2W3X4Y,authenticated,authenticated,,,,,,,,,,,,,"{\""provider\"":\""phone\"",\""providers\"":[\""phone\""]}",{},2024-01-15 10:46:00.000000+00,2024-01-15 10:46:00.000000+00,+12125550101,2024-01-15 10:46:00.000000+00,,,2024-01-15 10:46:00.000000+00,,0,,,false,,, +00000000-0000-0000-0000-000000000000,user_3fN4O5P6Q7R8S9T0U1V2W3X4Y5Z,authenticated,authenticated,,,,,,,,,,,,,"{\""provider\"":\""phone\"",\""providers\"":[\""phone\""]}",{},2024-01-15 10:47:00.000000+00,2024-01-15 10:47:00.000000+00,+12125550102,2024-01-15 10:47:00.000000+00,,,2024-01-15 10:47:00.000000+00,,0,,,false,,, +00000000-0000-0000-0000-000000000000,user_3fO5P6Q7R8S9T0U1V2W3X4Y5Z6A,authenticated,authenticated,,,,,,,,,,,,,"{\""provider\"":\""phone\"",\""providers\"":[\""phone\""]}",{},2024-01-15 10:48:00.000000+00,2024-01-15 10:48:00.000000+00,+12125550103,2024-01-15 10:48:00.000000+00,,,2024-01-15 10:48:00.000000+00,,0,,,false,,, +00000000-0000-0000-0000-000000000000,user_3fP6Q7R8S9T0U1V2W3X4Y5Z6A7B,authenticated,authenticated,,,,,,,,,,,,,"{\""provider\"":\""phone\"",\""providers\"":[\""phone\""]}",{},2024-01-15 10:49:00.000000+00,2024-01-15 10:49:00.000000+00,+12125550104,2024-01-15 10:49:00.000000+00,,,2024-01-15 10:49:00.000000+00,,0,,,false,,, +00000000-0000-0000-0000-000000000000,user_3fQ7R8S9T0U1V2W3X4Y5Z6A7B8C,authenticated,authenticated,,,,,,,,,,,,,"{\""provider\"":\""phone\"",\""providers\"":[\""phone\""]}",{},2024-01-15 10:50:00.000000+00,2024-01-15 10:50:00.000000+00,+12125550105,2024-01-15 10:50:00.000000+00,,,2024-01-15 10:50:00.000000+00,,0,,,false,,, +00000000-0000-0000-0000-000000000000,user_3fR8S9T0U1V2W3X4Y5Z6A7B8C9D,authenticated,authenticated,,,,,,,,,,,,,"{\""provider\"":\""phone\"",\""providers\"":[\""phone\""]}",{},2024-01-15 10:51:00.000000+00,2024-01-15 10:51:00.000000+00,+12125550106,2024-01-15 10:51:00.000000+00,,,2024-01-15 10:51:00.000000+00,,0,,,false,,, +00000000-0000-0000-0000-000000000000,user_3fS9T0U1V2W3X4Y5Z6A7B8C9D0E,authenticated,authenticated,,,,,,,,,,,,,"{\""provider\"":\""phone\"",\""providers\"":[\""phone\""]}",{},2024-01-15 10:52:00.000000+00,2024-01-15 10:52:00.000000+00,+12125550107,2024-01-15 10:52:00.000000+00,,,2024-01-15 10:52:00.000000+00,,0,,,false,,, +00000000-0000-0000-0000-000000000000,user_3fT0U1V2W3X4Y5Z6A7B8C9D0E1F,authenticated,authenticated,,,,,,,,,,,,,"{\""provider\"":\""phone\"",\""providers\"":[\""phone\""]}",{},2024-01-15 10:53:00.000000+00,2024-01-15 10:53:00.000000+00,+12125550108,2024-01-15 10:53:00.000000+00,,,2024-01-15 10:53:00.000000+00,,0,,,false,,, +00000000-0000-0000-0000-000000000000,user_3fU1V2W3X4Y5Z6A7B8C9D0E1F2G,authenticated,authenticated,,,,,,,,,,,,,"{\""provider\"":\""phone\"",\""providers\"":[\""phone\""]}",{},2024-01-15 10:54:00.000000+00,2024-01-15 10:54:00.000000+00,+12125550109,2024-01-15 10:54:00.000000+00,,,2024-01-15 10:54:00.000000+00,,0,,,false,,, +00000000-0000-0000-0000-000000000000,user_pipe_separator_test,authenticated,authenticated,primary@test.com|secondary@test.com,$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2,2024-01-15 10:55:00.000000+00,,,,,,,,,,"{\""provider\"":\""email\"",\""providers\"":[\""email\""]}",{},,,,,,,2024-01-15 10:55:00.000000+00,,0,,,false,,Pipe,Separator diff --git a/samples/supabase.json b/samples/supabase.json new file mode 100644 index 0000000..46cf3f2 --- /dev/null +++ b/samples/supabase.json @@ -0,0 +1,462 @@ +[ + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2YDryYFVMM1W1plDDKz7Gzf4we6", + "aud": "authenticated", + "role": "authenticated", + "email": "jane.doe@test.com", + "first_name": "Jane", + "last_name": "Doe", + "encrypted_password": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "email_confirmed_at": "2024-01-15 10:30:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:30:00.000000+00", + "updated_at": "2024-01-15 10:30:00.000000+00", + "confirmed_at": "2024-01-15 10:30:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2ZZCgLE7kJG2CRBxTZ6YUIvzS10", + "aud": "authenticated", + "role": "authenticated", + "email": "johndoe@test.com", + "first_name": "John", + "last_name": "Doe", + "encrypted_password": "", + "email_confirmed_at": "2024-01-15 10:31:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:31:00.000000+00", + "updated_at": "2024-01-15 10:31:00.000000+00", + "confirmed_at": "2024-01-15 10:31:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2cWszPHuo6P2lCdnhhZbVMfbAIC", + "aud": "authenticated", + "role": "authenticated", + "email": "johnhancock@test.com", + "first_name": "John", + "last_name": "Hancock", + "encrypted_password": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "email_confirmed_at": "2024-01-15 10:32:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:32:00.000000+00", + "updated_at": "2024-01-15 10:32:00.000000+00", + "confirmed_at": "2024-01-15 10:32:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2cukOsyNsh0J3MCEvrgM6PkoB0I", + "aud": "authenticated", + "role": "authenticated", + "email": "janehancock@test.com", + "first_name": "Jane", + "last_name": "Hancock", + "encrypted_password": "", + "email_confirmed_at": "2024-01-15 10:33:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:33:00.000000+00", + "updated_at": "2024-01-15 10:33:00.000000+00", + "confirmed_at": "2024-01-15 10:33:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2dA1B2C3D4E5F6G7H8I9J0K1L2M", + "aud": "authenticated", + "role": "authenticated", + "email": "alicesmith@test.com", + "first_name": "Alice", + "last_name": "Smith", + "encrypted_password": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "email_confirmed_at": "2024-01-15 10:34:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:34:00.000000+00", + "updated_at": "2024-01-15 10:34:00.000000+00", + "confirmed_at": "2024-01-15 10:34:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2dB2C3D4E5F6G7H8I9J0K1L2M3N", + "aud": "authenticated", + "role": "authenticated", + "email": "bobjohnson@test.com", + "first_name": "Bob", + "last_name": "Johnson", + "encrypted_password": "", + "email_confirmed_at": "2024-01-15 10:35:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:35:00.000000+00", + "updated_at": "2024-01-15 10:35:00.000000+00", + "confirmed_at": "2024-01-15 10:35:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2dC3D4E5F6G7H8I9J0K1L2M3N4O", + "aud": "authenticated", + "role": "authenticated", + "email": "carolwilliams@test.com", + "first_name": "Carol", + "last_name": "Williams", + "encrypted_password": "", + "email_confirmed_at": "2024-01-15 10:36:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:36:00.000000+00", + "updated_at": "2024-01-15 10:36:00.000000+00", + "confirmed_at": "2024-01-15 10:36:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2dD4E5F6G7H8I9J0K1L2M3N4O5P", + "aud": "authenticated", + "role": "authenticated", + "email": "davidbrown@test.com", + "first_name": "David", + "last_name": "Brown", + "encrypted_password": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "email_confirmed_at": "2024-01-15 10:37:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:37:00.000000+00", + "updated_at": "2024-01-15 10:37:00.000000+00", + "confirmed_at": "2024-01-15 10:37:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2dE5F6G7H8I9J0K1L2M3N4O5P6Q", + "aud": "authenticated", + "role": "authenticated", + "email": "emmajones@test.com", + "first_name": "Emma", + "last_name": "Jones", + "encrypted_password": "", + "email_confirmed_at": "2024-01-15 10:38:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:38:00.000000+00", + "updated_at": "2024-01-15 10:38:00.000000+00", + "confirmed_at": "2024-01-15 10:38:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2dF6G7H8I9J0K1L2M3N4O5P6Q7R", + "aud": "authenticated", + "role": "authenticated", + "email": "frankgarcia@test.com", + "first_name": "Frank", + "last_name": "Garcia", + "encrypted_password": "", + "email_confirmed_at": "2024-01-15 10:39:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:39:00.000000+00", + "updated_at": "2024-01-15 10:39:00.000000+00", + "confirmed_at": "2024-01-15 10:39:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2eG7H8I9J0K1L2M3N4O5P6Q7R8S", + "aud": "authenticated", + "role": "authenticated", + "email": "sconnor@test.com", + "first_name": "Sarah", + "last_name": "Connor", + "username": "sconnor", + "encrypted_password": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "email_confirmed_at": "2024-01-15 10:40:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:40:00.000000+00", + "updated_at": "2024-01-15 10:40:00.000000+00", + "confirmed_at": "2024-01-15 10:40:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2eH8I9J0K1L2M3N4O5P6Q7R8S9T", + "aud": "authenticated", + "role": "authenticated", + "email": "mscott@test.com", + "first_name": "Michael", + "last_name": "Scott", + "username": "mscott", + "encrypted_password": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "email_confirmed_at": "2024-01-15 10:41:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:41:00.000000+00", + "updated_at": "2024-01-15 10:41:00.000000+00", + "confirmed_at": "2024-01-15 10:41:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2eI9J0K1L2M3N4O5P6Q7R8S9T0U", + "aud": "authenticated", + "role": "authenticated", + "email": "lknope@test.com", + "first_name": "Leslie", + "last_name": "Knope", + "username": "lknope", + "encrypted_password": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "email_confirmed_at": "2024-01-15 10:42:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:42:00.000000+00", + "updated_at": "2024-01-15 10:42:00.000000+00", + "confirmed_at": "2024-01-15 10:42:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2eJ0K1L2M3N4O5P6Q7R8S9T0U1V", + "aud": "authenticated", + "role": "authenticated", + "email": "rswanson@test.com", + "first_name": "Ron", + "last_name": "Swanson", + "username": "rswanson", + "encrypted_password": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "email_confirmed_at": "2024-01-15 10:43:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:43:00.000000+00", + "updated_at": "2024-01-15 10:43:00.000000+00", + "confirmed_at": "2024-01-15 10:43:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_2eK1L2M3N4O5P6Q7R8S9T0U1V2W", + "aud": "authenticated", + "role": "authenticated", + "email": "aludgate@test.com", + "first_name": "April", + "last_name": "Ludgate", + "username": "aludgate", + "encrypted_password": "$2b$10$U4C0ZY8OG8y41F9LusfKyu3HRMBL0rCZcKBVsXhgr.n8Ou6FPhzO2", + "email_confirmed_at": "2024-01-15 10:44:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"email\",\"providers\":[\"email\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:44:00.000000+00", + "updated_at": "2024-01-15 10:44:00.000000+00", + "confirmed_at": "2024-01-15 10:44:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_3fL2M3N4O5P6Q7R8S9T0U1V2W3X", + "aud": "authenticated", + "role": "authenticated", + "username": "phoneuser1", + "phone": "+12125550100", + "phone_confirmed_at": "2024-01-15 10:45:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"phone\",\"providers\":[\"phone\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:45:00.000000+00", + "updated_at": "2024-01-15 10:45:00.000000+00", + "confirmed_at": "2024-01-15 10:45:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_3fM3N4O5P6Q7R8S9T0U1V2W3X4Y", + "aud": "authenticated", + "role": "authenticated", + "username": "phoneuser2", + "phone": "+12125550101", + "phone_confirmed_at": "2024-01-15 10:46:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"phone\",\"providers\":[\"phone\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:46:00.000000+00", + "updated_at": "2024-01-15 10:46:00.000000+00", + "confirmed_at": "2024-01-15 10:46:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_3fN4O5P6Q7R8S9T0U1V2W3X4Y5Z", + "aud": "authenticated", + "role": "authenticated", + "username": "phoneuser3", + "phone": "+12125550102", + "phone_confirmed_at": "2024-01-15 10:47:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"phone\",\"providers\":[\"phone\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:47:00.000000+00", + "updated_at": "2024-01-15 10:47:00.000000+00", + "confirmed_at": "2024-01-15 10:47:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_3fO5P6Q7R8S9T0U1V2W3X4Y5Z6A", + "aud": "authenticated", + "role": "authenticated", + "username": "phoneuser4", + "phone": "+12125550103", + "phone_confirmed_at": "2024-01-15 10:48:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"phone\",\"providers\":[\"phone\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:48:00.000000+00", + "updated_at": "2024-01-15 10:48:00.000000+00", + "confirmed_at": "2024-01-15 10:48:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_3fP6Q7R8S9T0U1V2W3X4Y5Z6A7B", + "aud": "authenticated", + "role": "authenticated", + "username": "phoneuser5", + "phone": "+12125550104", + "phone_confirmed_at": "2024-01-15 10:49:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"phone\",\"providers\":[\"phone\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:49:00.000000+00", + "updated_at": "2024-01-15 10:49:00.000000+00", + "confirmed_at": "2024-01-15 10:49:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_3fQ7R8S9T0U1V2W3X4Y5Z6A7B8C", + "aud": "authenticated", + "role": "authenticated", + "username": "phoneuser6", + "phone": "+12125550105", + "phone_confirmed_at": "2024-01-15 10:50:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"phone\",\"providers\":[\"phone\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:50:00.000000+00", + "updated_at": "2024-01-15 10:50:00.000000+00", + "confirmed_at": "2024-01-15 10:50:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_3fR8S9T0U1V2W3X4Y5Z6A7B8C9D", + "aud": "authenticated", + "role": "authenticated", + "username": "phoneuser7", + "phone": "+12125550106", + "phone_confirmed_at": "2024-01-15 10:51:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"phone\",\"providers\":[\"phone\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:51:00.000000+00", + "updated_at": "2024-01-15 10:51:00.000000+00", + "confirmed_at": "2024-01-15 10:51:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_3fS9T0U1V2W3X4Y5Z6A7B8C9D0E", + "aud": "authenticated", + "role": "authenticated", + "username": "phoneuser8", + "phone": "+12125550107", + "phone_confirmed_at": "2024-01-15 10:52:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"phone\",\"providers\":[\"phone\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:52:00.000000+00", + "updated_at": "2024-01-15 10:52:00.000000+00", + "confirmed_at": "2024-01-15 10:52:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_3fT0U1V2W3X4Y5Z6A7B8C9D0E1F", + "aud": "authenticated", + "role": "authenticated", + "username": "phoneuser9", + "phone": "+12125550108", + "phone_confirmed_at": "2024-01-15 10:53:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"phone\",\"providers\":[\"phone\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:53:00.000000+00", + "updated_at": "2024-01-15 10:53:00.000000+00", + "confirmed_at": "2024-01-15 10:53:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + }, + { + "instance_id": "00000000-0000-0000-0000-000000000000", + "id": "user_3fU1V2W3X4Y5Z6A7B8C9D0E1F2G", + "aud": "authenticated", + "role": "authenticated", + "username": "phoneuser10", + "phone": "+12125550109", + "phone_confirmed_at": "2024-01-15 10:54:00.000000+00", + "raw_app_meta_data": "{\"provider\":\"phone\",\"providers\":[\"phone\"]}", + "raw_user_meta_data": "{}", + "created_at": "2024-01-15 10:54:00.000000+00", + "updated_at": "2024-01-15 10:54:00.000000+00", + "confirmed_at": "2024-01-15 10:54:00.000000+00", + "email_change_confirm_status": "0", + "is_sso_user": "false", + "deleted_at": "" + } +] diff --git a/src/clean-logs/index.test.ts b/src/clean-logs/index.test.ts new file mode 100644 index 0000000..7823345 --- /dev/null +++ b/src/clean-logs/index.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +// Mock @clack/prompts +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn(), + cancel: vi.fn(), + spinner: vi.fn(() => ({ + start: vi.fn(), + stop: vi.fn(), + message: vi.fn(), + })), +})); + +// Mock picocolors +vi.mock('picocolors', () => ({ + default: { + bgCyan: vi.fn((s) => s), + black: vi.fn((s) => s), + }, +})); + +describe('clean-logs', () => { + const LOGS_DIR = path.join(process.cwd(), 'logs'); + const TEST_LOGS_DIR = path.join(process.cwd(), 'test-logs'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + // Clean up test directory + if (fs.existsSync(TEST_LOGS_DIR)) { + const files = fs.readdirSync(TEST_LOGS_DIR); + files.forEach((file) => { + fs.unlinkSync(path.join(TEST_LOGS_DIR, file)); + }); + fs.rmdirSync(TEST_LOGS_DIR); + } + }); + + test('creates logs directory path correctly', () => { + expect(LOGS_DIR).toBe(path.join(process.cwd(), 'logs')); + }); + + test('test directory setup works', () => { + // Create test directory and files + if (!fs.existsSync(TEST_LOGS_DIR)) { + fs.mkdirSync(TEST_LOGS_DIR); + } + + // Create test files + fs.writeFileSync(path.join(TEST_LOGS_DIR, 'test1.log'), 'test'); + fs.writeFileSync(path.join(TEST_LOGS_DIR, 'test2.log'), 'test'); + + const files = fs.readdirSync(TEST_LOGS_DIR); + expect(files.length).toBe(2); + + // Clean up + files.forEach((file) => { + fs.unlinkSync(path.join(TEST_LOGS_DIR, file)); + }); + + const filesAfter = fs.readdirSync(TEST_LOGS_DIR); + expect(filesAfter.length).toBe(0); + }); + + test('can read files from logs directory', () => { + if (fs.existsSync(LOGS_DIR)) { + const files = fs.readdirSync(LOGS_DIR); + expect(Array.isArray(files)).toBe(true); + } + }); +}); diff --git a/src/clean-logs/index.ts b/src/clean-logs/index.ts new file mode 100644 index 0000000..03027ca --- /dev/null +++ b/src/clean-logs/index.ts @@ -0,0 +1,80 @@ +import fs from 'fs'; +import path from 'path'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; + +const LOGS_DIR = path.join(process.cwd(), 'logs'); + +/** + * Deletes all log files from the logs directory + * + * Prompts the user for confirmation before deleting any files. + * Only deletes files, not subdirectories. + * + * @returns A promise that resolves when the operation is complete + */ +const cleanLogs = async () => { + p.intro( + `${color.bgCyan(color.black('Clerk User Migration Utility - Clean Logs'))}` + ); + + // Check if logs directory exists + if (!fs.existsSync(LOGS_DIR)) { + p.outro('No logs directory found. Nothing to clean.'); + return; + } + + // Read all files in the logs directory + const files = fs.readdirSync(LOGS_DIR); + + if (files.length === 0) { + p.outro('Logs directory is already empty.'); + return; + } + + // Confirm deletion + const shouldDelete = await p.confirm({ + message: `Delete ${files.length} log file(s)?`, + }); + + if (!shouldDelete || p.isCancel(shouldDelete)) { + p.cancel('Operation cancelled.'); + return; + } + + const s = p.spinner(); + s.start(`Deleting ${files.length} log file(s)`); + + let deletedCount = 0; + let errorCount = 0; + + for (const file of files) { + try { + const filePath = path.join(LOGS_DIR, file); + const stats = fs.statSync(filePath); + + // Only delete files, not directories + if (stats.isFile()) { + fs.unlinkSync(filePath); + deletedCount++; + } + } catch (error) { + errorCount++; + const errorMessage = + error instanceof Error ? error.message : String(error); + p.log.error(`Failed to delete ${file}: ${errorMessage}`); + } + } + + s.stop(); + + if (errorCount > 0) { + p.outro( + `Deleted ${deletedCount} file(s). Failed to delete ${errorCount} file(s).` + ); + } else { + p.outro(`Successfully deleted ${deletedCount} log file(s).`); + } +}; + +void cleanLogs(); diff --git a/src/delete/index.test.ts b/src/delete/index.test.ts new file mode 100644 index 0000000..c325aa9 --- /dev/null +++ b/src/delete/index.test.ts @@ -0,0 +1,636 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// Create mock functions at module level +const mockGetUserList = vi.fn(); +const mockDeleteUser = vi.fn(); + +// Mock @clerk/backend before importing the module +vi.mock('@clerk/backend', () => ({ + createClerkClient: () => ({ + users: { + getUserList: mockGetUserList, + deleteUser: mockDeleteUser, + }, + }), +})); + +// Mock @clack/prompts to prevent console output during tests +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + spinner: vi.fn(() => ({ + start: vi.fn(), + stop: vi.fn(), + message: vi.fn(), + })), + log: { + error: vi.fn(), + info: vi.fn(), + }, +})); + +// Mock picocolors +vi.mock('picocolors', () => ({ + default: { + bgCyan: vi.fn((s) => s), + black: vi.fn((s) => s), + red: vi.fn((s) => s), + yellow: vi.fn((s) => s), + }, +})); + +// Mock utils +vi.mock('../utils', () => ({ + getDateTimeStamp: vi.fn(() => '2024-01-01T12:00:00'), + createImportFilePath: vi.fn((file: string) => file), + getFileType: vi.fn(() => 'application/json'), + tryCatch: async (promise: Promise) => { + try { + const data = await promise; + return [data, null]; + } catch (error) { + return [null, error]; + } + }, + getRetryDelay: ( + retryCount: number, + retryAfterSeconds: number | undefined, + defaultDelayMs: number + ) => { + const delayMs = retryAfterSeconds + ? retryAfterSeconds * 1000 + : defaultDelayMs; + const delaySeconds = retryAfterSeconds || defaultDelayMs / 1000; + return { delayMs, delaySeconds }; + }, +})); + +// Mock env constants +vi.mock('../envs-constants', () => ({ + env: { + CLERK_SECRET_KEY: 'test_secret_key', + RATE_LIMIT: 10, + CONCURRENCY_LIMIT: 5, // Higher for faster tests + }, + MAX_RETRIES: 5, + RETRY_DELAY_MS: 10000, +})); + +// Mock fs module +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +// Mock logger module +vi.mock('../logger', () => ({ + errorLogger: vi.fn(), + importLogger: vi.fn(), + deleteErrorLogger: vi.fn(), + deleteLogger: vi.fn(), + closeAllStreams: vi.fn(), +})); + +// Import after mocks are set up +import { deleteErrorLogger, deleteLogger } from '../logger'; +import * as fs from 'fs'; + +// Get reference to mocked functions - cast to mock type since vi.mocked is not available +const mockDeleteErrorLogger = deleteErrorLogger as ReturnType; +const mockDeleteLogger = deleteLogger as ReturnType; + +describe('delete-users', () => { + let fetchUsers: any; + let deleteUsers: any; + let readSettings: any; + let readMigrationFile: any; + let findIntersection: any; + + const mockExistsSync = fs.existsSync as ReturnType; + const mockReadFileSync = fs.readFileSync as ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + // Set default return values to handle auto-execution of processUsers() + mockGetUserList.mockResolvedValue({ data: [] }); + mockDeleteUser.mockResolvedValue({}); + mockExistsSync.mockReturnValue(true); + + // Mock readFileSync to return different data based on file path + mockReadFileSync.mockImplementation((filePath: any) => { + const path = filePath.toString(); + if (path.includes('.settings')) { + return JSON.stringify({ file: 'samples/test.json' }); + } + // Return empty array for migration files by default + return JSON.stringify([]); + }); + + // Import the module to get functions - note: vi.resetModules() is not available in Bun's Vitest + const deleteUsersModule = await import('./index'); + fetchUsers = deleteUsersModule.fetchUsers; + deleteUsers = deleteUsersModule.deleteUsers; + readSettings = deleteUsersModule.readSettings; + readMigrationFile = deleteUsersModule.readMigrationFile; + findIntersection = deleteUsersModule.findIntersection; + + vi.clearAllMocks(); + }); + + describe('fetchUsers', () => { + test('fetches users with limit 500 and offset 0 on first call', async () => { + mockGetUserList.mockResolvedValueOnce({ + data: [ + { id: 'user_1', firstName: 'John' }, + { id: 'user_2', firstName: 'Jane' }, + ], + }); + + await fetchUsers(0); + + expect(mockGetUserList).toHaveBeenCalledTimes(1); + expect(mockGetUserList).toHaveBeenCalledWith({ + offset: 0, + limit: 500, + }); + }); + + test('returns users when data length is less than limit', async () => { + const mockUsers = [ + { id: 'user_1', firstName: 'John' }, + { id: 'user_2', firstName: 'Jane' }, + ]; + mockGetUserList.mockResolvedValueOnce({ data: mockUsers }); + + const result = await fetchUsers(0); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('user_1'); + expect(result[1].id).toBe('user_2'); + }); + + test('paginates when data length equals limit (500)', async () => { + // Create 500 users for first page + const firstPage = Array.from({ length: 500 }, (_, i) => ({ + id: `user_${i}`, + firstName: `User${i}`, + })); + + // Create 200 users for second page + const secondPage = Array.from({ length: 200 }, (_, i) => ({ + id: `user_${i + 500}`, + firstName: `User${i + 500}`, + })); + + mockGetUserList + .mockResolvedValueOnce({ data: firstPage }) + .mockResolvedValueOnce({ data: secondPage }); + + const result = await fetchUsers(0); + + expect(mockGetUserList).toHaveBeenCalledTimes(2); + expect(mockGetUserList).toHaveBeenNthCalledWith(1, { + offset: 0, + limit: 500, + }); + expect(mockGetUserList).toHaveBeenNthCalledWith(2, { + offset: 500, + limit: 500, + }); + expect(result).toHaveLength(700); + }); + + test('paginates through multiple pages without delay', async () => { + const firstPage = Array.from({ length: 500 }, (_, i) => ({ + id: `user_${i}`, + firstName: `User${i}`, + })); + + const secondPage = Array.from({ length: 100 }, (_, i) => ({ + id: `user_${i + 500}`, + firstName: `User${i + 500}`, + })); + + mockGetUserList + .mockResolvedValueOnce({ data: firstPage }) + .mockResolvedValueOnce({ data: secondPage }); + + const result = await fetchUsers(0); + + // Should fetch both pages + expect(mockGetUserList).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(600); + }); + + test('handles multiple pagination rounds (3 batches)', async () => { + const firstPage = Array.from({ length: 500 }, (_, i) => ({ + id: `user_${i}`, + firstName: `User${i}`, + })); + + const secondPage = Array.from({ length: 500 }, (_, i) => ({ + id: `user_${i + 500}`, + firstName: `User${i + 500}`, + })); + + const thirdPage = Array.from({ length: 150 }, (_, i) => ({ + id: `user_${i + 1000}`, + firstName: `User${i + 1000}`, + })); + + mockGetUserList + .mockResolvedValueOnce({ data: firstPage }) + .mockResolvedValueOnce({ data: secondPage }) + .mockResolvedValueOnce({ data: thirdPage }); + + const result = await fetchUsers(0); + + expect(mockGetUserList).toHaveBeenCalledTimes(3); + expect(mockGetUserList).toHaveBeenNthCalledWith(1, { + offset: 0, + limit: 500, + }); + expect(mockGetUserList).toHaveBeenNthCalledWith(2, { + offset: 500, + limit: 500, + }); + expect(mockGetUserList).toHaveBeenNthCalledWith(3, { + offset: 1000, + limit: 500, + }); + expect(result).toHaveLength(1150); + expect(mockGetUserList).toHaveBeenCalledTimes(3); + }); + + test('handles empty user list', async () => { + mockGetUserList.mockResolvedValueOnce({ data: [] }); + + const result = await fetchUsers(0); + + expect(mockGetUserList).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(0); + }); + }); + + describe('deleteUsers', () => { + const dateTime = '2024-01-01T12:00:00'; + + test('deletes all users sequentially', async () => { + mockDeleteUser.mockResolvedValue({}); + + const users = [ + { id: 'user_1', firstName: 'John' }, + { id: 'user_2', firstName: 'Jane' }, + { id: 'user_3', firstName: 'Bob' }, + ] as any[]; + + await deleteUsers(users, dateTime); + + expect(mockDeleteUser).toHaveBeenCalledTimes(3); + expect(mockDeleteUser).toHaveBeenNthCalledWith(1, 'user_1'); + expect(mockDeleteUser).toHaveBeenNthCalledWith(2, 'user_2'); + expect(mockDeleteUser).toHaveBeenNthCalledWith(3, 'user_3'); + }); + + test('processes deletions concurrently', async () => { + mockDeleteUser.mockResolvedValue({}); + + const users = [ + { id: 'user_1', firstName: 'John' }, + { id: 'user_2', firstName: 'Jane' }, + { id: 'user_3', firstName: 'Bob' }, + ] as any[]; + + await deleteUsers(users, dateTime); + + // Should delete all users + expect(mockDeleteUser).toHaveBeenCalledTimes(3); + }); + + test('updates progress counter after each deletion', async () => { + mockDeleteUser.mockResolvedValue({}); + + const users = [ + { id: 'user_1', firstName: 'John' }, + { id: 'user_2', firstName: 'Jane' }, + { id: 'user_3', firstName: 'Bob' }, + ] as any[]; + + await deleteUsers(users, dateTime); + + // Verify all deletions completed + expect(mockDeleteUser).toHaveBeenCalledTimes(3); + }); + + test('handles empty user array', async () => { + await deleteUsers([], dateTime); + + expect(mockDeleteUser).not.toHaveBeenCalled(); + }); + + test('continues deletion if one fails and logs error', async () => { + mockDeleteUser + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('Delete failed')) + .mockResolvedValueOnce({}); + + const users = [ + { id: 'user_1', externalId: 'ext_1', firstName: 'John' }, + { id: 'user_2', externalId: 'ext_2', firstName: 'Jane' }, + { id: 'user_3', externalId: 'ext_3', firstName: 'Bob' }, + ] as any[]; + + await deleteUsers(users, dateTime); + + // Should attempt all three deletions + expect(mockDeleteUser).toHaveBeenCalledTimes(3); + + // Should log to both error log and delete log for user_2 + expect(mockDeleteErrorLogger).toHaveBeenCalledTimes(1); + expect(mockDeleteErrorLogger).toHaveBeenCalledWith( + { + userId: 'ext_2', + status: 'error', + errors: [{ message: 'Delete failed', longMessage: 'Delete failed' }], + }, + dateTime + ); + + // Should also log to delete log file + expect(mockDeleteLogger).toHaveBeenCalledTimes(3); // 2 success + 1 error + expect(mockDeleteLogger).toHaveBeenCalledWith( + { userId: 'ext_2', status: 'error', error: 'Delete failed' }, + dateTime + ); + }); + + test('logs errors with user id when externalId is not present', async () => { + mockDeleteUser.mockRejectedValueOnce(new Error('API error')); + + const users = [ + { id: 'user_1', firstName: 'John' }, // no externalId + ] as any[]; + + await deleteUsers(users, dateTime); + + expect(mockDeleteErrorLogger).toHaveBeenCalledWith( + { + userId: 'user_1', + status: 'error', + errors: [{ message: 'API error', longMessage: 'API error' }], + }, + dateTime + ); + + expect(mockDeleteLogger).toHaveBeenCalledWith( + { userId: 'user_1', status: 'error', error: 'API error' }, + dateTime + ); + }); + + test('tracks successful and failed deletions separately', async () => { + mockDeleteUser + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('Error 1')) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('Error 2')); + + const users = [ + { id: 'user_1', firstName: 'John' }, + { id: 'user_2', firstName: 'Jane' }, + { id: 'user_3', firstName: 'Bob' }, + { id: 'user_4', firstName: 'Alice' }, + ] as any[]; + + await deleteUsers(users, dateTime); + + expect(mockDeleteUser).toHaveBeenCalledTimes(4); + expect(mockDeleteErrorLogger).toHaveBeenCalledTimes(2); + expect(mockDeleteLogger).toHaveBeenCalledTimes(4); // All 4 users logged (2 success + 2 error) + }); + }); + + describe('readSettings', () => { + test('reads settings file and returns file path', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ file: 'samples/users.json' }) + ); + + const result = readSettings(); + + expect(result).toBe('samples/users.json'); + expect(mockExistsSync).toHaveBeenCalledWith( + expect.stringContaining('.settings') + ); + expect(mockReadFileSync).toHaveBeenCalledWith( + expect.stringContaining('.settings'), + 'utf-8' + ); + }); + + test('exits with error when .settings file does not exist', () => { + mockExistsSync.mockReturnValue(false); + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + readSettings(); + + expect(mockExit).toHaveBeenCalledWith(1); + mockExit.mockRestore(); + }); + + test('exits with error when .settings file has no file property', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ key: 'authjs' })); + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + readSettings(); + + expect(mockExit).toHaveBeenCalledWith(1); + mockExit.mockRestore(); + }); + }); + + describe('readMigrationFile', () => { + test('reads JSON migration file and returns set of user IDs', async () => { + const mockUsers = [ + { userId: '1', email: 'user1@example.com' }, + { userId: '2', email: 'user2@example.com' }, + { userId: '3', email: 'user3@example.com' }, + ]; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(mockUsers)); + + const result = await readMigrationFile('samples/users.json'); + + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(3); + expect(result.has('1')).toBe(true); + expect(result.has('2')).toBe(true); + expect(result.has('3')).toBe(true); + }); + + test("reads JSON file with 'id' field instead of 'userId'", async () => { + const mockUsers = [ + { id: 'user_1', email: 'user1@example.com' }, + { id: 'user_2', email: 'user2@example.com' }, + ]; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(mockUsers)); + + const result = await readMigrationFile('samples/users.json'); + + expect(result.size).toBe(2); + expect(result.has('user_1')).toBe(true); + expect(result.has('user_2')).toBe(true); + }); + + test('exits with error when migration file does not exist', async () => { + mockExistsSync.mockReturnValue(false); + const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + await readMigrationFile('samples/nonexistent.json'); + + expect(mockExit).toHaveBeenCalledWith(1); + mockExit.mockRestore(); + }); + + test('handles empty user array in JSON file', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify([])); + + const result = await readMigrationFile('samples/empty.json'); + + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(0); + }); + + test('skips users without userId or id field in JSON', async () => { + const mockUsers = [ + { userId: '1', email: 'user1@example.com' }, + { email: 'user2@example.com' }, // no userId or id + { userId: '3', email: 'user3@example.com' }, + ]; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(mockUsers)); + + const result = await readMigrationFile('samples/users.json'); + + expect(result.size).toBe(2); + expect(result.has('1')).toBe(true); + expect(result.has('3')).toBe(true); + }); + }); + + describe('findIntersection', () => { + test('finds users that exist in both Clerk and migration file', () => { + const clerkUsers = [ + { id: 'clerk_1', externalId: '1' }, + { id: 'clerk_2', externalId: '2' }, + { id: 'clerk_3', externalId: '3' }, + { id: 'clerk_4', externalId: '4' }, + ] as any[]; + + const migrationUserIds = new Set(['2', '3', '5']); + + const result = findIntersection(clerkUsers, migrationUserIds); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('clerk_2'); + expect(result[1].id).toBe('clerk_3'); + }); + + test('returns empty array when no users match', () => { + const clerkUsers = [ + { id: 'clerk_1', externalId: '1' }, + { id: 'clerk_2', externalId: '2' }, + ] as any[]; + + const migrationUserIds = new Set(['5', '6']); + + const result = findIntersection(clerkUsers, migrationUserIds); + + expect(result).toHaveLength(0); + }); + + test('ignores Clerk users without externalId', () => { + const clerkUsers = [ + { id: 'clerk_1', externalId: '1' }, + { id: 'clerk_2' }, // no externalId + { id: 'clerk_3', externalId: '3' }, + ] as any[]; + + const migrationUserIds = new Set(['1', '2', '3']); + + const result = findIntersection(clerkUsers, migrationUserIds); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('clerk_1'); + expect(result[1].id).toBe('clerk_3'); + }); + + test('handles empty Clerk users array', () => { + const clerkUsers = [] as any[]; + const migrationUserIds = new Set(['1', '2']); + + const result = findIntersection(clerkUsers, migrationUserIds); + + expect(result).toHaveLength(0); + }); + + test('handles empty migration user IDs set', () => { + const clerkUsers = [ + { id: 'clerk_1', externalId: '1' }, + { id: 'clerk_2', externalId: '2' }, + ] as any[]; + const migrationUserIds = new Set(); + + const result = findIntersection(clerkUsers, migrationUserIds); + + expect(result).toHaveLength(0); + }); + }); + + describe('integration: full delete process', () => { + test('fetches and deletes 750 users across 2 pages', async () => { + const dateTime = '2024-01-01T12:00:00'; + + // Setup pagination mock + const firstPage = Array.from({ length: 500 }, (_, i) => ({ + id: `user_${i}`, + firstName: `User${i}`, + })); + + const secondPage = Array.from({ length: 250 }, (_, i) => ({ + id: `user_${i + 500}`, + firstName: `User${i + 500}`, + })); + + mockGetUserList + .mockResolvedValueOnce({ data: firstPage }) + .mockResolvedValueOnce({ data: secondPage }); + + mockDeleteUser.mockResolvedValue({}); + + // Fetch users + const users = await fetchUsers(0); + expect(users).toHaveLength(750); + expect(mockGetUserList).toHaveBeenCalledTimes(2); + + vi.clearAllMocks(); + + // Delete users + await deleteUsers(users, dateTime); + expect(mockDeleteUser).toHaveBeenCalledTimes(750); + }); + }); +}); diff --git a/src/delete/index.ts b/src/delete/index.ts new file mode 100644 index 0000000..8ee1cf5 --- /dev/null +++ b/src/delete/index.ts @@ -0,0 +1,443 @@ +import 'dotenv/config'; +import { createClerkClient } from '@clerk/backend'; +import type { User } from '@clerk/backend'; +import type { ClerkAPIError } from '@clerk/types'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; +import { + createImportFilePath, + getDateTimeStamp, + getFileType, + getRetryDelay, + tryCatch, +} from '../utils'; +import { env, MAX_RETRIES, RETRY_DELAY_MS } from '../envs-constants'; +import { closeAllStreams, deleteErrorLogger, deleteLogger } from '../logger'; +import * as fs from 'fs'; +import * as path from 'path'; +import csvParser from 'csv-parser'; +import pLimit from 'p-limit'; + +const LIMIT = 500; +const users: User[] = []; +const s = p.spinner(); +let total: number; +let count = 0; +let failed = 0; + +/** + * Reads the .settings file to get the migration source file path + * @returns The file path of the migration source + * @throws Exits the process if .settings file is not found or missing the file property + */ +export const readSettings = (): string => { + const settingsPath = path.join(process.cwd(), '.settings'); + + if (!fs.existsSync(settingsPath)) { + p.log.error( + color.red( + 'No migration has been performed yet. Unable to find .settings file with migration source.' + ) + ); + process.exit(1); + } + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + file?: string; + }; + + if (!settings.file) { + p.log.error( + color.red( + 'No migration source found in .settings file. Please perform a migration first.' + ) + ); + process.exit(1); + } + + return settings.file; +}; + +/** + * Reads a migration file and extracts user IDs + * Supports both JSON and CSV files + * @param filePath - The relative path to the migration file + * @returns A Promise that resolves to a Set of user IDs from the migration file + * @throws Exits the process if the migration file is not found + */ +export const readMigrationFile = async ( + filePath: string +): Promise> => { + const fullPath = createImportFilePath(filePath); + + if (!fs.existsSync(fullPath)) { + p.log.error(color.red(`Migration file not found at: ${fullPath}`)); + process.exit(1); + } + + const type = getFileType(fullPath); + const userIds = new Set(); + + // Handle CSV files + if (type === 'text/csv') { + return new Promise((resolve, reject) => { + fs.createReadStream(fullPath) + .pipe(csvParser({ skipComments: true })) + .on('data', (data: { id?: string }) => { + // CSV files have 'id' column for user IDs + if (data.id) { + userIds.add(data.id); + } + }) + .on('error', (err) => { + p.log.error(color.red(`Error reading CSV file: ${err.message}`)); + reject(err); + }) + .on('end', () => { + resolve(userIds); + }); + }); + } + + // Handle JSON files + const fileContent = fs.readFileSync(fullPath, 'utf-8'); + const users = JSON.parse(fileContent) as Array<{ + userId?: string; + id?: string; + }>; + + // Extract user IDs from the migration file + for (const user of users) { + // JSON files have 'userId' property + if (user.userId) { + userIds.add(user.userId); + } + // Also check for 'id' property as fallback + else if (user.id) { + userIds.add(user.id); + } + } + + return userIds; +}; + +/** + * Recursively fetches all users from Clerk, paginating through results + * @param offset - The offset for pagination (starts at 0) + * @returns An array of all Clerk users + */ +export const fetchUsers = async (offset: number): Promise => { + // Clear the users array on the initial call (offset 0) + if (offset === 0) { + users.length = 0; + } + + const clerk = createClerkClient({ secretKey: env.CLERK_SECRET_KEY }); + const { data } = await clerk.users.getUserList({ offset, limit: LIMIT }); + + if (data.length > 0) { + for (const user of data) { + users.push(user); + } + } + + if (data.length === LIMIT) { + // No delay needed - pagination is sequential by design + return fetchUsers(offset + LIMIT); + } + + return users; +}; + +/** + * Finds the intersection of Clerk users and migration file users + * + * Matches Clerk users whose externalId matches a userId in the migration file. + * This identifies which migrated users exist in Clerk. + * + * @param clerkUsers - Array of users fetched from Clerk + * @param migrationUserIds - Set of user IDs from the migration file + * @returns Array of Clerk users that were part of the migration + */ +export const findIntersection = ( + clerkUsers: User[], + migrationUserIds: Set +) => { + return clerkUsers.filter((user) => { + // Match Clerk user's externalId with migration file's userId + return user.externalId && migrationUserIds.has(user.externalId); + }); +}; + +// Track error messages and counts +const errorCounts = new Map(); + +/** + * Deletes a single user from Clerk with retry logic for rate limits + * + * @param user - The Clerk user to delete + * @param dateTime - Timestamp for error logging + * @param retryCount - Current retry attempt count (default 0) + * @returns A promise that resolves when the user is deleted + */ +const deleteUser = async ( + user: User, + dateTime: string, + retryCount: number = 0 +) => { + const clerk = createClerkClient({ secretKey: env.CLERK_SECRET_KEY }); + const [, error] = await tryCatch(clerk.users.deleteUser(user.id)); + + if (error) { + // Check for rate limit error (429) + const clerkError = error as { + status?: number; + errors?: ClerkAPIError[]; + message?: string; + }; + + if (clerkError.status === 429) { + // Extract Retry-After value from response (in seconds) + const retryAfterSeconds = clerkError.errors?.[0]?.meta?.retryAfter as + | number + | undefined; + + if (retryCount < MAX_RETRIES) { + // Calculate retry delay using shared utility function + const { delayMs, delaySeconds } = getRetryDelay( + retryCount, + retryAfterSeconds, + RETRY_DELAY_MS + ); + + // Log retry attempt + const retryMessage = `Rate limit hit (429), retrying in ${delaySeconds}s (attempt ${retryCount + 1}/${MAX_RETRIES})`; + + deleteErrorLogger( + { + userId: user.externalId || user.id, + status: '429_retry', + errors: [ + { + code: 'rate_limit_retry', + message: retryMessage, + longMessage: retryMessage, + }, + ], + }, + dateTime + ); + + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return deleteUser(user, dateTime, retryCount + 1); + } + + // Max retries exceeded - log as permanent failure + const errorMessage = `Rate limit exceeded after ${MAX_RETRIES} retries`; + failed++; + errorCounts.set(errorMessage, (errorCounts.get(errorMessage) ?? 0) + 1); + + // Log to error log file + deleteErrorLogger( + { + userId: user.externalId || user.id, + status: '429', + errors: [ + { + code: 'rate_limit_exceeded', + message: errorMessage, + longMessage: errorMessage, + }, + ], + }, + dateTime + ); + + // Log to delete log file + deleteLogger( + { + userId: user.externalId || user.id, + status: 'error', + error: errorMessage, + }, + dateTime + ); + } else { + // Non-429 error + failed++; + const errorMessage = clerkError.message || 'Unknown error'; + errorCounts.set(errorMessage, (errorCounts.get(errorMessage) ?? 0) + 1); + + // Log to error log file + deleteErrorLogger( + { + userId: user.externalId || user.id, + status: 'error', + errors: [ + { + message: clerkError.message || 'Unknown error', + longMessage: clerkError.message || 'Unknown error', + }, + ], + }, + dateTime + ); + + // Log to delete log file + deleteLogger( + { + userId: user.externalId || user.id, + status: 'error', + error: errorMessage, + }, + dateTime + ); + } + } else { + count++; + + // Log successful deletion + deleteLogger( + { userId: user.externalId || user.id, status: 'success' }, + dateTime + ); + } + + const processed = count + failed; + s.message( + `Deleting users: [${processed}/${total}] (${count} successful, ${failed} failed)` + ); +}; + +/** + * Deletes an array of users from Clerk + * + * Deletes users concurrently with rate limiting. + * Updates a spinner progress message after each deletion. + * Logs any errors that occur during deletion. + * + * @param users - Array of Clerk users to delete + * @param dateTime - Timestamp for error logging + * @returns A promise that resolves when all users are processed + */ +export const deleteUsers = async (users: User[], dateTime: string) => { + // Reset error counts + errorCounts.clear(); + + s.message(`Deleting users: [0/${total}]`); + + // Set up concurrency limiter + const limit = pLimit(env.CONCURRENCY_LIMIT); + + // Process all users concurrently with the limit + const promises = users.map((user) => limit(() => deleteUser(user, dateTime))); + + await Promise.all(promises); + + // Close all log streams + closeAllStreams(); + + const summaryMessage = + failed > 0 + ? `Deleted ${count} users (${failed} failed)` + : `Deleted ${count} users`; + s.stop(summaryMessage); +}; + +/** + * Displays a formatted summary of the deletion operation + * + * Shows: + * - Total users processed + * - Successful deletions + * - Failed deletions + * - Breakdown of errors by type (wrapped to 75 characters) + */ +const displaySummary = () => { + if (failed === 0) { + // No summary needed if all succeeded + return; + } + + let message = `Total users processed: ${total}\n`; + message += `${color.green('Successfully deleted:')} ${count}\n`; + message += `${color.red('Failed with errors:')} ${failed}`; + + if (errorCounts.size > 0) { + message += `\n\n${color.bold('Error Breakdown:')}\n`; + for (const [error, errorCount] of errorCounts) { + const prefix = `${color.red('•')} ${errorCount} user${errorCount === 1 ? '' : 's'}: `; + message += `${prefix}${error}\n`; + } + } + + p.note(message.trim(), 'Deletion Summary'); +}; + +/** + * Main function to process and delete migrated users + * + * Workflow: + * 1. Reads the migration source file from .settings + * 2. Extracts user IDs from the migration file + * 3. Fetches all users from Clerk + * 4. Finds the intersection (migrated users that exist in Clerk) + * 5. Deletes the intersecting users + * + * @returns A promise that resolves when the deletion process is complete + */ +export const processUsers = async () => { + p.intro( + `${color.bgCyan(color.black('Clerk User Migration Utility - Deleting Migrated Users'))}` + ); + + // Read settings and migration file + const migrationFilePath = readSettings(); + s.start(); + s.message('Reading migration file'); + const migrationUserIds = await readMigrationFile(migrationFilePath); + s.stop(`Found ${migrationUserIds.size} users in migration file`); + + // Fetch Clerk users + s.start(); + s.message('Fetching current user list from Clerk'); + const allClerkUsers = await fetchUsers(0); + s.stop(`Found ${allClerkUsers.length} users in Clerk`); + + // Find intersection + s.start(); + s.message( + 'Finding users to delete (intersection of migrated users and Clerk users)' + ); + const usersToDelete = findIntersection(allClerkUsers, migrationUserIds); + total = usersToDelete.length; + s.stop(`Found ${total} migrated users to delete`); + + if (total === 0) { + p.log.info( + color.yellow('No migrated users found in Clerk. Nothing to delete.') + ); + p.outro('User deletion complete'); + return; + } + + // Delete users + const dateTime = getDateTimeStamp(); + s.start(); + await deleteUsers(usersToDelete, dateTime); + + // Display summary if there were errors + displaySummary(); + + p.outro('User deletion complete'); +}; + +processUsers().catch((error: Error) => { + p.log.error(color.red('\nError during user deletion:')); + p.log.error(color.red(error.message)); + if (error.stack) { + p.log.error(error.stack); + } + process.exit(1); +}); diff --git a/src/envs-constants.test.ts b/src/envs-constants.test.ts new file mode 100644 index 0000000..943f1e8 --- /dev/null +++ b/src/envs-constants.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from 'vitest'; +import { + createEnvSchema, + detectInstanceType, + getDefaultConcurrencyLimit, + getDefaultRateLimit, +} from './envs-constants'; + +describe('envs-constants', () => { + describe('detectInstanceType', () => { + test("returns 'prod' for sk_live_ prefix", () => { + expect( + detectInstanceType('sk_live_abcdefghijklmnopqrstuvwxyz123456') + ).toBe('prod'); + }); + + test("returns 'dev' for sk_test_ prefix", () => { + expect( + detectInstanceType('sk_test_abcdefghijklmnopqrstuvwxyz123456') + ).toBe('dev'); + }); + + test("returns 'dev' for other prefixes", () => { + expect( + detectInstanceType('sk_prod_abcdefghijklmnopqrstuvwxyz123456') + ).toBe('dev'); + expect(detectInstanceType('sk_abcdefghijklmnopqrstuvwxyz123456')).toBe( + 'dev' + ); + }); + + test("returns 'dev' for keys without underscore", () => { + expect(detectInstanceType('somekey')).toBe('dev'); + }); + + test("returns 'dev' for empty string", () => { + expect(detectInstanceType('')).toBe('dev'); + }); + }); + + describe('getDefaultRateLimit', () => { + test('returns 100 requests/second for production', () => { + expect(getDefaultRateLimit('prod')).toBe(100); + }); + + test('returns 10 requests/second for dev', () => { + expect(getDefaultRateLimit('dev')).toBe(10); + }); + }); + + describe('getDefaultConcurrencyLimit', () => { + test('returns ~95% of rate limit for production', () => { + // 100 req/s * 0.095 = 9.5, floored to 9 + expect(getDefaultConcurrencyLimit(100)).toBe(9); + }); + + test('returns ~95% of rate limit for dev', () => { + // 10 req/s * 0.095 = 0.95, max(1, floor(0.95)) = 1 + expect(getDefaultConcurrencyLimit(10)).toBe(1); + }); + + test('returns at least 1 for very low rate limits', () => { + expect(getDefaultConcurrencyLimit(1)).toBe(1); + expect(getDefaultConcurrencyLimit(2)).toBe(1); + }); + + test('rounds down for fractional concurrency', () => { + // 50 req/s * 0.095 = 4.75, floored to 4 + expect(getDefaultConcurrencyLimit(50)).toBe(4); + // 75 req/s * 0.095 = 7.125, floored to 7 + expect(getDefaultConcurrencyLimit(75)).toBe(7); + // 150 req/s * 0.095 = 14.25, floored to 14 + expect(getDefaultConcurrencyLimit(150)).toBe(14); + }); + }); +}); + +describe('createEnvSchema', () => { + test('returns a Zod schema object', () => { + const schema = createEnvSchema(); + expect(schema).toBeDefined(); + expect(typeof schema.safeParse).toBe('function'); + expect(typeof schema.parse).toBe('function'); + }); + + test('automatically uses production defaults for production keys', () => { + const schema = createEnvSchema(); + const result = schema.safeParse({ + CLERK_SECRET_KEY: 'sk_live_abcdefghijklmnopqrstuvwxyz123456', + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.RATE_LIMIT).toBe(100); // Production default + expect(result.data.CONCURRENCY_LIMIT).toBe(9); // 100 * 0.095 = 9.5, floored to 9 + } + }); + + test('automatically uses dev defaults for test keys', () => { + const schema = createEnvSchema(); + const result = schema.safeParse({ + CLERK_SECRET_KEY: 'sk_test_abcdefghijklmnopqrstuvwxyz123456', + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.RATE_LIMIT).toBe(10); // Dev default + expect(result.data.CONCURRENCY_LIMIT).toBe(1); // 10 * 0.095 = 0.95, max(1, floor(0.95)) = 1 + } + }); + + test('allows custom rate limit to override defaults', () => { + const schema = createEnvSchema(); + const result = schema.safeParse({ + CLERK_SECRET_KEY: 'sk_live_abcdefghijklmnopqrstuvwxyz123456', + RATE_LIMIT: '50', + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.RATE_LIMIT).toBe(50); + // 50 * 0.095 = 4.75, floored to 4 + expect(result.data.CONCURRENCY_LIMIT).toBe(4); + } + }); + + test('allows custom concurrency limit to override defaults', () => { + const schema = createEnvSchema(); + const result = schema.safeParse({ + CLERK_SECRET_KEY: 'sk_live_abcdefghijklmnopqrstuvwxyz123456', + CONCURRENCY_LIMIT: '15', + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.RATE_LIMIT).toBe(100); // Production default + expect(result.data.CONCURRENCY_LIMIT).toBe(15); // Custom override + } + }); +}); + +describe('exported env object', () => { + test('env object exists', async () => { + const envModule = await import('./envs-constants'); + expect(envModule.env).toBeDefined(); + }); + + test('env object has required fields with correct types', async () => { + const envModule = await import('./envs-constants'); + + expect(typeof envModule.env.CLERK_SECRET_KEY).toBe('string'); + expect(typeof envModule.env.RATE_LIMIT).toBe('number'); + expect(typeof envModule.env.CONCURRENCY_LIMIT).toBe('number'); + }); +}); + +describe('integration: instance type determines defaults', () => { + test('production instance uses production defaults', () => { + const secretKey = 'sk_live_abcdefghijklmnopqrstuvwxyz123456'; + const instanceType = detectInstanceType(secretKey); + const rateLimit = getDefaultRateLimit(instanceType); + const concurrencyLimit = getDefaultConcurrencyLimit(rateLimit); + + expect(instanceType).toBe('prod'); + expect(rateLimit).toBe(100); + expect(concurrencyLimit).toBe(9); // 100 * 0.095 = 9.5, floored to 9 + + const schema = createEnvSchema(); + const result = schema.safeParse({ CLERK_SECRET_KEY: secretKey }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.RATE_LIMIT).toBe(100); + expect(result.data.CONCURRENCY_LIMIT).toBe(9); + } + }); + + test('dev instance uses dev defaults', () => { + const secretKey = 'sk_test_abcdefghijklmnopqrstuvwxyz123456'; + const instanceType = detectInstanceType(secretKey); + const rateLimit = getDefaultRateLimit(instanceType); + const concurrencyLimit = getDefaultConcurrencyLimit(rateLimit); + + expect(instanceType).toBe('dev'); + expect(rateLimit).toBe(10); + expect(concurrencyLimit).toBe(1); // 10 * 0.095 = 0.95, max(1, floor(0.95)) = 1 + + const schema = createEnvSchema(); + const result = schema.safeParse({ CLERK_SECRET_KEY: secretKey }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.RATE_LIMIT).toBe(10); + expect(result.data.CONCURRENCY_LIMIT).toBe(1); + } + }); +}); diff --git a/src/envs-constants.ts b/src/envs-constants.ts new file mode 100644 index 0000000..feb7a92 --- /dev/null +++ b/src/envs-constants.ts @@ -0,0 +1,120 @@ +import { z } from 'zod'; +import { config } from 'dotenv'; +config(); + +/** + * Detects whether a Clerk instance is production or development based on the secret key + * + * @param secretKey - The Clerk secret key (format: sk_{type}_{random}) + * @returns "prod" if the key contains "live", otherwise "dev" + * @example + * detectInstanceType("sk_live_xxx") // "prod" + * detectInstanceType("sk_test_xxx") // "dev" + */ +export const detectInstanceType = (secretKey: string): 'dev' | 'prod' => { + return secretKey.split('_')[1] === 'live' ? 'prod' : 'dev'; +}; + +/** + * Gets the default rate limit based on instance type + * + * Rate limits (Clerk's documented limits): + * - Production: 1000 requests per 10 seconds = 100 requests/second + * - Dev: 100 requests per 10 seconds = 10 requests/second + * + * @param instanceType - The type of Clerk instance + * @returns The rate limit in requests per second + */ +export const getDefaultRateLimit = (instanceType: 'dev' | 'prod'): number => { + return instanceType === 'prod' ? 100 : 10; +}; + +/** + * Calculates the default concurrency limit based on rate limit + * + * Uses 95% of the rate limit assuming ~100ms average API latency: + * - Production: 100 req/s → 10 concurrent = ~95-100 req/s throughput + * - Dev: 10 req/s → 1 concurrent = ~9-10 req/s throughput + * + * Formula: CONCURRENCY = RATE_LIMIT * 0.095 + * - This assumes 100ms average API response time + * - With X concurrent requests at 100ms each: throughput = X * 10 req/s + * - To get 95 req/s: need 9.5 concurrent + * + * Users can override this via CONCURRENCY_LIMIT in .env to tune performance + * based on their actual API latency and desired throughput. + * + * @param rateLimit - The rate limit in requests per second + * @returns The concurrency limit (number of concurrent requests) + */ +export const getDefaultConcurrencyLimit = (rateLimit: number): number => { + // 95% of rate limit with 100ms latency assumption + return Math.max(1, Math.floor(rateLimit * 0.095)); +}; + +/** + * Creates a Zod schema for environment variable validation with dynamic defaults + * based on the actual CLERK_SECRET_KEY value + * + * @returns A Zod object schema for environment variables + */ +export const createEnvSchema = () => { + return z + .object({ + CLERK_SECRET_KEY: z.string(), + RATE_LIMIT: z.coerce.number().positive().optional(), + CONCURRENCY_LIMIT: z.coerce.number().positive().optional(), + }) + .transform((data) => { + // Dynamically determine instance type from the actual secret key + const instanceType = detectInstanceType(data.CLERK_SECRET_KEY); + + const rateLimit = data.RATE_LIMIT ?? getDefaultRateLimit(instanceType); + const concurrencyLimit = + data.CONCURRENCY_LIMIT ?? getDefaultConcurrencyLimit(rateLimit); + + return { + CLERK_SECRET_KEY: data.CLERK_SECRET_KEY, + RATE_LIMIT: rateLimit, + CONCURRENCY_LIMIT: concurrencyLimit, + }; + }); +}; + +const envSchema = createEnvSchema(); + +/** + * Type representing the validated environment configuration + */ +export type EnvSchema = z.infer; + +const parsed = envSchema.safeParse(process.env); + +if (!parsed.success) { + // Infrastructure error at module load time - occurs before CLI is initialized + // eslint-disable-next-line no-console + console.error('❌ Invalid environment variables:'); + // eslint-disable-next-line no-console + console.error(JSON.stringify(parsed.error.issues, null, 2)); + process.exit(1); +} + +/** + * Validated environment configuration with defaults applied + * + * @property CLERK_SECRET_KEY - Your Clerk secret key + * @property RATE_LIMIT - Rate limit in requests per second (auto-configured based on instance type) + * @property CONCURRENCY_LIMIT - Number of concurrent requests (defaults to ~95% of rate limit, can be overridden in .env) + */ +export const env = parsed.data; + +/** + * Maximum number of retries for rate limit (429) errors + */ +export const MAX_RETRIES = 5; + +/** + * Default delay in milliseconds when retrying after a 429 error (10 seconds) + * Used as a fallback when the response doesn't include a Retry-After header + */ +export const RETRY_DELAY_MS = 10000; diff --git a/src/logger.test.ts b/src/logger.test.ts new file mode 100644 index 0000000..69cf513 --- /dev/null +++ b/src/logger.test.ts @@ -0,0 +1,567 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { + closeAllStreams, + deleteErrorLogger, + deleteLogger, + errorLogger, + importLogger, + validationLogger, +} from './logger'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; + +// Helper to clean up logs directory +const cleanupLogs = () => { + closeAllStreams(); // Close streams before cleanup + if (existsSync('logs')) { + rmSync('logs', { recursive: true }); + } +}; + +// Helper to read NDJSON (newline-delimited JSON) files +const readNDJSON = (filePath: string): unknown[] => { + const content = readFileSync(filePath, 'utf8'); + return content + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)); +}; + +describe('errorLogger', () => { + beforeEach(cleanupLogs); + afterEach(cleanupLogs); + + test('logs a single error to migration log', () => { + const dateTime = 'error-single-test'; + + errorLogger( + { + errors: [ + { + code: '1234', + message: 'isolinear chip failed to initialize', + }, + ], + status: 'error', + userId: '123', + }, + dateTime + ); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log).toHaveLength(1); + expect(log[0]).toEqual({ + type: 'User Creation Error', + userId: '123', + status: 'error', + error: undefined, // longMessage is undefined + }); + }); + + test('logs error with longMessage', () => { + const dateTime = 'error-longmessage-test'; + + errorLogger( + { + errors: [ + { + code: 'form_identifier_exists', + message: 'Email already exists', + longMessage: + 'A user with this email address already exists in the system.', + }, + ], + status: '422', + userId: 'user_abc123', + }, + dateTime + ); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log[0]).toEqual({ + type: 'User Creation Error', + userId: 'user_abc123', + status: '422', + error: 'A user with this email address already exists in the system.', + }); + }); + + test('logs multiple errors from same payload as separate entries', () => { + const dateTime = 'error-multiple-test'; + + errorLogger( + { + errors: [ + { + code: 'invalid_email', + message: 'Invalid email', + longMessage: 'The email address format is invalid.', + }, + { + code: 'invalid_password', + message: 'Invalid password', + longMessage: 'Password does not meet requirements.', + }, + ], + status: '400', + userId: 'user_xyz', + }, + dateTime + ); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log).toHaveLength(2); + expect(log[0].error).toBe('The email address format is invalid.'); + expect(log[1].error).toBe('Password does not meet requirements.'); + }); + + test('appends to existing log file', () => { + const dateTime = 'error-append-test'; + + // First error + errorLogger( + { + errors: [{ code: 'err1', message: 'First error' }], + status: '400', + userId: 'user_1', + }, + dateTime + ); + + // Second error + errorLogger( + { + errors: [{ code: 'err2', message: 'Second error' }], + status: '500', + userId: 'user_2', + }, + dateTime + ); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log).toHaveLength(2); + expect(log[0].userId).toBe('user_1'); + expect(log[1].userId).toBe('user_2'); + }); + + test('handles rate limit error (429)', () => { + const dateTime = 'error-ratelimit-test'; + + errorLogger( + { + errors: [ + { + code: 'rate_limit_exceeded', + message: 'Too many requests', + longMessage: 'Rate limit exceeded. Please try again later.', + }, + ], + status: '429', + userId: 'user_rate', + }, + dateTime + ); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log[0].status).toBe('429'); + expect(log[0].error).toBe('Rate limit exceeded. Please try again later.'); + }); +}); + +describe('validationLogger', () => { + beforeEach(cleanupLogs); + afterEach(cleanupLogs); + + test('logs a validation error to migration log', () => { + const dateTime = 'validation-basic-test'; + + validationLogger( + { + error: 'invalid_type for required field.', + path: ['email'], + id: 'user_123', + row: 5, + }, + dateTime + ); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log).toHaveLength(1); + expect(log[0]).toEqual({ + type: 'Validation Error', + row: 5, + id: 'user_123', + error: 'invalid_type for required field.', + path: ['email'], + }); + }); + + test('logs validation error with nested path', () => { + const dateTime = 'validation-nested-test'; + + validationLogger( + { + error: 'invalid_type for required field.', + path: ['unsafeMetadata', 'customField'], + id: 'user_456', + row: 10, + }, + dateTime + ); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log[0].path).toEqual(['unsafeMetadata', 'customField']); + }); + + test('logs validation error with numeric path (array index)', () => { + const dateTime = 'validation-array-test'; + + validationLogger( + { + error: 'invalid_email for required field.', + path: ['email', 1], + id: 'user_789', + row: 3, + }, + dateTime + ); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log[0].path).toEqual(['email', 1]); + }); + + test('appends multiple validation errors', () => { + const dateTime = 'validation-append-test'; + + validationLogger( + { + error: 'missing userId', + path: ['userId'], + id: 'unknown', + row: 1, + }, + dateTime + ); + + validationLogger( + { + error: 'invalid email format', + path: ['email'], + id: 'user_2', + row: 2, + }, + dateTime + ); + + validationLogger( + { + error: 'invalid passwordHasher', + path: ['passwordHasher'], + id: 'user_3', + row: 3, + }, + dateTime + ); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log).toHaveLength(3); + expect(log[0].row).toBe(1); + expect(log[1].row).toBe(2); + expect(log[2].row).toBe(3); + }); +}); + +describe('importLogger', () => { + beforeEach(cleanupLogs); + afterEach(cleanupLogs); + + test('logs a successful import', () => { + const dateTime = 'import-success-test'; + + importLogger({ userId: 'user_123', status: 'success' }, dateTime); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log).toHaveLength(1); + expect(log[0]).toEqual({ + userId: 'user_123', + status: 'success', + }); + }); + + test('logs a failed import with error', () => { + const dateTime = 'import-error-test'; + + importLogger( + { userId: 'user_456', status: 'error', error: 'Email already exists' }, + dateTime + ); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log).toHaveLength(1); + expect(log[0]).toEqual({ + userId: 'user_456', + status: 'error', + error: 'Email already exists', + }); + }); + + test('logs multiple imports in sequence', () => { + const dateTime = 'import-multiple-test'; + + importLogger({ userId: 'user_1', status: 'success' }, dateTime); + importLogger( + { userId: 'user_2', status: 'error', error: 'Invalid email' }, + dateTime + ); + importLogger({ userId: 'user_3', status: 'success' }, dateTime); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log).toHaveLength(3); + expect(log[0].userId).toBe('user_1'); + expect(log[0].status).toBe('success'); + expect(log[1].userId).toBe('user_2'); + expect(log[1].status).toBe('error'); + expect(log[1].error).toBe('Invalid email'); + expect(log[2].userId).toBe('user_3'); + expect(log[2].status).toBe('success'); + }); +}); + +describe('deleteErrorLogger', () => { + beforeEach(cleanupLogs); + afterEach(cleanupLogs); + + test('logs a single error to user deletion log', () => { + const dateTime = 'delete-error-single-test'; + + deleteErrorLogger( + { + errors: [ + { + code: 'user_not_found', + message: 'User not found', + }, + ], + status: 'error', + userId: 'user_123', + }, + dateTime + ); + + const log = readNDJSON(`logs/user-deletion-${dateTime}.log`); + expect(log).toHaveLength(1); + expect(log[0]).toEqual({ + type: 'User Deletion Error', + userId: 'user_123', + status: 'error', + error: undefined, // longMessage is undefined + }); + }); + + test('logs error with longMessage', () => { + const dateTime = 'delete-error-longmessage-test'; + + deleteErrorLogger( + { + errors: [ + { + code: 'permission_denied', + message: 'Permission denied', + longMessage: 'You do not have permission to delete this user.', + }, + ], + status: '403', + userId: 'user_abc123', + }, + dateTime + ); + + const log = readNDJSON(`logs/user-deletion-${dateTime}.log`); + expect(log[0]).toEqual({ + type: 'User Deletion Error', + userId: 'user_abc123', + status: '403', + error: 'You do not have permission to delete this user.', + }); + }); + + test('logs multiple errors from same payload as separate entries', () => { + const dateTime = 'delete-error-multiple-test'; + + deleteErrorLogger( + { + errors: [ + { + code: 'error_1', + message: 'First error', + longMessage: 'The first error occurred.', + }, + { + code: 'error_2', + message: 'Second error', + longMessage: 'The second error occurred.', + }, + ], + status: '400', + userId: 'user_xyz', + }, + dateTime + ); + + const log = readNDJSON(`logs/user-deletion-${dateTime}.log`); + expect(log).toHaveLength(2); + expect(log[0].error).toBe('The first error occurred.'); + expect(log[1].error).toBe('The second error occurred.'); + }); + + test('appends to existing log file', () => { + const dateTime = 'delete-error-append-test'; + + // First error + deleteErrorLogger( + { + errors: [{ code: 'err1', message: 'First error' }], + status: '400', + userId: 'user_1', + }, + dateTime + ); + + // Second error + deleteErrorLogger( + { + errors: [{ code: 'err2', message: 'Second error' }], + status: '500', + userId: 'user_2', + }, + dateTime + ); + + const log = readNDJSON(`logs/user-deletion-${dateTime}.log`); + expect(log).toHaveLength(2); + expect(log[0].userId).toBe('user_1'); + expect(log[1].userId).toBe('user_2'); + }); +}); + +describe('deleteLogger', () => { + beforeEach(cleanupLogs); + afterEach(cleanupLogs); + + test('logs a successful deletion', () => { + const dateTime = 'delete-success-test'; + + deleteLogger({ userId: 'user_123', status: 'success' }, dateTime); + + const log = readNDJSON(`logs/user-deletion-${dateTime}.log`); + expect(log).toHaveLength(1); + expect(log[0]).toEqual({ + userId: 'user_123', + status: 'success', + }); + }); + + test('logs a failed deletion with error', () => { + const dateTime = 'delete-error-test'; + + deleteLogger( + { userId: 'user_456', status: 'error', error: 'User not found' }, + dateTime + ); + + const log = readNDJSON(`logs/user-deletion-${dateTime}.log`); + expect(log).toHaveLength(1); + expect(log[0]).toEqual({ + userId: 'user_456', + status: 'error', + error: 'User not found', + }); + }); + + test('logs multiple deletions in sequence', () => { + const dateTime = 'delete-multiple-test'; + + deleteLogger({ userId: 'user_1', status: 'success' }, dateTime); + deleteLogger( + { userId: 'user_2', status: 'error', error: 'Permission denied' }, + dateTime + ); + deleteLogger({ userId: 'user_3', status: 'success' }, dateTime); + + const log = readNDJSON(`logs/user-deletion-${dateTime}.log`); + expect(log).toHaveLength(3); + expect(log[0].userId).toBe('user_1'); + expect(log[0].status).toBe('success'); + expect(log[1].userId).toBe('user_2'); + expect(log[1].status).toBe('error'); + expect(log[1].error).toBe('Permission denied'); + expect(log[2].userId).toBe('user_3'); + expect(log[2].status).toBe('success'); + }); +}); + +describe('mixed logging', () => { + beforeEach(cleanupLogs); + afterEach(cleanupLogs); + + test('error and validation logs go to same migration log file', () => { + const dateTime = 'mixed-errors-test'; + + errorLogger( + { + errors: [{ code: 'err', message: 'API error' }], + status: '500', + userId: 'user_1', + }, + dateTime + ); + + validationLogger( + { + error: 'validation failed', + path: ['email'], + id: 'user_2', + row: 5, + }, + dateTime + ); + + const log = readNDJSON(`logs/migration-${dateTime}.log`); + expect(log).toHaveLength(2); + expect(log[0].type).toBe('User Creation Error'); + expect(log[1].type).toBe('Validation Error'); + }); + + test('error logs and import logs go to same migration log file', () => { + const dateTime = 'mixed-combined-test'; + + errorLogger( + { + errors: [ + { + code: 'err', + message: 'API error', + longMessage: 'API error occurred', + }, + ], + status: '500', + userId: 'user_1', + }, + dateTime + ); + + importLogger( + { userId: 'user_1', status: 'error', error: 'API error occurred' }, + dateTime + ); + + importLogger({ userId: 'user_2', status: 'success' }, dateTime); + + const migrationLog = readNDJSON(`logs/migration-${dateTime}.log`); + + expect(migrationLog).toHaveLength(3); + expect(migrationLog[0].type).toBe('User Creation Error'); + expect(migrationLog[1].status).toBe('error'); + expect(migrationLog[2].status).toBe('success'); + }); +}); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..e7481c3 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,154 @@ +import fs from 'fs'; +import path from 'path'; +import type { + DeleteLogEntry, + ErrorLog, + ErrorPayload, + ImportLogEntry, + ValidationErrorPayload, +} from './types'; + +/** + * Ensures a folder exists, creating it if necessary + * @param folderPath - The absolute path to the folder + */ +const confirmOrCreateFolder = (folderPath: string) => { + try { + if (!fs.existsSync(folderPath)) { + fs.mkdirSync(folderPath); + } + } catch (err) { + // Logger infrastructure error - fallback when file system fails + // eslint-disable-next-line no-console + console.error('Error creating directory for logs:', err); + } +}; + +/** + * Gets the absolute path to the logs directory + * @returns The absolute path to the logs folder + */ +const getLogPath = () => path.join(__dirname, '..', 'logs'); + +/** + * Appends an entry to a log file using append writes (NDJSON format) + * Uses synchronous writes to ensure immediate persistence for testing and reliability + * @param filePath - The relative file path within the logs directory + * @param entry - The log entry to append (will be JSON stringified) + */ +function appendToLogFile(filePath: string, entry: unknown) { + try { + const logPath = getLogPath(); + confirmOrCreateFolder(logPath); + const fullPath = `${logPath}/${filePath}`; + + // Use synchronous append to ensure immediate write + // This is more reliable for logging and testing + fs.appendFileSync(fullPath, `${JSON.stringify(entry)}\n`); + } catch (err) { + // Logger infrastructure error - fallback when file system fails + // eslint-disable-next-line no-console + console.error('Error writing to log file:', err); + } +} + +/** + * No-op function for backwards compatibility. + * Previously closed write streams, but now uses synchronous writes. + */ +export function closeAllStreams() { + // No-op - using synchronous writes now +} + +/** + * Generic function to log error payloads with multiple errors + * @param payload - The error payload containing user ID, status, and error details + * @param dateTime - The timestamp for the log file name (format: YYYY-MM-DDTHH:mm:ss) + * @param logFile - The log file name (e.g., 'migration' or 'user-deletion') + * @param errorType - The error type string (e.g., 'User Creation Error') + */ +function logErrorPayload( + payload: ErrorPayload, + dateTime: string, + logFile: string, + errorType: string +) { + for (const err of payload.errors) { + const errorToLog: ErrorLog = { + type: errorType, + userId: payload.userId, + status: payload.status, + error: err.longMessage, + }; + appendToLogFile(`${logFile}-${dateTime}.log`, errorToLog); + } +} + +/** + * Generic function to log simple entries (success/error status) + * @param entry - The log entry containing user ID and status + * @param dateTime - The timestamp for the log file name (format: YYYY-MM-DDTHH:mm:ss) + * @param logFile - The log file name (e.g., 'migration' or 'user-deletion') + */ +function logEntry( + entry: ImportLogEntry | DeleteLogEntry, + dateTime: string, + logFile: string +) { + appendToLogFile(`${logFile}-${dateTime}.log`, entry); +} + +/** + * Logs user creation errors from the Clerk API + * @param payload - The error payload containing user ID, status, and error details + * @param dateTime - The timestamp for the log file name (format: YYYY-MM-DDTHH:mm:ss) + */ +export const errorLogger = (payload: ErrorPayload, dateTime: string) => { + logErrorPayload(payload, dateTime, 'migration', 'User Creation Error'); +}; + +/** + * Logs validation errors that occur during user data transformation + * @param payload - The validation error payload containing row, ID, error message, and field path + * @param dateTime - The timestamp for the log file name (format: YYYY-MM-DDTHH:mm:ss) + */ +export const validationLogger = ( + payload: ValidationErrorPayload, + dateTime: string +) => { + const error = { + type: 'Validation Error', + row: payload.row, + id: payload.id, + error: payload.error, + path: payload.path, + }; + appendToLogFile(`migration-${dateTime}.log`, error); +}; + +/** + * Logs successful user imports and errors + * @param entry - The import log entry containing user ID and timestamp + * @param dateTime - The timestamp for the log file name (format: YYYY-MM-DDTHH:mm:ss) + */ +export const importLogger = (entry: ImportLogEntry, dateTime: string) => { + logEntry(entry, dateTime, 'migration'); +}; + +/** + * Logs user deletion errors from the Clerk API + * @param payload - The error payload containing user ID, status, and error details + * @param dateTime - The timestamp for the log file name (format: YYYY-MM-DDTHH:mm:ss) + */ +export const deleteErrorLogger = (payload: ErrorPayload, dateTime: string) => { + logErrorPayload(payload, dateTime, 'user-deletion', 'User Deletion Error'); +}; + +/** + * Logs user deletion attempts + * @param entry - The delete log entry containing user ID and status + * @param dateTime - The timestamp for the log file name (format: YYYY-MM-DDTHH:mm:ss) + */ +export const deleteLogger = (entry: DeleteLogEntry, dateTime: string) => { + logEntry(entry, dateTime, 'user-deletion'); +}; diff --git a/src/migrate/cli.test.ts b/src/migrate/cli.test.ts new file mode 100644 index 0000000..63b6ef4 --- /dev/null +++ b/src/migrate/cli.test.ts @@ -0,0 +1,1045 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { + analyzeFields, + detectInstanceType, + displayIdentifierAnalysis, + displayOtherFieldsAnalysis, + formatCount, + hasValue, + loadRawUsers, + loadSettings, + saveSettings, +} from './cli'; + +// Mock modules +vi.mock('fs', async () => { + const actualFs = await import('fs'); + return { + default: { + ...actualFs.default, + existsSync: vi.fn(actualFs.existsSync), + readFileSync: vi.fn(actualFs.readFileSync), + writeFileSync: vi.fn(actualFs.writeFileSync), + }, + ...actualFs, + existsSync: vi.fn(actualFs.existsSync), + readFileSync: vi.fn(actualFs.readFileSync), + writeFileSync: vi.fn(actualFs.writeFileSync), + }; +}); +vi.mock('@clack/prompts', () => ({ + note: vi.fn(), + spinner: vi.fn(() => ({ + start: vi.fn(), + stop: vi.fn(), + message: vi.fn(), + })), +})); +vi.mock('picocolors', () => ({ + default: { + bold: vi.fn((s) => s), + dim: vi.fn((s) => s), + green: vi.fn((s) => s), + red: vi.fn((s) => s), + yellow: vi.fn((s) => s), + blue: vi.fn((s) => s), + cyan: vi.fn((s) => s), + reset: vi.fn((s) => s), + }, +})); + +// Import the mocked module to get access to the mock +import * as p from '@clack/prompts'; + +// Create a module mock for envs-constants +let mockSecretKey = 'sk_test_mockkey'; + +vi.mock('../envs-constants', () => ({ + env: { + get CLERK_SECRET_KEY() { + return mockSecretKey; + }, + }, +})); + +// Mock the utils module +vi.mock('../utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createImportFilePath: vi.fn((file: string) => file), + getFileType: vi.fn((file: string) => { + if (file.endsWith('.csv')) return 'text/csv'; + if (file.endsWith('.json')) return 'application/json'; + return 'unknown'; + }), + checkIfFileExists: vi.fn(() => true), + }; +}); + +// ============================================================================ +// detectInstanceType tests +// ============================================================================ + +describe('detectInstanceType', () => { + beforeEach(() => { + mockSecretKey = 'sk_test_mockkey'; + }); + + test('detects dev instance from sk_test_ prefix', () => { + mockSecretKey = 'sk_test_abcdefghijklmnopqrstuvwxyz123456'; + const result = detectInstanceType(); + expect(result).toBe('dev'); + }); + + test('detects prod instance from sk_live_ prefix', () => { + mockSecretKey = 'sk_live_abcdefghijklmnopqrstuvwxyz123456'; + const result = detectInstanceType(); + expect(result).toBe('prod'); + }); + + test('detects prod instance from other prefixes', () => { + mockSecretKey = 'sk_prod_abcdefghijklmnopqrstuvwxyz123456'; + const result = detectInstanceType(); + expect(result).toBe('prod'); + }); + + test('detects prod instance from sk_ without test', () => { + mockSecretKey = 'sk_abcdefghijklmnopqrstuvwxyz123456'; + const result = detectInstanceType(); + expect(result).toBe('prod'); + }); +}); + +// ============================================================================ +// loadSettings and saveSettings tests +// ============================================================================ + +describe('loadSettings', () => { + const mockSettingsPath = path.join(process.cwd(), '.settings'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('loads settings from .settings file when it exists', () => { + const mockSettings = { key: 'clerk', file: 'users.json' }; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockSettings)); + + const result = loadSettings(); + + expect(fs.existsSync).toHaveBeenCalledWith(mockSettingsPath); + expect(fs.readFileSync).toHaveBeenCalledWith(mockSettingsPath, 'utf-8'); + expect(result).toEqual(mockSettings); + }); + + test('returns empty object when .settings file does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = loadSettings(); + + expect(fs.existsSync).toHaveBeenCalledWith(mockSettingsPath); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(result).toEqual({}); + }); + + test('returns empty object when .settings file is corrupted', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json'); + + const result = loadSettings(); + + expect(result).toEqual({}); + }); + + test('returns empty object when .settings file cannot be read', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const result = loadSettings(); + + expect(result).toEqual({}); + }); + + test('returns empty object when JSON.parse fails', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('not json at all'); + + const result = loadSettings(); + + expect(result).toEqual({}); + }); +}); + +describe('saveSettings', () => { + const mockSettingsPath = path.join(process.cwd(), '.settings'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('writes settings to .settings file', () => { + const settings = { key: 'clerk', file: 'users.json', offset: '10' }; + vi.mocked(fs.writeFileSync).mockImplementation(() => {}); + + saveSettings(settings); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + mockSettingsPath, + JSON.stringify(settings, null, 2) + ); + }); + + test('silently fails when unable to write file', () => { + const settings = { key: 'clerk', file: 'users.json' }; + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw new Error('Permission denied'); + }); + + // Should not throw + expect(() => saveSettings(settings)).not.toThrow(); + }); + + test('formats JSON with 2-space indentation', () => { + const settings = { key: 'clerk', file: 'users.json', offset: '0' }; + vi.mocked(fs.writeFileSync).mockImplementation(() => {}); + + saveSettings(settings); + + const expectedJson = JSON.stringify(settings, null, 2); + expect(fs.writeFileSync).toHaveBeenCalledWith( + mockSettingsPath, + expectedJson + ); + }); +}); + +// ============================================================================ +// hasValue tests +// ============================================================================ + +describe('hasValue', () => { + test('returns false for undefined', () => { + expect(hasValue(undefined)).toBe(false); + }); + + test('returns false for null', () => { + expect(hasValue(null)).toBe(false); + }); + + test('returns false for empty string', () => { + expect(hasValue('')).toBe(false); + }); + + test('returns false for empty array', () => { + expect(hasValue([])).toBe(false); + }); + + test('returns true for non-empty string', () => { + expect(hasValue('hello')).toBe(true); + }); + + test('returns true for number 0', () => { + expect(hasValue(0)).toBe(true); + }); + + test('returns true for boolean false', () => { + expect(hasValue(false)).toBe(true); + }); + + test('returns true for non-empty array', () => { + expect(hasValue([1, 2, 3])).toBe(true); + }); + + test('returns true for array with one element', () => { + expect(hasValue(['item'])).toBe(true); + }); + + test('returns true for empty object', () => { + expect(hasValue({})).toBe(true); + }); + + test('returns true for object with properties', () => { + expect(hasValue({ key: 'value' })).toBe(true); + }); + + test('returns true for string with whitespace', () => { + expect(hasValue(' ')).toBe(true); + }); +}); + +// ============================================================================ +// analyzeFields tests +// ============================================================================ + +describe('analyzeFields', () => { + test('returns empty analysis for empty user array', () => { + const result = analyzeFields([]); + + expect(result).toEqual({ + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 0, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 0, + }, + totalUsers: 0, + fieldCounts: {}, + }); + }); + + test('counts verified emails correctly (email field)', () => { + const users = [ + { userId: '1', email: 'test1@example.com' }, + { userId: '2', email: 'test2@example.com' }, + { userId: '3' }, // no email + ]; + + const result = analyzeFields(users); + + expect(result.identifiers.verifiedEmails).toBe(2); + expect(result.identifiers.hasAnyIdentifier).toBe(2); + }); + + test('counts verified emails correctly (emailAddresses field)', () => { + const users = [ + { userId: '1', emailAddresses: ['test1@example.com'] }, + { userId: '2', emailAddresses: ['test2@example.com'] }, + { userId: '3' }, // no email + ]; + + const result = analyzeFields(users); + + expect(result.identifiers.verifiedEmails).toBe(2); + }); + + test('counts verified emails when either email or emailAddresses is present', () => { + const users = [ + { userId: '1', email: 'test1@example.com' }, + { userId: '2', emailAddresses: ['test2@example.com'] }, + { + userId: '3', + email: 'test3@example.com', + emailAddresses: ['test3@example.com'], + }, + ]; + + const result = analyzeFields(users); + + expect(result.identifiers.verifiedEmails).toBe(3); + }); + + test('counts unverified emails correctly', () => { + const users = [ + { + userId: '1', + email: 'verified@example.com', + unverifiedEmailAddresses: ['unverified@example.com'], + }, + { userId: '2', unverifiedEmailAddresses: ['unverified2@example.com'] }, + { userId: '3', email: 'test@example.com' }, // no unverified + ]; + + const result = analyzeFields(users); + + expect(result.identifiers.unverifiedEmails).toBe(2); + }); + + test('counts verified phones correctly (phone field)', () => { + const users = [ + { userId: '1', phone: '+1234567890' }, + { userId: '2', phone: '+0987654321' }, + { userId: '3' }, // no phone + ]; + + const result = analyzeFields(users); + + expect(result.identifiers.verifiedPhones).toBe(2); + expect(result.identifiers.hasAnyIdentifier).toBe(2); + }); + + test('counts verified phones correctly (phoneNumbers field)', () => { + const users = [ + { userId: '1', phoneNumbers: ['+1234567890'] }, + { userId: '2', phoneNumbers: ['+0987654321'] }, + ]; + + const result = analyzeFields(users); + + expect(result.identifiers.verifiedPhones).toBe(2); + }); + + test('counts unverified phones correctly', () => { + const users = [ + { + userId: '1', + phone: '+1234567890', + unverifiedPhoneNumbers: ['+9999999999'], + }, + { userId: '2', unverifiedPhoneNumbers: ['+8888888888'] }, + { userId: '3', phone: '+1234567890' }, // no unverified + ]; + + const result = analyzeFields(users); + + expect(result.identifiers.unverifiedPhones).toBe(2); + }); + + test('counts usernames correctly', () => { + const users = [ + { userId: '1', username: 'user1', email: 'test@example.com' }, + { userId: '2', username: 'user2', email: 'test2@example.com' }, + { userId: '3', email: 'test3@example.com' }, // no username + ]; + + const result = analyzeFields(users); + + expect(result.identifiers.username).toBe(2); + }); + + test('counts users with at least one identifier', () => { + const users = [ + { userId: '1', email: 'test1@example.com' }, + { userId: '2', phone: '+1234567890' }, + { userId: '3', username: 'user3', email: 'test3@example.com' }, + { userId: '4' }, // no identifiers + ]; + + const result = analyzeFields(users); + + expect(result.identifiers.hasAnyIdentifier).toBe(3); + }); + + test('does not count unverified identifiers toward hasAnyIdentifier', () => { + const users = [ + { userId: '1', unverifiedEmailAddresses: ['test@example.com'] }, + { userId: '2', unverifiedPhoneNumbers: ['+1234567890'] }, + ]; + + const result = analyzeFields(users); + + expect(result.identifiers.hasAnyIdentifier).toBe(0); + }); + + test('identifies fields present on all users', () => { + const users = [ + { + userId: '1', + firstName: 'John', + lastName: 'Doe', + email: 'test@example.com', + }, + { + userId: '2', + firstName: 'Jane', + lastName: 'Smith', + email: 'test2@example.com', + }, + { + userId: '3', + firstName: 'Bob', + lastName: 'Johnson', + email: 'test3@example.com', + }, + ]; + + const result = analyzeFields(users); + + expect(result.presentOnAll).toContain('First Name'); + expect(result.presentOnAll).toContain('Last Name'); + expect(result.presentOnSome).not.toContain('First Name'); + expect(result.presentOnSome).not.toContain('Last Name'); + }); + + test('identifies fields present on some users', () => { + const users = [ + { userId: '1', firstName: 'John', email: 'test@example.com' }, + { userId: '2', lastName: 'Smith', email: 'test2@example.com' }, + { userId: '3', email: 'test3@example.com' }, + ]; + + const result = analyzeFields(users); + + expect(result.presentOnSome).toContain('First Name'); + expect(result.presentOnSome).toContain('Last Name'); + expect(result.presentOnAll).not.toContain('First Name'); + expect(result.presentOnAll).not.toContain('Last Name'); + }); + + test('analyzes password field correctly', () => { + const users = [ + { userId: '1', password: 'hash1', email: 'test@example.com' }, + { userId: '2', password: 'hash2', email: 'test2@example.com' }, + { userId: '3', email: 'test3@example.com' }, + ]; + + const result = analyzeFields(users); + + expect(result.presentOnSome).toContain('Password'); + }); + + test('analyzes totpSecret field correctly', () => { + const users = [ + { userId: '1', totpSecret: 'secret1', email: 'test@example.com' }, + { userId: '2', email: 'test2@example.com' }, + ]; + + const result = analyzeFields(users); + + expect(result.presentOnSome).toContain('TOTP Secret'); + }); + + test('returns correct totalUsers count', () => { + const users = [ + { userId: '1', email: 'test@example.com' }, + { userId: '2', email: 'test2@example.com' }, + { userId: '3', email: 'test3@example.com' }, + ]; + + const result = analyzeFields(users); + + expect(result.totalUsers).toBe(3); + }); + + test('handles users with all identifier types', () => { + const users = [ + { + userId: '1', + email: 'test@example.com', + phone: '+1234567890', + username: 'testuser', + unverifiedEmailAddresses: ['unverified@example.com'], + unverifiedPhoneNumbers: ['+9999999999'], + }, + ]; + + const result = analyzeFields(users); + + expect(result.identifiers.verifiedEmails).toBe(1); + expect(result.identifiers.unverifiedEmails).toBe(1); + expect(result.identifiers.verifiedPhones).toBe(1); + expect(result.identifiers.unverifiedPhones).toBe(1); + expect(result.identifiers.username).toBe(1); + expect(result.identifiers.hasAnyIdentifier).toBe(1); + }); + + test('ignores empty string values in hasValue check', () => { + const users = [ + { + userId: '1', + firstName: '', + lastName: 'Doe', + email: 'test@example.com', + }, + { + userId: '2', + firstName: 'Jane', + lastName: '', + email: 'test2@example.com', + }, + ]; + + const result = analyzeFields(users); + + expect(result.presentOnSome).toContain('First Name'); + expect(result.presentOnSome).toContain('Last Name'); + expect(result.presentOnAll).not.toContain('First Name'); + expect(result.presentOnAll).not.toContain('Last Name'); + }); + + test('ignores empty arrays in hasValue check', () => { + const users = [ + { userId: '1', email: 'test@example.com', emailAddresses: [] }, + { userId: '2', phone: '+1234567890', phoneNumbers: [] }, + ]; + + const result = analyzeFields(users); + + // Email should still be counted because email field is present + expect(result.identifiers.verifiedEmails).toBe(1); + expect(result.identifiers.verifiedPhones).toBe(1); + }); +}); + +// ============================================================================ +// formatCount 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'); + }); + + test('returns "No users have {label}" when count is 0', () => { + const result = formatCount(0, 10, 'email'); + expect(result).toBe('No users have email'); + }); + + 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('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('handles large numbers', () => { + const result = formatCount(1234, 5678, 'verified emails'); + expect(result).toBe('1234 of 5678 users have verified emails'); + }); + + test('handles count equal to total of 1', () => { + const result = formatCount(1, 1, 'phone number'); + expect(result).toBe('All users have phone number'); + }); +}); + +// ============================================================================ +// 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 = [ + { + _id: { $oid: 'auth0123' }, + email: 'john@example.com', + email_verified: true, + username: 'johndoe', + given_name: 'John', + family_name: 'Doe', + }, + ]; + + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockJsonData)); + + const result = await loadRawUsers('users.json', 'auth0'); + + // transformKeys now supports nested path extraction via dot notation + // postTransform removes emailVerified after processing + expect(result[0]).toEqual({ + userId: 'auth0123', + email: 'john@example.com', + username: 'johndoe', + firstName: 'John', + lastName: 'Doe', + }); + }); + + 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', + }); + }); +}); + +// ============================================================================ +// 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, + }; + + 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, + }; + + // 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, + }; + + // 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, + }; + + // Should not throw + expect(() => displayIdentifierAnalysis(analysis)).not.toThrow(); + }); +}); + +// ============================================================================ +// 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, + }; + + 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'); + }); +}); diff --git a/src/migrate/cli.ts b/src/migrate/cli.ts new file mode 100644 index 0000000..d3c84da --- /dev/null +++ b/src/migrate/cli.ts @@ -0,0 +1,780 @@ +import * as p from '@clack/prompts'; +import color from 'picocolors'; +import fs from 'fs'; +import path from 'path'; +import csvParser from 'csv-parser'; +import { transformers } from './transformers'; +import { + checkIfFileExists, + createImportFilePath, + getFileType, + transformKeys as transformKeysFromFunctions, + tryCatch, +} from '../utils'; +import { env } from '../envs-constants'; + +const SETTINGS_FILE = '.settings'; + +type Settings = { + key?: string; + file?: string; +}; + +const DEV_USER_LIMIT = 500; + +/** + * Detects whether the Clerk instance is development or production based on the secret key + * + * @returns "dev" if the secret key starts with "sk_test_", otherwise "prod" + */ +export const detectInstanceType = (): 'dev' | 'prod' => { + const secretKey = env.CLERK_SECRET_KEY; + if (secretKey.startsWith('sk_test_')) { + return 'dev'; + } + return 'prod'; +}; + +// Fields to analyze for the import (non-identifier fields) +const ANALYZED_FIELDS = [ + { key: 'firstName', label: 'First Name' }, + { key: 'lastName', label: 'Last Name' }, + { key: 'password', label: 'Password' }, + { key: 'totpSecret', label: 'TOTP Secret' }, +]; + +type IdentifierCounts = { + verifiedEmails: number; + unverifiedEmails: number; + verifiedPhones: number; + unverifiedPhones: number; + username: number; + hasAnyIdentifier: number; +}; + +type FieldAnalysis = { + presentOnAll: string[]; + presentOnSome: string[]; + identifiers: IdentifierCounts; + totalUsers: number; + fieldCounts: Record; +}; + +/** + * Loads saved settings from the .settings file in the current directory + * + * Reads previously saved migration parameters to use as defaults in the CLI. + * Returns an empty object if the file doesn't exist or is corrupted. + * + * @returns The saved settings object with key and file properties + */ +export const loadSettings = (): Settings => { + try { + const settingsPath = path.join(process.cwd(), SETTINGS_FILE); + if (fs.existsSync(settingsPath)) { + const content = fs.readFileSync(settingsPath, 'utf-8'); + return JSON.parse(content) as Settings; + } + } catch { + // If settings file is corrupted or unreadable, return empty settings + } + return {}; +}; + +/** + * Saves migration settings to the .settings file in the current directory + * + * Persists the current migration parameters (transformer key, file path) + * so they can be used as defaults in future runs. Fails silently if unable to write. + * + * @param settings - The settings object to save + */ +export const saveSettings = (settings: Settings): void => { + try { + const settingsPath = path.join(process.cwd(), SETTINGS_FILE); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + } catch { + // Silently fail if we can't write settings + } +}; + +/** + * Loads and transforms users from a file without validation + * + * Reads users from JSON or CSV files and applies the transformer's field transformations + * and postTransform logic. Used for analyzing file contents before migration. + * Does not validate against the schema. + * + * @param file - The file path to load users from + * @param transformerKey - The transformer key identifying which platform to migrate from + * @returns Array of transformed user objects (not validated) + * @throws Error if transformer is not found for the given key + */ +export const loadRawUsers = async ( + file: string, + transformerKey: string +): Promise[]> => { + const filePath = createImportFilePath(file); + const type = getFileType(filePath); + const transformer = transformers.find((h) => h.key === transformerKey); + + if (!transformer) { + throw new Error(`Transformer not found for key: ${transformerKey}`); + } + + const transformUser = ( + data: Record + ): Record => { + const transformed = transformKeysFromFunctions(data, transformer); + // Apply postTransform if defined + if ( + 'postTransform' in transformer && + typeof transformer.postTransform === 'function' + ) { + transformer.postTransform(transformed); + } + return transformed; + }; + + if (type === 'text/csv') { + return new Promise((resolve, reject) => { + const users: Record[] = []; + fs.createReadStream(filePath) + .pipe(csvParser({ skipComments: true })) + .on('data', (data: Record) => + users.push(transformUser(data)) + ) + .on('error', (err) => reject(err)) + .on('end', () => resolve(users)); + }); + } + const rawUsers = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record< + string, + unknown + >[]; + return rawUsers.map((data) => transformUser(data)); +}; + +/** + * Checks if a value exists and is not empty + * + * Returns false for undefined, null, empty strings, and empty arrays. + * Returns true for all other values including 0, false, and non-empty objects. + * + * @param value - The value to check + * @returns true if the value has meaningful content, false otherwise + */ +export const hasValue = (value: unknown): boolean => { + if (value === undefined || value === null || value === '') return false; + if (Array.isArray(value)) return value.length > 0; + return true; +}; + +/** + * Analyzes user data to determine field presence and identifier coverage + * + * Examines all users to count: + * - How many users have each field (firstName, lastName, password, totpSecret) + * - Identifier coverage (verified/unverified emails and phones, usernames) + * - Whether all users have at least one valid identifier + * + * Used to provide feedback about Dashboard configuration requirements. + * + * @param users - Array of user objects to analyze + * @returns Field analysis object with counts and identifier statistics + */ +export function analyzeFields(users: Record[]): FieldAnalysis { + const totalUsers = users.length; + + if (totalUsers === 0) { + return { + presentOnAll: [], + presentOnSome: [], + identifiers: { + verifiedEmails: 0, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 0, + }, + totalUsers: 0, + fieldCounts: {}, + }; + } + + const fieldCounts: Record = {}; + const identifiers: IdentifierCounts = { + verifiedEmails: 0, + unverifiedEmails: 0, + verifiedPhones: 0, + unverifiedPhones: 0, + username: 0, + hasAnyIdentifier: 0, + }; + + // Count how many users have each field + for (const user of users) { + // Count non-identifier fields + for (const field of ANALYZED_FIELDS) { + if (hasValue(user[field.key])) { + fieldCounts[field.key] = (fieldCounts[field.key] || 0) + 1; + } + } + + // Count consolidated identifier fields + const hasVerifiedEmail = + hasValue(user.email) || hasValue(user.emailAddresses); + const hasUnverifiedEmail = hasValue(user.unverifiedEmailAddresses); + const hasVerifiedPhone = + hasValue(user.phone) || hasValue(user.phoneNumbers); + const hasUnverifiedPhone = hasValue(user.unverifiedPhoneNumbers); + const hasUsername = hasValue(user.username); + + if (hasVerifiedEmail) identifiers.verifiedEmails++; + if (hasUnverifiedEmail) identifiers.unverifiedEmails++; + if (hasVerifiedPhone) identifiers.verifiedPhones++; + if (hasUnverifiedPhone) identifiers.unverifiedPhones++; + if (hasUsername) identifiers.username++; + + // Check if user has at least one valid identifier + if (hasVerifiedEmail || hasVerifiedPhone || hasUsername) { + identifiers.hasAnyIdentifier++; + } + } + + const presentOnAll: string[] = []; + const presentOnSome: string[] = []; + + for (const field of ANALYZED_FIELDS) { + const count = fieldCounts[field.key] || 0; + if (count === totalUsers) { + presentOnAll.push(field.label); + } else if (count > 0) { + presentOnSome.push(field.label); + } + } + + return { presentOnAll, presentOnSome, identifiers, totalUsers, fieldCounts }; +} + +/** + * Formats a count statistic into a human-readable string + * + * @param count - The number of users who have the field + * @param total - The total number of users + * @param label - The label for the field + * @returns A formatted string like "All users have...", "No users have...", or "X of Y users have..." + */ +export function formatCount( + count: number, + total: number, + label: string +): string { + if (count === total) { + return `All users have ${label}`; + } else if (count === 0) { + return `No users have ${label}`; + } + return `${count} of ${total} users have ${label}`; +} + +/** + * Displays identifier analysis and Dashboard configuration guidance + * + * Shows: + * - Count of users with each identifier type (verified emails, verified phones, usernames) + * - Count of users with unverified identifiers (if any) + * - Whether all users have at least one valid identifier + * - Dashboard configuration recommendations (required vs optional identifiers) + * + * Uses color coding: green for complete coverage, yellow for partial, red for missing. + * + * @param analysis - The field analysis results + */ +export function displayIdentifierAnalysis(analysis: FieldAnalysis): void { + const { identifiers, totalUsers } = analysis; + + let identifierMessage = ''; + + // Show counts for each identifier type + identifierMessage += color.bold('Identifier Analysis:\n'); + + // Helper to get the correct icon based on coverage + const getIcon = (count: number, total: number): string => { + if (count === total) return color.green('●'); + if (count > 0) return color.yellow('○'); + return color.red('○'); + }; + + identifierMessage += ` ${getIcon(identifiers.verifiedEmails, totalUsers)} ${formatCount(identifiers.verifiedEmails, totalUsers, 'verified emails')}\n`; + identifierMessage += ` ${getIcon(identifiers.verifiedPhones, totalUsers)} ${formatCount(identifiers.verifiedPhones, totalUsers, 'verified phone numbers')}\n`; + identifierMessage += ` ${getIcon(identifiers.username, totalUsers)} ${formatCount(identifiers.username, totalUsers, 'a username')}\n`; + + // Show unverified counts if present + if (identifiers.unverifiedEmails > 0) { + identifierMessage += ` ${color.dim('○')} ${formatCount(identifiers.unverifiedEmails, totalUsers, 'unverified emails')}\n`; + } + if (identifiers.unverifiedPhones > 0) { + identifierMessage += ` ${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 += color.bold('Dashboard Configuration:\n'); + + const requiredIdentifiers: string[] = []; + const optionalIdentifiers: string[] = []; + + if (identifiers.verifiedEmails === totalUsers) { + requiredIdentifiers.push('email'); + } else if (identifiers.verifiedEmails > 0) { + optionalIdentifiers.push('email'); + } + + if (identifiers.verifiedPhones === totalUsers) { + requiredIdentifiers.push('phone'); + } else if (identifiers.verifiedPhones > 0) { + optionalIdentifiers.push('phone'); + } + + if (identifiers.username === totalUsers) { + requiredIdentifiers.push('username'); + } else if (identifiers.username > 0) { + optionalIdentifiers.push('username'); + } + + if (requiredIdentifiers.length > 0) { + identifierMessage += ` ${color.green('●')} Enable and ${color.bold('require')} ${requiredIdentifiers.join(', ')} in the Dashboard\n`; + } + if (optionalIdentifiers.length > 0) { + identifierMessage += ` ${color.yellow('○')} Enable ${optionalIdentifiers.join(', ')} in the Dashboard (do not require)\n`; + } + + p.note(identifierMessage.trim(), 'Identifiers'); +} + +/** + * Displays password analysis and prompts for migration preference + * + * Shows how many users have passwords and provides Dashboard configuration guidance. + * If some users lack passwords, prompts whether to migrate those users anyway. + * If no users have passwords, returns immediately without displaying anything. + * + * @param analysis - The field analysis results + * @returns true if users without passwords should be migrated (skipPasswordRequirement), + * false if all users have passwords, + * null if the user cancelled + */ +export async function displayPasswordAnalysis( + analysis: FieldAnalysis +): Promise { + const { totalUsers, fieldCounts } = analysis; + const usersWithPasswords = fieldCounts.password || 0; + + // If no users have passwords, show message and skip password section + if (usersWithPasswords === 0) { + p.note(`${color.dim('○')} No users have passwords`, 'Password'); + return true; + } + + let passwordMessage = ''; + + if (usersWithPasswords === totalUsers) { + passwordMessage += `${color.green('●')} All users have passwords\n`; + } else { + passwordMessage += `${color.yellow('○')} ${usersWithPasswords} of ${totalUsers} users have passwords\n`; + } + + passwordMessage += '\n'; + passwordMessage += color.bold('Dashboard Configuration:\n'); + passwordMessage += ` ${color.green('●')} Enable Password in the Dashboard\n`; + + p.note(passwordMessage.trim(), 'Password'); + + // Ask if user wants to migrate users without passwords + if (usersWithPasswords < totalUsers) { + const migrateWithoutPassword = await p.confirm({ + message: "Do you want to migrate users who don't have a password?", + initialValue: true, + }); + + if (p.isCancel(migrateWithoutPassword)) { + return null; // User cancelled + } + + return migrateWithoutPassword; + } + + return false; // All users have passwords, no need for skipPasswordRequirement +} + +/** + * Displays user model analysis (first/last name) and Dashboard configuration guidance + * + * Shows how many users have first and last names and provides recommendations + * for Dashboard configuration (required vs optional vs disabled). + * + * @param analysis - The field analysis results + * @returns true if users have name data and confirmation is needed, false otherwise + */ +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/or last names\n`; + } else { + nameMessage += `${color.dim('○')} No users have first or last names\n`; + } + + nameMessage += '\n'; + nameMessage += color.bold('Dashboard Configuration:\n'); + + if (usersWithBothNames === totalUsers) { + nameMessage += ` ${color.green('●')} First and last name must be enabled in the Dashboard and could be required\n`; + } else if (someUsersHaveNames) { + nameMessage += ` ${color.yellow('○')} First and last name must be enabled in the Dashboard but not required\n`; + } else { + nameMessage += ` ${color.dim('○')} First and last name could be enabled or disabled in the Dashboard but cannot be required\n`; + } + + p.note(nameMessage.trim(), 'User Model'); + + // Return true if confirmation is needed (when users have name data) + return someUsersHaveNames; +}; + +/** + * 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) + ); + + let fieldsMessage = ''; + + 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)}`; + } + } + + 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.' + ); + for (const field of filteredPresentOnSome) { + fieldsMessage += `\n ${color.yellow('○')} ${color.reset(field)}`; + } + } + + if (fieldsMessage) { + p.note(fieldsMessage.trim(), 'Other Fields'); + return true; + } + + return false; +}; + +/** + * Runs the interactive CLI for user migration + * + * Guides the user through the migration process: + * 1. Displays available transformers with descriptions + * 2. Gathers migration parameters (transformer, file, resumeAfter) + * 3. Analyzes the import file and displays field statistics + * 4. Validates instance type and user count (dev instances limited to 500 users) + * 5. Confirms Dashboard configuration for identifiers, password, user model, and other fields + * 6. Gets final confirmation before starting migration + * + * Saves settings for future runs and returns all configuration options. + * + * @returns Configuration object with transformer key, file path, resumeAfter, instance type, + * and skipPasswordRequirement flag + * @throws Exits the process if migration is cancelled or validation fails + */ +export async function runCLI() { + p.intro(`${color.bgCyan(color.black('Clerk User Migration Utility'))}`); + + // Load previous settings to use as defaults + const savedSettings = loadSettings(); + + // Step 1: Display available transformers with descriptions + let transformerMessage = color.bold('Available Transformers:\n\n'); + for (const transformer of transformers) { + transformerMessage += color.cyan(`● ${transformer.label}\n`); + transformerMessage += ` ${color.dim(transformer.description)}\n\n`; + } + p.note(transformerMessage.trim(), 'Transformers'); + + // Step 2: Gather initial inputs + const initialArgs = await p.group( + { + key: () => + p.select({ + message: 'What platform are you migrating your users from?', + initialValue: savedSettings.key || transformers[0].value, + maxItems: 1, + options: transformers, + }), + file: () => + p.text({ + message: 'Specify the file to use for importing your users', + initialValue: savedSettings.file || 'users.json', + placeholder: savedSettings.file || 'users.json', + validate: (value) => { + if (!checkIfFileExists(value)) { + return 'That file does not exist. Please try again'; + } + if ( + getFileType(value) !== 'text/csv' && + getFileType(value) !== 'application/json' + ) { + return 'Please supply a valid JSON or CSV file'; + } + }, + }), + resumeAfter: () => + p.text({ + message: 'Resume after user ID (leave empty to start from beginning)', + initialValue: '', + defaultValue: '', + placeholder: 'user_xxx or leave empty', + }), + }, + { + onCancel: () => { + p.cancel('Migration cancelled.'); + process.exit(0); + }, + } + ); + + // Step 3: Analyze the file and display field information + const spinner = p.spinner(); + spinner.start('Analyzing import file...'); + + const [users, error] = await tryCatch( + loadRawUsers(initialArgs.file, initialArgs.key) + ); + + if (error) { + spinner.stop('Error analyzing file'); + p.cancel('Failed to analyze import file. Please check the file format.'); + process.exit(1); + } + + // Filter users if resuming after a specific user ID + let filteredUsers = users; + if (initialArgs.resumeAfter) { + const resumeIndex = users.findIndex( + (u) => u.userId === initialArgs.resumeAfter + ); + if (resumeIndex === -1) { + spinner.stop('User ID not found'); + p.cancel( + `Could not find user ID "${initialArgs.resumeAfter}" in the import file.` + ); + process.exit(1); + } + // Start from the user AFTER the specified ID + filteredUsers = users.slice(resumeIndex + 1); + p.log.info( + `Resuming migration after user ID: ${initialArgs.resumeAfter}\n` + + `Skipping ${resumeIndex + 1} users, starting with user ${resumeIndex + 2} of ${users.length}` + ); + } + + const userCount = filteredUsers.length; + spinner.stop(`Found ${userCount} users to migrate`); + + const analysis = analyzeFields(filteredUsers); + + // Step 4: Check instance type and validate + const instanceType = detectInstanceType(); + + if (instanceType === 'dev') { + p.log.info( + `${color.cyan('Development')} instance detected (based on CLERK_SECRET_KEY)` + ); + + if (userCount > DEV_USER_LIMIT) { + p.cancel( + `Cannot import ${userCount} users to a development instance. ` + + `Development instances are limited to ${DEV_USER_LIMIT} users.` + ); + process.exit(1); + } + } else { + p.log.warn( + `${color.yellow('Production')} instance detected (based on CLERK_SECRET_KEY)` + ); + p.log.warn( + color.yellow( + `You are about to import ${userCount} users to your production instance.` + ) + ); + + const confirmProduction = await p.confirm({ + message: 'Are you sure you want to import users to production?', + initialValue: false, + }); + + if (p.isCancel(confirmProduction) || !confirmProduction) { + p.cancel('Migration cancelled.'); + process.exit(0); + } + } + + // Step 5: Display and confirm identifier settings + displayIdentifierAnalysis(analysis); + + // Exit if no users have valid identifiers + if (analysis.identifiers.hasAnyIdentifier === 0) { + p.cancel( + 'No users can be imported. All users are missing a valid identifier (verified email, verified phone, or username).' + ); + 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 6: Display password analysis and get migration preference + const skipPasswordRequirement = await displayPasswordAnalysis(analysis); + + if (skipPasswordRequirement === null) { + p.cancel('Migration cancelled.'); + process.exit(0); + } + + // 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, + }); + + if (p.isCancel(confirmPassword) || !confirmPassword) { + p.cancel( + 'Migration cancelled. Please enable Password in the Dashboard and try again.' + ); + process.exit(0); + } + } + + // Step 7: Display user model analysis + const needsUserModelConfirmation = displayUserModelAnalysis(analysis); + + if (needsUserModelConfirmation) { + const confirmUserModel = await p.confirm({ + message: + 'Have you configured first and last name settings in the Dashboard?', + initialValue: true, + }); + + if (p.isCancel(confirmUserModel) || !confirmUserModel) { + p.cancel( + 'Migration cancelled. Please configure user model settings and try again.' + ); + process.exit(0); + } + } + + // Step 8: Display and confirm other field settings (if any) + const hasOtherFields = displayOtherFieldsAnalysis(analysis); + + if (hasOtherFields) { + const confirmFields = await p.confirm({ + message: 'Have you configured the other field settings in the Dashboard?', + initialValue: true, + }); + + if (p.isCancel(confirmFields) || !confirmFields) { + p.cancel( + 'Migration cancelled. Please configure field settings and try again.' + ); + process.exit(0); + } + } + + // Step 9: Final confirmation + const beginMigration = await p.confirm({ + message: 'Begin Migration?', + initialValue: true, + }); + + if (p.isCancel(beginMigration) || !beginMigration) { + p.cancel('Migration cancelled.'); + process.exit(0); + } + + // Save settings for next run (not including instance - always auto-detected) + saveSettings({ + key: initialArgs.key, + file: initialArgs.file, + }); + + return { + ...initialArgs, + instance: instanceType, + begin: beginMigration, + skipPasswordRequirement: skipPasswordRequirement || false, + }; +} diff --git a/src/migrate/functions.test.ts b/src/migrate/functions.test.ts new file mode 100644 index 0000000..f7bdae5 --- /dev/null +++ b/src/migrate/functions.test.ts @@ -0,0 +1,493 @@ +import { describe, expect, test } from 'vitest'; +import { loadUsersFromFile } from './functions'; +import { transformKeys } from '../utils'; +import { transformers } from './transformers'; + +test('Clerk - loadUsersFromFile - JSON', async () => { + const usersFromClerk = await loadUsersFromFile( + './samples/clerk.json', + 'clerk' + ); + + // Find users with verified emails + const usersWithEmail = usersFromClerk.filter( + (u) => u.email && (Array.isArray(u.email) ? u.email.length > 0 : u.email) + ); + expect(usersWithEmail.length).toBeGreaterThanOrEqual(2); + + // Find users with metadata + const usersWithMetadata = usersFromClerk.filter( + (u) => u.publicMetadata || u.privateMetadata || u.unsafeMetadata + ); + expect(usersWithMetadata.length).toBeGreaterThanOrEqual(2); + + // Find users with username + const usersWithUsername = usersFromClerk.filter((u) => u.username); + expect(usersWithUsername.length).toBeGreaterThanOrEqual(2); + + // Find users with username and password + const usersWithUsernameAndPassword = usersFromClerk.filter( + (u) => u.username && u.password && u.passwordHasher + ); + expect(usersWithUsernameAndPassword.length).toBeGreaterThanOrEqual(2); + + // Find users with email and password + const usersWithEmailAndPassword = usersFromClerk.filter( + (u) => u.email && u.password && u.passwordHasher + ); + expect(usersWithEmailAndPassword.length).toBeGreaterThanOrEqual(2); + + // Find users with phone + const usersWithPhone = usersFromClerk.filter( + (u) => u.phone && (Array.isArray(u.phone) ? u.phone.length > 0 : u.phone) + ); + expect(usersWithPhone.length).toBeGreaterThanOrEqual(2); +}); + +test('Auth.js - loadUsersFromFile - JSON', async () => { + const usersFromAuthjs = await loadUsersFromFile( + './samples/authjs.json', + 'authjs' + ); + + // Find users with verified emails + const usersWithEmail = usersFromAuthjs.filter( + (u) => u.email && (Array.isArray(u.email) ? u.email.length > 0 : u.email) + ); + expect(usersWithEmail.length).toBeGreaterThanOrEqual(2); + + // Note: Users with ONLY unverified emails (email_verified: null) will be + // filtered out during validation because Clerk requires at least one verified + // identifier (email or phone). This is correct behavior. + + // Find users with firstName and lastName (split from name field) + const usersWithNames = usersFromAuthjs.filter( + (u) => u.firstName && u.lastName + ); + expect(usersWithNames.length).toBeGreaterThanOrEqual(15); + + // Verify a specific user's name was split correctly + const janeDoUser = usersFromAuthjs.find( + (u) => u.email === 'jane.doe@test.com' + ); + expect(janeDoUser?.firstName).toBe('Jane'); + expect(janeDoUser?.lastName).toBe('Doe'); + + // Verify a user with no name (null) doesn't have firstName/lastName + const userWithNullName = usersFromAuthjs.find( + (u) => u.email === 'noprofile@test.com' + ); + expect(userWithNullName?.firstName).toBeUndefined(); + expect(userWithNullName?.lastName).toBeUndefined(); +}); + +test('Supabase - loadUsersFromFile - JSON', async () => { + const usersFromSupabase = await loadUsersFromFile( + './samples/supabase.json', + 'supabase' + ); + + // Find users with verified emails + const usersWithEmail = usersFromSupabase.filter( + (u) => u.email && (Array.isArray(u.email) ? u.email.length > 0 : u.email) + ); + expect(usersWithEmail.length).toBeGreaterThanOrEqual(2); + + // Find users with username + const usersWithUsername = usersFromSupabase.filter((u) => u.username); + expect(usersWithUsername.length).toBeGreaterThanOrEqual(2); + + // Find users with username and password + const usersWithUsernameAndPassword = usersFromSupabase.filter( + (u) => u.username && u.password && u.passwordHasher + ); + expect(usersWithUsernameAndPassword.length).toBeGreaterThanOrEqual(2); + + // Find users with email and password + const usersWithEmailAndPassword = usersFromSupabase.filter( + (u) => u.email && u.password && u.passwordHasher + ); + expect(usersWithEmailAndPassword.length).toBeGreaterThanOrEqual(2); + + // Find users with phone + const usersWithPhone = usersFromSupabase.filter( + (u) => u.phone && (Array.isArray(u.phone) ? u.phone.length > 0 : u.phone) + ); + expect(usersWithPhone.length).toBeGreaterThanOrEqual(2); +}); + +test('Auth0 - loadUsersFromFile - JSON', async () => { + const usersFromAuth0 = await loadUsersFromFile( + './samples/auth0.json', + 'auth0' + ); + + // Find users with verified emails + const usersWithEmail = usersFromAuth0.filter( + (u) => u.email && (Array.isArray(u.email) ? u.email.length > 0 : u.email) + ); + expect(usersWithEmail.length).toBeGreaterThanOrEqual(2); + + // Find users with username + const usersWithUsername = usersFromAuth0.filter((u) => u.username); + expect(usersWithUsername.length).toBeGreaterThanOrEqual(2); + + // Find users with username and password + const usersWithUsernameAndPassword = usersFromAuth0.filter( + (u) => u.username && u.password && u.passwordHasher + ); + expect(usersWithUsernameAndPassword.length).toBeGreaterThanOrEqual(2); + + // Find users with email and password + const usersWithEmailAndPassword = usersFromAuth0.filter( + (u) => u.email && u.password && u.passwordHasher + ); + expect(usersWithEmailAndPassword.length).toBeGreaterThanOrEqual(2); + + // Find users with phone + const usersWithPhone = usersFromAuth0.filter( + (u) => u.phone && (Array.isArray(u.phone) ? u.phone.length > 0 : u.phone) + ); + expect(usersWithPhone.length).toBeGreaterThanOrEqual(2); +}); + +// ============================================================================ +// transformKeys tests +// ============================================================================ + +describe('transformKeys', () => { + // Test setup: these transformers are guaranteed to exist in the transformers array + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const clerkTransformer = transformers.find((h) => h.key === 'clerk')!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const supabaseTransformer = transformers.find((h) => h.key === 'supabase')!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const auth0Transformer = transformers.find((h) => h.key === 'auth0')!; + + describe('key transformation', () => { + test('transforms keys according to transformer config', () => { + const data = { + id: 'user_123', + first_name: 'John', + last_name: 'Doe', + primary_email_address: 'john@example.com', + }; + + const result = transformKeys(data, clerkTransformer); + + expect(result).toEqual({ + userId: 'user_123', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + }); + }); + + test('transforms Clerk-specific keys', () => { + const data = { + id: 'user_123', + primary_email_address: 'john@example.com', + verified_email_addresses: ['john@example.com', 'other@example.com'], + password_digest: '$2a$10$hash', + password_hasher: 'bcrypt', + totp_secret: 'SECRET', + backup_codes_enabled: false, + }; + + const result = transformKeys(data, clerkTransformer); + + expect(result).toEqual({ + userId: 'user_123', + email: 'john@example.com', + emailAddresses: ['john@example.com', 'other@example.com'], + password: '$2a$10$hash', + passwordHasher: 'bcrypt', + totpSecret: 'SECRET', + backupCodesEnabled: false, + }); + }); + + test('transforms Supabase-specific keys', () => { + const data = { + id: 'uuid-123', + email: 'jane@example.com', + email_confirmed_at: '2024-01-01 12:00:00+00', + first_name: 'Jane', + last_name: 'Smith', + encrypted_password: '$2a$10$hash', + phone: '+1234567890', + }; + + const result = transformKeys(data, supabaseTransformer); + + expect(result).toEqual({ + userId: 'uuid-123', + email: 'jane@example.com', + emailConfirmedAt: '2024-01-01 12:00:00+00', + firstName: 'Jane', + lastName: 'Smith', + password: '$2a$10$hash', + phone: '+1234567890', + }); + }); + + test('transforms Auth0-specific keys', () => { + const data = { + _id: { $oid: 'auth0123' }, + email: 'user@example.com', + email_verified: true, + username: 'bobuser', + given_name: 'Bob', + family_name: 'Jones', + phone_number: '+1987654321', + passwordHash: '$2b$10$hash', + user_metadata: { role: 'admin' }, + }; + + const result = transformKeys(data, auth0Transformer); + + // transformKeys now extracts nested paths like "_id.$oid" + expect(result).toEqual({ + userId: 'auth0123', + email: 'user@example.com', + emailVerified: true, + username: 'bobuser', + firstName: 'Bob', + lastName: 'Jones', + phone: '+1987654321', + password: '$2b$10$hash', + publicMetadata: { role: 'admin' }, + }); + }); + + test('keeps unmapped keys unchanged', () => { + const data = { + id: 'user_123', + customField: 'custom value', + anotherField: 42, + }; + + const result = transformKeys(data, clerkTransformer); + + expect(result).toEqual({ + userId: 'user_123', + customField: 'custom value', + anotherField: 42, + }); + }); + }); + + describe('filtering empty values', () => { + test('filters out empty strings', () => { + const data = { + id: 'user_123', + first_name: 'John', + last_name: '', + primary_email_address: 'john@example.com', + }; + + const result = transformKeys(data, clerkTransformer); + + expect(result).toEqual({ + userId: 'user_123', + firstName: 'John', + email: 'john@example.com', + }); + expect(result).not.toHaveProperty('lastName'); + }); + + test("filters out empty JSON string '{\"}'", () => { + const data = { + id: 'user_123', + first_name: 'John', + public_metadata: '"{}"', + unsafe_metadata: '"{}"', + }; + + const result = transformKeys(data, clerkTransformer); + + expect(result).toEqual({ + userId: 'user_123', + firstName: 'John', + }); + expect(result).not.toHaveProperty('publicMetadata'); + expect(result).not.toHaveProperty('unsafeMetadata'); + }); + + test('filters out null values', () => { + const data = { + id: 'user_123', + first_name: 'John', + last_name: null, + username: null, + }; + + const result = transformKeys(data, clerkTransformer); + + expect(result).toEqual({ + userId: 'user_123', + firstName: 'John', + }); + expect(result).not.toHaveProperty('lastName'); + expect(result).not.toHaveProperty('username'); + }); + + test('keeps falsy but valid values (false, 0)', () => { + const data = { + id: 'user_123', + backup_codes_enabled: false, + }; + + const result = transformKeys(data, clerkTransformer); + + expect(result).toEqual({ + userId: 'user_123', + backupCodesEnabled: false, + }); + }); + + test('keeps undefined values (current behavior)', () => { + const data = { + id: 'user_123', + first_name: undefined, + }; + + const result = transformKeys(data, clerkTransformer); + + // undefined is not filtered, only "", '"{}"', and null + expect(result).toHaveProperty('firstName'); + expect(result.firstName).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + test('handles empty object', () => { + const result = transformKeys({}, clerkTransformer); + expect(result).toEqual({}); + }); + + test('handles object with only filtered values', () => { + const data = { + first_name: '', + last_name: null, + username: '"{}"', + }; + + const result = transformKeys(data, clerkTransformer); + expect(result).toEqual({}); + }); + + test('preserves array values', () => { + const data = { + id: 'user_123', + verified_email_addresses: ['a@example.com', 'b@example.com'], + verified_phone_numbers: ['+1111111111', '+2222222222'], + }; + + const result = transformKeys(data, clerkTransformer); + + expect(result.emailAddresses).toEqual(['a@example.com', 'b@example.com']); + expect(result.phoneNumbers).toEqual(['+1111111111', '+2222222222']); + }); + + test('preserves object values', () => { + const data = { + id: 'user_123', + public_metadata: { role: 'admin', tier: 'premium' }, + private_metadata: { internalId: 456 }, + }; + + const result = transformKeys(data, clerkTransformer); + + expect(result.publicMetadata).toEqual({ role: 'admin', tier: 'premium' }); + expect(result.privateMetadata).toEqual({ internalId: 456 }); + }); + + test('handles special characters in values', () => { + const data = { + id: 'user_123', + first_name: 'José', + last_name: "O'Brien", + username: 'user@special!', + }; + + const result = transformKeys(data, clerkTransformer); + + expect(result).toEqual({ + userId: 'user_123', + firstName: 'José', + lastName: "O'Brien", + username: 'user@special!', + }); + }); + }); +}); + +// ============================================================================ +// Clerk transformer - pipe separator tests +// ============================================================================ + +describe('Clerk transformer - email and phone parsing with pipe separators', () => { + test('parses pipe-separated emails in CSV format', async () => { + // This test verifies the fix for rows with pipe-separated emails + // like: verified_email_addresses: "email1@test.com|email2@test.com" + const users = await loadUsersFromFile('./samples/clerk.csv', 'clerk'); + + const userWithPipeSeparatedEmails = users.find( + (u) => u.userId === 'user_pipe_email_test' + ); + + expect(userWithPipeSeparatedEmails).toBeDefined(); + expect(Array.isArray(userWithPipeSeparatedEmails?.email)).toBe(true); + expect(userWithPipeSeparatedEmails?.email).toEqual([ + 'primary@test.com', + 'secondary@test.com', + ]); + }); + + test('parses pipe-separated phones in CSV format', async () => { + const users = await loadUsersFromFile('./samples/clerk.csv', 'clerk'); + + const userWithPipeSeparatedPhones = users.find( + (u) => u.userId === 'user_pipe_phone_test' + ); + + expect(userWithPipeSeparatedPhones).toBeDefined(); + expect(Array.isArray(userWithPipeSeparatedPhones?.phone)).toBe(true); + expect(userWithPipeSeparatedPhones?.phone).toEqual([ + '+12125550200', + '+12125550201', + ]); + }); + + test('parses mixed comma and pipe separators for emails', async () => { + const users = await loadUsersFromFile('./samples/clerk.csv', 'clerk'); + + const userWithMixedSeparators = users.find( + (u) => u.userId === 'user_mixed_separator_test' + ); + + expect(userWithMixedSeparators).toBeDefined(); + expect(Array.isArray(userWithMixedSeparators?.email)).toBe(true); + expect(userWithMixedSeparators?.email).toEqual([ + 'first@test.com', + 'second@test.com', + 'third@test.com', + ]); + }); + + test('parses mixed comma and pipe separators for phones', async () => { + const users = await loadUsersFromFile('./samples/clerk.csv', 'clerk'); + + const userWithMixedPhoneSeparators = users.find( + (u) => u.userId === 'user_mixed_phone_separator_test' + ); + + expect(userWithMixedPhoneSeparators).toBeDefined(); + expect(Array.isArray(userWithMixedPhoneSeparators?.phone)).toBe(true); + expect(userWithMixedPhoneSeparators?.phone).toEqual([ + '+12125550300', + '+12125550301', + ]); + }); +}); diff --git a/src/migrate/functions.ts b/src/migrate/functions.ts new file mode 100644 index 0000000..41c381f --- /dev/null +++ b/src/migrate/functions.ts @@ -0,0 +1,268 @@ +import fs from 'fs'; +import csvParser from 'csv-parser'; +import * as p from '@clack/prompts'; +import { validationLogger } from '../logger'; +import { transformers } from './transformers'; +import { userSchema } from './validator'; +import type { TransformerMapKeys, User } from '../types'; +import { PASSWORD_HASHERS } from '../types'; +import { + createImportFilePath, + getDateTimeStamp, + getFileType, + transformKeys, +} from '../utils'; + +const s = p.spinner(); + +/** + * Transforms and validates an array of users for import + * + * Processes each user through: + * 1. Field transformation using the transformer's transformer config + * 2. Special handling for Clerk-to-Clerk migrations (email/phone array consolidation) + * 3. Transformer-specific postTransform logic (if defined) + * 4. Schema validation + * 5. Validation error logging for failed users + * + * Throws immediately if an invalid password hasher is detected. + * Logs other validation errors and excludes invalid users from the result. + * + * @param users - Array of raw user data to transform + * @param key - Transformer key identifying the source platform + * @param dateTime - Timestamp for log file naming + * @returns Object containing transformed users array and validation failure count + * @throws Error if an invalid password hasher is detected + */ +function transformUsers( + users: User[], + key: TransformerMapKeys, + dateTime: string +): { transformedData: User[]; validationFailed: number } { + // This applies to smaller numbers. Pass in 10, get 5 back. + const transformedData: User[] = []; + let validationFailed = 0; + for (let i = 0; i < users.length; i++) { + const transformerKeys = transformers.find((obj) => obj.key === key); + + if (transformerKeys === undefined) { + throw new Error('No transformer found for the specified key'); + } + + const transformedUser = transformKeys(users[i], transformerKeys); + + // Transform email to array for clerk transformer (merges primary + verified + unverified emails) + if (key === 'clerk') { + // Helper to parse email field - could be array (JSON) or comma/pipe-separated string (CSV) + const parseEmails = (field: unknown): string[] => { + if (Array.isArray(field)) return field as string[]; + if (typeof field === 'string' && field) { + return field + .split(/[,|]/) + .map((e: string) => e.trim()) + .filter(Boolean); + } + return []; + }; + + const primaryEmail = transformedUser.email as string | undefined; + const verifiedEmails = parseEmails(transformedUser.emailAddresses); + const unverifiedEmails = parseEmails( + transformedUser.unverifiedEmailAddresses + ); + + // Build email array: primary first, then verified, then unverified (deduplicated) + const allEmails: string[] = []; + if (primaryEmail) allEmails.push(primaryEmail); + for (const email of [...verifiedEmails, ...unverifiedEmails]) { + if (!allEmails.includes(email)) allEmails.push(email); + } + if (allEmails.length > 0) { + transformedUser.email = allEmails; + } + // Remove the individual email fields after consolidation to avoid validation errors + delete transformedUser.emailAddresses; + delete transformedUser.unverifiedEmailAddresses; + + // Helper to parse phone field - could be array (JSON) or comma/pipe-separated string (CSV) + const parsePhones = (field: unknown): string[] => { + if (Array.isArray(field)) return field as string[]; + if (typeof field === 'string' && field) { + return field + .split(/[,|]/) + .map((p: string) => p.trim()) + .filter(Boolean); + } + return []; + }; + + const primaryPhone = transformedUser.phone as string | undefined; + const verifiedPhones = parsePhones(transformedUser.phoneNumbers); + const unverifiedPhones = parsePhones( + transformedUser.unverifiedPhoneNumbers + ); + + // Build phone array: primary first, then verified, then unverified (deduplicated) + const allPhones: string[] = []; + if (primaryPhone) allPhones.push(primaryPhone); + for (const phone of [...verifiedPhones, ...unverifiedPhones]) { + if (!allPhones.includes(phone)) allPhones.push(phone); + } + if (allPhones.length > 0) { + transformedUser.phone = allPhones; + } + // Remove the individual phone fields after consolidation to avoid validation errors + delete transformedUser.phoneNumbers; + delete transformedUser.unverifiedPhoneNumbers; + } + + // Apply transformer-specific post-transformation if defined + if ('postTransform' in transformerKeys) { + transformerKeys.postTransform(transformedUser); + } + const validationResult = userSchema.safeParse(transformedUser); + // Check if validation was successful + if (validationResult.success) { + // The data is valid according to the original schema + const validatedData = validationResult.data; + transformedData.push(validatedData); + } else { + // The data is not valid, handle errors + validationFailed++; + const firstIssue = validationResult.error.issues[0]; + + // Check if this is a password hasher validation error with an invalid value + // Only stop immediately if there's an actual invalid value, not missing/undefined + if ( + firstIssue.path.includes('passwordHasher') && + transformedUser.passwordHasher + ) { + const userId = transformedUser.userId as string; + const invalidHasher = + typeof transformedUser.passwordHasher === 'string' + ? transformedUser.passwordHasher + : JSON.stringify(transformedUser.passwordHasher); + s.stop('Validation Error'); + throw new Error( + `Invalid password hasher detected.\n` + + `User ID: ${userId}\n` + + `Row: ${i + 1}\n` + + `Invalid hasher: "${invalidHasher}"\n` + + `Expected one of: ${PASSWORD_HASHERS.join(', ')}` + ); + } + + validationLogger( + { + error: `${firstIssue.code} for required field.`, + path: firstIssue.path as (string | number)[], + id: transformedUser.userId as string, + row: i, + }, + dateTime + ); + } + } + return { transformedData, validationFailed }; +} + +/** + * Adds default field values from the transformer configuration to all users + * + * Some transformers define default values that should be applied to all users. + * For example, the Supabase transformer defaults passwordHasher to "bcrypt". + * + * @param users - Array of user objects + * @param key - Transformer key identifying which defaults to apply + * @returns Array of users with default fields applied (if transformer has defaults) + */ +function addDefaultFields(users: User[], key: string) { + const transformer = transformers.find((obj) => obj.key === key); + const defaultFields = + transformer && 'defaults' in transformer ? transformer.defaults : null; + + if (defaultFields) { + const updatedUsers: User[] = []; + + for (const user of users) { + const updated = { + ...user, + ...defaultFields, + }; + updatedUsers.push(updated); + } + + return updatedUsers; + } + return users; +} + +/** + * Loads, transforms, and validates users from a JSON or CSV file + * + * Main entry point for loading user data. Performs the following: + * 1. Reads users from file (supports JSON and CSV) + * 2. Applies transformer default fields + * 3. Transforms field names to Clerk schema + * 4. Validates each user against schema + * 5. Logs validation errors + * 6. Returns only successfully validated users and validation failure count + * + * Displays a spinner during the loading process. + * + * @param file - File path to load users from (relative or absolute) + * @param key - Transformer key identifying the source platform + * @returns Object containing validated users array and validation failure count + * @throws Error if file cannot be read or contains invalid data + */ +export async function loadUsersFromFile( + file: string, + key: TransformerMapKeys +): Promise<{ users: User[]; validationFailed: number }> { + const dateTime = getDateTimeStamp(); + s.start(); + s.message('Loading users and preparing to migrate'); + + const type = getFileType(createImportFilePath(file)); + + // convert a CSV to JSON and return array + if (type === 'text/csv') { + const users: User[] = []; + return new Promise((resolve, reject) => { + fs.createReadStream(createImportFilePath(file)) + .pipe(csvParser({ skipComments: true })) + .on('data', (data: User) => { + users.push(data); + }) + .on('error', (err) => { + s.stop('Error loading users'); + reject(err); + }) + .on('end', () => { + const usersWithDefaultFields = addDefaultFields(users, key); + const { transformedData, validationFailed } = transformUsers( + usersWithDefaultFields, + key, + dateTime + ); + s.stop('Users Loaded'); + resolve({ users: transformedData, validationFailed }); + }); + }); + + // if the file is already JSON, just read and parse and return the result + } + const users = JSON.parse( + fs.readFileSync(createImportFilePath(file), 'utf-8') + ) as User[]; + const usersWithDefaultFields = addDefaultFields(users, key); + + const { transformedData, validationFailed } = transformUsers( + usersWithDefaultFields, + key, + dateTime + ); + + s.stop('Users Loaded'); + return { users: transformedData, validationFailed }; +} diff --git a/src/migrate/import-users.test.ts b/src/migrate/import-users.test.ts new file mode 100644 index 0000000..8e03a1f --- /dev/null +++ b/src/migrate/import-users.test.ts @@ -0,0 +1,523 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { existsSync, rmSync } from 'node:fs'; + +// Mock @clerk/backend before importing the module +const mockCreateUser = vi.fn(); +const mockCreateEmailAddress = vi.fn(); +const mockCreatePhoneNumber = vi.fn(); +vi.mock('@clerk/backend', () => ({ + createClerkClient: vi.fn(() => ({ + users: { + createUser: mockCreateUser, + }, + emailAddresses: { + createEmailAddress: mockCreateEmailAddress, + }, + phoneNumbers: { + createPhoneNumber: mockCreatePhoneNumber, + }, + })), +})); + +// Mock @clack/prompts to prevent console output during tests +vi.mock('@clack/prompts', () => ({ + note: vi.fn(), + outro: vi.fn(), + log: { + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn(), + stop: vi.fn(), + message: vi.fn(), + })), +})); + +// Mock picocolors to prevent console output during tests +vi.mock('picocolors', () => ({ + default: { + bold: vi.fn((s) => s), + dim: vi.fn((s) => s), + gray: vi.fn((s) => s), + green: vi.fn((s) => s), + red: vi.fn((s) => s), + yellow: vi.fn((s) => s), + blue: vi.fn((s) => s), + cyan: vi.fn((s) => s), + white: vi.fn((s) => s), + black: vi.fn((s) => s), + bgCyan: vi.fn((s) => s), + }, +})); + +// Mock utils for testing +vi.mock('../utils', () => ({ + getDateTimeStamp: vi.fn(() => '2024-01-01T12:00:00'), + tryCatch: async (promise: Promise) => { + try { + const data = await promise; + return [data, null]; + } catch (throwable) { + if (throwable instanceof Error) return [null, throwable]; + throw throwable; + } + }, + getRetryDelay: ( + retryCount: number, + retryAfterSeconds: number | undefined, + defaultDelayMs: number + ) => { + const delayMs = retryAfterSeconds + ? retryAfterSeconds * 1000 + : defaultDelayMs; + const delaySeconds = retryAfterSeconds || defaultDelayMs / 1000; + return { delayMs, delaySeconds }; + }, +})); + +// Mock logger module +vi.mock('../logger', () => ({ + errorLogger: vi.fn(), + importLogger: vi.fn(), + closeAllStreams: vi.fn(), +})); + +// Mock env constants +vi.mock('../envs-constants', () => ({ + env: { + CLERK_SECRET_KEY: 'test_secret_key', + RATE_LIMIT: 10, + CONCURRENCY_LIMIT: 5, // Higher for faster tests + }, + MAX_RETRIES: 5, + RETRY_DELAY_MS: 10000, +})); + +// Import after mocks are set up +import { importUsers } from './import-users'; +import * as logger from '../logger'; + +// Helper to clean up logs directory +const cleanupLogs = () => { + if (existsSync('logs')) { + rmSync('logs', { recursive: true, force: true, maxRetries: 3 }); + } +}; + +describe('importUsers', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanupLogs(); + }); + + afterEach(() => { + cleanupLogs(); + }); + + describe('createUser API calls', () => { + test('calls Clerk API with correct params for user with password', async () => { + mockCreateUser.mockResolvedValue({ id: 'user_created' }); + + const users = [ + { + userId: 'user_123', + email: ['john@example.com'], + firstName: 'John', + lastName: 'Doe', + password: '$2a$10$hashedpassword', + passwordHasher: 'bcrypt' as const, + username: 'johndoe', + }, + ]; + + await importUsers(users); + + expect(mockCreateUser).toHaveBeenCalledTimes(1); + expect(mockCreateUser).toHaveBeenCalledWith({ + externalId: 'user_123', + emailAddress: ['john@example.com'], + firstName: 'John', + lastName: 'Doe', + passwordDigest: '$2a$10$hashedpassword', + passwordHasher: 'bcrypt', + username: 'johndoe', + phoneNumber: undefined, + totpSecret: undefined, + }); + }); + + test('calls Clerk API with skipPasswordRequirement for user without password', async () => { + mockCreateUser.mockResolvedValue({ id: 'user_created' }); + + const users = [ + { + userId: 'user_456', + email: ['jane@example.com'], + firstName: 'Jane', + lastName: 'Smith', + }, + ]; + + await importUsers(users, true); + + expect(mockCreateUser).toHaveBeenCalledTimes(1); + expect(mockCreateUser).toHaveBeenCalledWith({ + externalId: 'user_456', + emailAddress: ['jane@example.com'], + firstName: 'Jane', + lastName: 'Smith', + skipPasswordRequirement: true, + username: undefined, + phoneNumber: undefined, + totpSecret: undefined, + }); + }); + + test('processes multiple users concurrently', async () => { + mockCreateUser.mockResolvedValue({ id: 'user_created' }); + + const users = [ + { userId: 'user_1', email: ['user1@example.com'] }, + { userId: 'user_2', email: ['user2@example.com'] }, + { userId: 'user_3', email: ['user3@example.com'] }, + ]; + + await importUsers(users); + + expect(mockCreateUser).toHaveBeenCalledTimes(3); + }); + + test('includes phone number when provided', async () => { + mockCreateUser.mockResolvedValue({ id: 'user_created' }); + + const users = [ + { + userId: 'user_phone', + email: ['phone@example.com'], + phone: ['+1234567890'], + }, + ]; + + await importUsers(users); + + expect(mockCreateUser).toHaveBeenCalledWith( + expect.objectContaining({ + phoneNumber: ['+1234567890'], + }) + ); + }); + + test('includes TOTP secret when provided', async () => { + mockCreateUser.mockResolvedValue({ id: 'user_created' }); + + const users = [ + { + userId: 'user_totp', + email: ['totp@example.com'], + totpSecret: 'JBSWY3DPEHPK3PXP', + }, + ]; + + await importUsers(users); + + expect(mockCreateUser).toHaveBeenCalledWith( + expect.objectContaining({ + totpSecret: 'JBSWY3DPEHPK3PXP', + }) + ); + }); + }); + + describe('error handling', () => { + test('logs error when Clerk API fails', async () => { + const errorLoggerSpy = vi.spyOn(logger, 'errorLogger'); + + const clerkError = { + status: 422, + errors: [ + { + code: 'form_identifier_exists', + message: 'Email exists', + longMessage: 'That email address is taken.', + }, + ], + }; + mockCreateUser.mockRejectedValue(clerkError); + + const users = [{ userId: 'user_fail', email: ['existing@example.com'] }]; + + await importUsers(users); + + expect(errorLoggerSpy).toHaveBeenCalled(); + expect(errorLoggerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user_fail', + status: '422', + }), + expect.any(String) + ); + }); + + test('continues processing after error', async () => { + mockCreateUser + .mockRejectedValueOnce({ + status: 400, + errors: [{ code: 'error', message: 'Failed' }], + }) + .mockResolvedValueOnce({ id: 'user_2_created' }) + .mockResolvedValueOnce({ id: 'user_3_created' }); + + const users = [ + { userId: 'user_1', email: ['user1@example.com'] }, + { userId: 'user_2', email: ['user2@example.com'] }, + { userId: 'user_3', email: ['user3@example.com'] }, + ]; + + await importUsers(users); + + // All three should be attempted + expect(mockCreateUser).toHaveBeenCalledTimes(3); + }); + + test('retries on rate limit (429) error', { timeout: 15000 }, async () => { + const rateLimitError = { + status: 429, + errors: [{ code: 'rate_limit', message: 'Too many requests' }], + }; + + mockCreateUser + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({ id: 'user_created' }); + + const users = [{ userId: 'user_rate', email: ['rate@example.com'] }]; + + await importUsers(users); + + // Should be called twice: first fails with 429, retry succeeds + expect(mockCreateUser).toHaveBeenCalledTimes(2); + }); + }); + + describe('validation', () => { + test('skips createUser for invalid users (missing userId)', async () => { + // Mock errorLogger to prevent TypeError from ZodError structure mismatch + vi.spyOn(logger, 'errorLogger').mockImplementation(() => {}); + + const users = [{ email: ['noid@example.com'] } as any]; + + await importUsers(users); + + // createUser should not be called for invalid user + expect(mockCreateUser).not.toHaveBeenCalled(); + }); + }); +}); + +describe('importUsers edge cases', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreatePhoneNumber.mockReset(); + cleanupLogs(); + }); + + afterEach(() => { + cleanupLogs(); + }); + + test('handles empty user array', async () => { + await importUsers([]); + expect(mockCreateUser).not.toHaveBeenCalled(); + }); + + test('handles user with all optional fields', async () => { + mockCreateUser.mockReset().mockResolvedValue({ id: 'user_full_created' }); + mockCreateEmailAddress.mockResolvedValue({}); + + const users = [ + { + userId: 'user_full', + email: ['full@example.com', 'secondary@example.com'], + firstName: 'Full', + lastName: 'User', + password: '$2a$10$hash', + passwordHasher: 'bcrypt' as const, + username: 'fulluser', + phone: ['+1111111111'], + totpSecret: 'SECRET123', + backupCodesEnabled: true, + }, + ]; + + await importUsers(users); + + // createUser should be called with only the primary email + expect(mockCreateUser).toHaveBeenCalledWith( + expect.objectContaining({ + externalId: 'user_full', + emailAddress: ['full@example.com'], + firstName: 'Full', + lastName: 'User', + passwordDigest: '$2a$10$hash', + passwordHasher: 'bcrypt', + username: 'fulluser', + phoneNumber: ['+1111111111'], + totpSecret: 'SECRET123', + }) + ); + + // createEmailAddress should be called for additional emails + expect(mockCreateEmailAddress).toHaveBeenCalledWith({ + userId: 'user_full_created', + emailAddress: 'secondary@example.com', + primary: false, + }); + }); + + test('adds multiple additional emails after user creation', async () => { + mockCreateUser.mockResolvedValue({ id: 'user_multi_email' }); + mockCreateEmailAddress.mockResolvedValue({}); + + const users = [ + { + userId: 'user_emails', + email: [ + 'primary@example.com', + 'second@example.com', + 'third@example.com', + ], + }, + ]; + + await importUsers(users); + + // createUser gets only the first email + expect(mockCreateUser).toHaveBeenCalledWith( + expect.objectContaining({ + emailAddress: ['primary@example.com'], + }) + ); + + // createEmailAddress called for each additional email + expect(mockCreateEmailAddress).toHaveBeenCalledTimes(2); + expect(mockCreateEmailAddress).toHaveBeenCalledWith({ + userId: 'user_multi_email', + emailAddress: 'second@example.com', + primary: false, + }); + expect(mockCreateEmailAddress).toHaveBeenCalledWith({ + userId: 'user_multi_email', + emailAddress: 'third@example.com', + primary: false, + }); + }); + + test('does not call createEmailAddress when only one email', async () => { + mockCreateUser.mockResolvedValue({ id: 'user_single' }); + + const users = [ + { + userId: 'user_one_email', + email: ['only@example.com'], + }, + ]; + + await importUsers(users); + + expect(mockCreateUser).toHaveBeenCalledTimes(1); + expect(mockCreateEmailAddress).not.toHaveBeenCalled(); + }); + + test('adds multiple additional phones after user creation', async () => { + mockCreateUser.mockResolvedValue({ id: 'user_multi_phone' }); + mockCreatePhoneNumber.mockResolvedValue({}); + + const users = [ + { + userId: 'user_phones', + email: ['test@example.com'], + phone: ['+1111111111', '+2222222222', '+3333333333'], + }, + ]; + + await importUsers(users); + + // createUser gets only the first phone + expect(mockCreateUser).toHaveBeenCalledWith( + expect.objectContaining({ + phoneNumber: ['+1111111111'], + }) + ); + + // createPhoneNumber called for each additional phone + expect(mockCreatePhoneNumber).toHaveBeenCalledTimes(2); + expect(mockCreatePhoneNumber).toHaveBeenCalledWith({ + userId: 'user_multi_phone', + phoneNumber: '+2222222222', + primary: false, + }); + expect(mockCreatePhoneNumber).toHaveBeenCalledWith({ + userId: 'user_multi_phone', + phoneNumber: '+3333333333', + primary: false, + }); + }); + + test('does not call createPhoneNumber when only one phone', async () => { + mockCreateUser.mockResolvedValue({ id: 'user_single_phone' }); + + const users = [ + { + userId: 'user_one_phone', + email: ['test@example.com'], + phone: ['+1234567890'], + }, + ]; + + await importUsers(users); + + expect(mockCreateUser).toHaveBeenCalledTimes(1); + expect(mockCreatePhoneNumber).not.toHaveBeenCalled(); + }); + + test('handles phone as string (converts to array)', async () => { + mockCreateUser.mockResolvedValue({ id: 'user_string_phone' }); + + const users = [ + { + userId: 'user_string_phone', + email: ['test@example.com'], + phone: '+1234567890', + }, + ]; + + await importUsers(users); + + expect(mockCreateUser).toHaveBeenCalledWith( + expect.objectContaining({ + phoneNumber: ['+1234567890'], + }) + ); + expect(mockCreatePhoneNumber).not.toHaveBeenCalled(); + }); + + test('handles user without phone', async () => { + mockCreateUser.mockResolvedValue({ id: 'user_no_phone' }); + + const users = [ + { + userId: 'user_no_phone', + email: ['test@example.com'], + }, + ]; + + await importUsers(users); + + expect(mockCreateUser).toHaveBeenCalledWith( + expect.not.objectContaining({ + phoneNumber: expect.anything(), + }) + ); + }); +}); diff --git a/src/migrate/import-users.ts b/src/migrate/import-users.ts new file mode 100644 index 0000000..93a6a34 --- /dev/null +++ b/src/migrate/import-users.ts @@ -0,0 +1,472 @@ +import { createClerkClient } from '@clerk/backend'; +import type { ClerkAPIError } from '@clerk/types'; +import { env, MAX_RETRIES, RETRY_DELAY_MS } from '../envs-constants'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; +import { closeAllStreams, errorLogger, importLogger } from '../logger'; +import { getDateTimeStamp, getRetryDelay, tryCatch } from '../utils'; +import { userSchema } from './validator'; +import type { ImportSummary, User } from '../types'; +import pLimit from 'p-limit'; + +const s = p.spinner(); +let processed = 0; +let successful = 0; +let failed = 0; +const errorCounts = new Map(); +let lastProcessedUserId: string | null = null; + +/** + * Gets the last processed user ID + * @returns The user ID of the last processed user, or null if none processed + */ +export function getLastProcessedUserId(): string | null { + return lastProcessedUserId; +} + +/** + * Creates a single user in Clerk with all associated data + * + * Handles the full user creation process: + * 1. Creates the user with primary email/phone and core fields + * 2. Adds additional emails and phones (rate-limited via shared limiter) + * 3. Adds verified and unverified email addresses + * 4. Adds verified and unverified phone numbers + * 5. Handles password with appropriate hasher + * 6. Supports backup codes if enabled + * + * @param userData - The validated user data + * @param skipPasswordRequirement - Whether to skip password requirement for users without passwords + * @param limit - Shared p-limit instance for rate limiting all API calls + * @param dateTime - Timestamp for log file naming + * @returns The created Clerk user object + * @throws Will throw if user creation fails + */ +async function createUser( + userData: User, + skipPasswordRequirement: boolean, + limit: ReturnType, + dateTime: string +) { + const clerk = createClerkClient({ secretKey: env.CLERK_SECRET_KEY }); + + // Extract primary email and additional emails + let emails: string[] = []; + if (userData.email) { + emails = Array.isArray(userData.email) ? userData.email : [userData.email]; + } + const primaryEmail = emails[0]; + const additionalEmails = emails.slice(1); + + // Extract primary phone and additional phones + let phones: string[] = []; + if (userData.phone) { + phones = Array.isArray(userData.phone) ? userData.phone : [userData.phone]; + } + const primaryPhone = phones[0]; + const additionalPhones = phones.slice(1); + + // Build user params dynamically based on available fields + // Using Record type to allow dynamic property assignment for password hashing params + const userParams: Record = { + externalId: userData.userId, + }; + + // Add email if present + if (primaryEmail) userParams.emailAddress = [primaryEmail]; + + // Add optional fields only if they have values + if (userData.firstName) userParams.firstName = userData.firstName; + if (userData.lastName) userParams.lastName = userData.lastName; + if (userData.username) userParams.username = userData.username; + if (primaryPhone) userParams.phoneNumber = [primaryPhone]; + if (userData.totpSecret) userParams.totpSecret = userData.totpSecret; + if (userData.backupCodes) userParams.backupCodes = userData.backupCodes; + if (userData.unsafeMetadata) + userParams.unsafeMetadata = userData.unsafeMetadata; + if (userData.privateMetadata) + userParams.privateMetadata = userData.privateMetadata; + if (userData.publicMetadata) + userParams.publicMetadata = userData.publicMetadata; + + // Additional Clerk API fields + if (userData.bypassClientTrust !== undefined) + userParams.bypassClientTrust = userData.bypassClientTrust; + if (userData.createOrganizationEnabled !== undefined) + userParams.createOrganizationEnabled = userData.createOrganizationEnabled; + if (userData.createOrganizationsLimit !== undefined) + userParams.createOrganizationsLimit = userData.createOrganizationsLimit; + if (userData.createdAt) userParams.createdAt = userData.createdAt; + if (userData.deleteSelfEnabled !== undefined) + userParams.deleteSelfEnabled = userData.deleteSelfEnabled; + if (userData.legalAcceptedAt) + userParams.legalAcceptedAt = userData.legalAcceptedAt; + if (userData.skipLegalChecks !== undefined) + userParams.skipLegalChecks = userData.skipLegalChecks; + if (userData.skipPasswordChecks !== undefined) + userParams.skipPasswordChecks = userData.skipPasswordChecks; + + // Handle password - if present, include digest and hasher; otherwise skip password requirement if allowed + if (userData.password && userData.passwordHasher) { + userParams.passwordDigest = userData.password; + userParams.passwordHasher = userData.passwordHasher; + } else if (skipPasswordRequirement) { + userParams.skipPasswordRequirement = true; + } + // If user has no password and skipPasswordRequirement is false, the API will return an error + + // Create the user with the primary email + // Rate-limited via the shared limiter + const [createdUser, createError] = await tryCatch( + limit(() => + clerk.users.createUser( + userParams as Parameters[0] + ) + ) + ); + + if (createError) { + throw createError; + } + + // Add additional emails to the created user + // Each API call is rate-limited via the shared limiter + // Use tryCatch to make these non-fatal - if they fail, log but continue + const emailPromises = additionalEmails + .filter((email) => email) + .map((email) => + limit(async () => { + const [, emailError] = await tryCatch( + clerk.emailAddresses.createEmailAddress({ + userId: createdUser.id, + emailAddress: email, + primary: false, + }) + ); + + if (emailError) { + // Log error but don't fail the entire user creation + errorLogger( + { + userId: userData.userId, + status: 'additional_email_error', + errors: [ + { + code: 'additional_email_failed', + message: `Failed to add additional email ${email}`, + longMessage: `Failed to add additional email ${email}: ${emailError.message}`, + }, + ], + }, + dateTime + ); + } + }) + ); + + // Add additional phones to the created user + // Each API call is rate-limited via the shared limiter + // Use tryCatch to make these non-fatal - if they fail, log but continue + const phonePromises = additionalPhones + .filter((phone) => phone) + .map((phone) => + limit(async () => { + const [, phoneError] = await tryCatch( + clerk.phoneNumbers.createPhoneNumber({ + userId: createdUser.id, + phoneNumber: phone, + primary: false, + }) + ); + + if (phoneError) { + // Log error but don't fail the entire user creation + errorLogger( + { + userId: userData.userId, + status: 'additional_phone_error', + errors: [ + { + code: 'additional_phone_failed', + message: `Failed to add additional phone ${phone}`, + longMessage: `Failed to add additional phone ${phone}: ${phoneError.message}`, + }, + ], + }, + dateTime + ); + } + }) + ); + + // Wait for all additional identifiers to be created + await Promise.all([...emailPromises, ...phonePromises]); + + return createdUser; +} + +/** + * Processes a single user for import to Clerk + * + * Validates the user data, creates the user in Clerk, and handles errors. + * Implements retry logic for rate limit errors (429) with a maximum of 5 retries. + * Updates progress counters and logs results. + * + * @param userData - The user data to import + * @param total - Total number of users being processed (for progress display) + * @param dateTime - Timestamp for log file naming + * @param skipPasswordRequirement - Whether to skip password requirement + * @param limit - Shared p-limit instance for rate limiting all API calls + * @param retryCount - Current retry attempt count (default 0) + * @returns A promise that resolves when the user is processed + */ +async function processUserToClerk( + userData: User, + total: number, + dateTime: string, + skipPasswordRequirement: boolean, + limit: ReturnType, + retryCount: number = 0 +) { + try { + // Validate user data + const parsedUserData = userSchema.safeParse(userData); + if (!parsedUserData.success) { + throw parsedUserData.error; + } + + // Create user (may throw for main user creation, but additional emails/phones use tryCatch internally) + await createUser( + parsedUserData.data, + skipPasswordRequirement, + limit, + dateTime + ); + + // Success + successful++; + processed++; + lastProcessedUserId = userData.userId; + + // Log successful import + importLogger({ userId: userData.userId, status: 'success' }, dateTime); + } catch (error: unknown) { + // Retry on rate limit error (429) + const clerkError = error as { status?: number; errors?: ClerkAPIError[] }; + if (clerkError.status === 429) { + // Extract Retry-After value from response (in seconds) + const retryAfterSeconds = clerkError.errors?.[0]?.meta?.retryAfter as + | number + | undefined; + + if (retryCount < MAX_RETRIES) { + // Calculate retry delay using shared utility function + const { delayMs, delaySeconds } = getRetryDelay( + retryCount, + retryAfterSeconds, + RETRY_DELAY_MS + ); + + // Log retry attempt + const retryMessage = `Rate limit hit (429), retrying in ${delaySeconds}s (attempt ${retryCount + 1}/${MAX_RETRIES})`; + + errorLogger( + { + userId: userData.userId, + status: '429_retry', + errors: [ + { + code: 'rate_limit_retry', + message: retryMessage, + longMessage: retryMessage, + }, + ], + }, + dateTime + ); + + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return processUserToClerk( + userData, + total, + dateTime, + skipPasswordRequirement, + limit, + retryCount + 1 + ); + } + // Max retries exceeded - log as permanent failure + const errorMessage = `Rate limit exceeded after ${MAX_RETRIES} retries`; + failed++; + processed++; + lastProcessedUserId = userData.userId; + s.message(`Migrating users: [${processed}/${total}]`); + errorCounts.set(errorMessage, (errorCounts.get(errorMessage) ?? 0) + 1); + + // Log to error log file + errorLogger( + { + userId: userData.userId, + status: '429', + errors: [ + { + code: 'rate_limit_exceeded', + message: errorMessage, + longMessage: errorMessage, + }, + ], + }, + dateTime + ); + + // Log to import log file + importLogger( + { userId: userData.userId, status: 'error', error: errorMessage }, + dateTime + ); + return; + } + + // Track error for summary + failed++; + processed++; + lastProcessedUserId = userData.userId; + s.message(`Migrating users: [${processed}/${total}]`); + + const errorMessage = + clerkError.errors?.[0]?.longMessage ?? + clerkError.errors?.[0]?.message ?? + 'Unknown error'; + errorCounts.set(errorMessage, (errorCounts.get(errorMessage) ?? 0) + 1); + + // Log to error log file + errorLogger( + { + userId: userData.userId, + status: String(clerkError.status ?? 'unknown'), + errors: clerkError.errors ?? [], + }, + dateTime + ); + + // Log to import log file + importLogger( + { userId: userData.userId, status: 'error', error: errorMessage }, + dateTime + ); + } + s.message( + `Migrating users: [${processed}/${total}] (${successful} successful, ${failed} failed)` + ); +} + +/** + * Displays a formatted summary of the import operation + * + * Shows: + * - Total users processed + * - Successful imports + * - Failed imports + * - Validation failures + * - Breakdown of errors by type + * + * @param summary - The import summary statistics + */ +function displaySummary(summary: ImportSummary) { + let message = `Total users processed: ${summary.totalProcessed}\n`; + message += `${color.green('Successfully imported:')} ${summary.successful}\n`; + message += `${color.red('Failed with errors:')} ${summary.failed}`; + + if (summary.validationFailed > 0) { + message += `\n${color.yellow('Failed validation:')} ${summary.validationFailed}`; + } + + if (summary.errorBreakdown.size > 0) { + message += `\n\n${color.bold('Error Breakdown:')}\n`; + for (const [error, count] of summary.errorBreakdown) { + const prefix = `${color.red('•')} ${count} user${count === 1 ? '' : 's'}: `; + message += `${prefix}${error}\n`; + } + } + + p.note(message.trim(), 'Migration Summary'); +} + +/** + * Imports an array of users to Clerk + * + * Main entry point for user migration. Processes users concurrently with + * rate limiting, displays progress, and shows a summary at completion. + * Logs all results to timestamped log files. + * + * @param users - Array of validated users to import + * @param skipPasswordRequirement - Whether to allow users without passwords (default: false) + * @param validationFailed - Number of users that failed validation (default: 0) + * @returns A promise that resolves when all users are processed + */ +export async function importUsers( + users: User[], + skipPasswordRequirement: boolean = false, + validationFailed: number = 0 +) { + const dateTime = getDateTimeStamp(); + + // Reset counters for each import run + processed = 0; + successful = 0; + failed = 0; + lastProcessedUserId = null; + errorCounts.clear(); + + // Set up interruption handler + const handleInterrupt = () => { + s.stop('Migration interrupted by user'); + p.log.warn(`Last processed user ID: ${lastProcessedUserId ?? 'none'}`); + if (lastProcessedUserId) { + p.note( + `To resume this migration, use the --resume-after flag:\n bun migrate --resume-after="${lastProcessedUserId}"`, + 'Resume Migration' + ); + } + closeAllStreams(); + process.exit(130); // Standard exit code for SIGINT + }; + + process.on('SIGINT', handleInterrupt); + + s.start(); + const total = users.length; + s.message(`Migrating users: [0/${total}]`); + + // Set up concurrency limiter based on rate limit + // This limiter is shared across ALL API calls (user creation, emails, phones) + const limit = pLimit(env.CONCURRENCY_LIMIT); + + // Process all users concurrently + // Note: We don't wrap processUserToClerk with limit() here because + // individual API calls inside createUser are rate-limited instead + const promises = users.map((user) => + processUserToClerk(user, total, dateTime, skipPasswordRequirement, limit) + ); + + await Promise.all(promises); + + // Remove interruption handler now that we're done + process.off('SIGINT', handleInterrupt); + + s.stop(`Migrated ${total} users`); + + // Close all log streams + closeAllStreams(); + + // Display summary + const summary: ImportSummary = { + totalProcessed: total, + successful, + failed, + validationFailed, + errorBreakdown: errorCounts, + }; + displaySummary(summary); +} diff --git a/src/migrate/index.ts b/src/migrate/index.ts new file mode 100644 index 0000000..95a9652 --- /dev/null +++ b/src/migrate/index.ts @@ -0,0 +1,64 @@ +import 'dotenv/config'; + +import { runCLI } from './cli'; +import { loadUsersFromFile } from './functions'; +import { getLastProcessedUserId, importUsers } from './import-users'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; + +/** + * Main entry point for the user migration script + * + * Workflow: + * 1. Runs the CLI to gather migration parameters + * 2. Loads and transforms users from the source file + * 3. Filters users if resuming after a specific user ID + * 4. Imports users to Clerk + * + * @returns A promise that resolves when migration is complete + */ +async function main() { + const args = await runCLI(); + + // Load all users from file + const { users, validationFailed } = await loadUsersFromFile( + args.file, + args.key + ); + + // If resuming after a specific user ID, filter to start after that user + let usersToImport = users; + if (args.resumeAfter) { + const resumeIndex = users.findIndex((u) => u.userId === args.resumeAfter); + if (resumeIndex !== -1) { + usersToImport = users.slice(resumeIndex + 1); + } + } + + await importUsers( + usersToImport, + args.skipPasswordRequirement, + validationFailed + ); +} + +main().catch((error: unknown) => { + p.log.error(color.red('\nMigration failed with error:')); + + const errorMessage = error instanceof Error ? error.message : String(error); + p.log.error(color.red(errorMessage)); + + const lastUserId = getLastProcessedUserId(); + if (lastUserId) { + p.log.warn(color.yellow(`Last processed user ID: ${lastUserId}`)); + p.note( + `To resume this migration, use:\n bun migrate --resume-after="${lastUserId}"`, + 'Resume Migration' + ); + } + + if (error instanceof Error && error.stack) { + p.log.error(error.stack); + } + process.exit(1); +}); diff --git a/src/migrate/transformers/auth0.ts b/src/migrate/transformers/auth0.ts new file mode 100644 index 0000000..7e82be0 --- /dev/null +++ b/src/migrate/transformers/auth0.ts @@ -0,0 +1,60 @@ +/** + * Transformer for migrating users from Auth0 + * + * Maps Auth0's user export format to Clerk's import format. + * Handles Auth0-specific features: + * - Nested _id.$oid field extraction + * - Email verification status routing (verified vs unverified) + * - User metadata mapping + * - Bcrypt password hashes + * + * @property {string} key - Transformer identifier used in CLI + * @property {string} value - Internal value for the transformer + * @property {string} label - Display name shown in CLI prompts + * @property {string} description - Detailed description shown in CLI + * @property {Object} transformer - Field mapping configuration (supports nested paths with dot notation) + * @property {Function} postTransform - Custom transformation logic for email verification + * @property {Object} defaults - Default values applied to all users (passwordHasher: bcrypt) + */ +const auth0Transformer = { + key: 'auth0', + value: 'auth0', + label: 'Auth0', + description: + 'This is designed to match the user export that you request from Auth0, but may need changes/updates to match the data in your export', + transformer: { + '_id.$oid': 'userId', // Nested field automatically flattened by transformKeys + email: 'email', + email_verified: 'emailVerified', + username: 'username', + given_name: 'firstName', + family_name: 'lastName', + phone_number: 'phone', + passwordHash: 'password', + user_metadata: 'publicMetadata', + }, + postTransform: (user: Record) => { + // Handle email verification + const emailVerified = user.emailVerified as boolean | undefined; + const email = user.email as string | undefined; + + if (email) { + if (emailVerified === true) { + // Email is verified - keep it as is + user.email = email; + } else { + // Email is unverified - move to unverifiedEmailAddresses + user.unverifiedEmailAddresses = email; + delete user.email; + } + } + + // Clean up the emailVerified field as it's not part of our schema + delete user.emailVerified; + }, + defaults: { + passwordHasher: 'bcrypt' as const, + }, +}; + +export default auth0Transformer; diff --git a/src/migrate/transformers/authjs.ts b/src/migrate/transformers/authjs.ts new file mode 100644 index 0000000..1493e7c --- /dev/null +++ b/src/migrate/transformers/authjs.ts @@ -0,0 +1,69 @@ +/** + * Transformer for migrating users from Auth.js (formerly Next-Auth) + * + * Maps Auth.js user data to Clerk's import format. + * This is a minimal transformer that only maps basic user fields. + * + * The postTransform function: + * - Handles email verification status (routes to email or unverifiedEmailAddresses) + * - Splits 'name' field into firstName (first word) and lastName (remaining words) + * + * @property {string} key - Transformer identifier used in CLI + * @property {string} value - Internal value for the transformer + * @property {string} label - Display name shown in CLI prompts + * @property {string} description - Detailed description shown in CLI + * @property {Object} transformer - Field mapping configuration + * @property {Function} postTransform - Handles email verification and name splitting + */ +const authjsTransformer = { + key: 'authjs', + value: 'authjs', + label: 'Authjs (Next-Auth)', + description: + 'Authjs does not have a pre-built export tool, so you will need to edit this transformer to match the exported data. This transformer assumes the export was done via `SELECT id, name, email, email_verified, created_at FROM users`. The name field will be automatically split into firstName and lastName.', + transformer: { + id: 'userId', + email: 'email', + email_verified: 'emailVerified', + name: 'name', + created_at: 'createdAt', + updated_at: 'updatedAt', + }, + postTransform: (user: Record) => { + // Handle email verification + const emailVerified = user.emailVerified as string | undefined; + const email = user.email as string | undefined; + + if (email) { + if (emailVerified) { + // Email is verified - keep it as is + user.email = email; + } else { + // Email is unverified - move to unverifiedEmailAddresses + user.unverifiedEmailAddresses = email; + delete user.email; + } + } + + // Clean up the emailVerified field as it's not part of our schema + delete user.emailVerified; + + // Split name into firstName and lastName + // Only set names if we have at least 2 words (Clerk requires both first and last) + const name = user.name as string | null | undefined; + if (name && typeof name === 'string') { + const trimmedName = name.trim(); + const nameParts = trimmedName.split(/\s+/); // Split by one or more spaces + + if (nameParts.length > 1) { + user.firstName = nameParts[0]; + user.lastName = nameParts.slice(1).join(' '); + } + + // Remove the original name field + delete user.name; + } + }, +}; + +export default authjsTransformer; diff --git a/src/migrate/transformers/clerk.ts b/src/migrate/transformers/clerk.ts new file mode 100644 index 0000000..a59d00a --- /dev/null +++ b/src/migrate/transformers/clerk.ts @@ -0,0 +1,42 @@ +/** + * Transformer for migrating users from one Clerk instance to another + * + * Maps Clerk's user export format to the import format. + * Supports all Clerk user fields including identifiers, passwords, MFA settings, + * and metadata. + * + * @property {string} key - Transformer identifier used in CLI + * @property {string} value - Internal value for the transformer + * @property {string} label - Display name shown in CLI prompts + * @property {string} description - Detailed description shown in CLI + * @property {Object} transformer - Field mapping configuration + */ +const clerkTransformer = { + key: 'clerk', + value: 'clerk', + label: 'Clerk', + description: + 'If you are migrating from a development instance to production or to another Clerk application, you can export your users from the Dashboard and then use this option to migrate. See https://clerk.com/docs/guides/development/migrating/overview#export-your-users-data-from-the-clerk-dashboard for more information.', + transformer: { + id: 'userId', + primary_email_address: 'email', + verified_email_addresses: 'emailAddresses', + unverified_email_addresses: 'unverifiedEmailAddresses', + first_name: 'firstName', + last_name: 'lastName', + password_digest: 'password', + password_hasher: 'passwordHasher', + primary_phone_number: 'phone', + verified_phone_numbers: 'phoneNumbers', + unverified_phone_numbers: 'unverifiedPhoneNumbers', + username: 'username', + totp_secret: 'totpSecret', + backup_codes_enabled: 'backupCodesEnabled', + backup_codes: 'backupCodes', + public_metadata: 'publicMetadata', + unsafe_metadata: 'unsafeMetadata', + private_metadata: 'privateMetadata', + }, +}; + +export default clerkTransformer; diff --git a/src/migrate/transformers/index.ts b/src/migrate/transformers/index.ts new file mode 100644 index 0000000..3049901 --- /dev/null +++ b/src/migrate/transformers/index.ts @@ -0,0 +1,11 @@ +import clerkTransformer from './clerk'; +import auth0Transformer from './auth0'; +import authjsTransformer from './authjs'; +import supabaseTransformer from './supabase'; + +export const transformers = [ + clerkTransformer, + auth0Transformer, + authjsTransformer, + supabaseTransformer, +]; diff --git a/src/migrate/transformers/supabase.ts b/src/migrate/transformers/supabase.ts new file mode 100644 index 0000000..9fc76f6 --- /dev/null +++ b/src/migrate/transformers/supabase.ts @@ -0,0 +1,91 @@ +/** + * Transformer for migrating users from Supabase Auth + * + * Maps Supabase Auth user export format to Clerk's import format. + * Handles Supabase-specific features: + * - DateTime conversion (created_at: PostgreSQL format → ISO 8601) + * - Email confirmation status routing (email_confirmed_at) + * - Bcrypt encrypted passwords + * - Phone numbers + * + * @property {string} key - Transformer identifier used in CLI + * @property {string} value - Internal value for the transformer + * @property {string} label - Display name shown in CLI prompts + * @property {string} description - Detailed description shown in CLI + * @property {Object} transformer - Field mapping configuration + * @property {Function} postTransform - Custom transformation logic for datetime, email, and phone verification + * @property {Object} defaults - Default values applied to all users (passwordHasher: bcrypt) + */ +const supabaseTransformer = { + key: 'supabase', + value: 'supabase', + label: 'Supabase', + description: + 'This should be used when you have exported your users via https://supabase.com/docs/guides/auth/managing-user-data#exporting-users. If you have performed your own exported via SQL you will likely need to edit this transformer to match or create a new one.', + transformer: { + id: 'userId', + email: 'email', + email_confirmed_at: 'emailConfirmedAt', + first_name: 'firstName', + last_name: 'lastName', + encrypted_password: 'password', + phone: 'phone', + phone_confirmed_at: 'phoneConfirmedAt', + raw_user_meta_data: 'publicMetadata', + created_at: 'createdAt', + }, + postTransform: (user: Record) => { + // Handle created_at datetime conversion + // Convert from Supabase format (2024-06-29 20:25:06.126079+00) to ISO 8601 (2022-10-20T10:00:27.645Z) + const createdAt = user.createdAt as string | undefined; + if (createdAt) { + try { + const isoDate = new Date(createdAt).toISOString(); + user.createdAt = isoDate; + } catch { + // If conversion fails, leave the original value + // Schema validation will catch any invalid formats and log via validationLogger + } + } + + // Handle email verification + const emailConfirmedAt = user.emailConfirmedAt as string | undefined; + const email = user.email as string | undefined; + + if (email) { + if (emailConfirmedAt) { + // Email is verified - keep it as is + user.email = email; + } else { + // Email is unverified - move to unverifiedEmailAddresses + user.unverifiedEmailAddresses = email; + delete user.email; + } + } + + // Handle phone verification + const phoneConfirmedAt = user.phoneConfirmedAt as string | undefined; + const phone = user.phone as string | undefined; + + if (phone) { + if (phoneConfirmedAt) { + // Phone is verified - keep it as is + user.phone = phone; + } else { + // Email is unverified - move to unverifiedEmailAddresses + user.unverifiedPhoneNumbers = phone; + delete user.phone; + } + } + + // Clean up the emailConfirmedAt and phoneConfirmedAt fields as they aren't + // part of our schema + delete user.emailConfirmedAt; + delete user.phoneCofnirmedAt; + }, + defaults: { + passwordHasher: 'bcrypt' as const, + }, +}; + +export default supabaseTransformer; diff --git a/src/migrate/validator.test.ts b/src/migrate/validator.test.ts new file mode 100644 index 0000000..85ce5e8 --- /dev/null +++ b/src/migrate/validator.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from 'vitest'; +import { userSchema } from './validator'; +import { PASSWORD_HASHERS } from '../types'; + +describe('userSchema', () => { + describe('userId (required)', () => { + test('passes with userId and email', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: 'test@example.com', + }); + expect(result.success).toBe(true); + }); + + test('passes with userId and phone', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + phone: '+1234567890', + }); + expect(result.success).toBe(true); + }); + + test('fails when userId is missing', () => { + const result = userSchema.safeParse({ email: 'test@example.com' }); + expect(result.success).toBe(false); + }); + + test('fails with only userId (no email or phone)', () => { + const result = userSchema.safeParse({ userId: 'user_123' }); + expect(result.success).toBe(false); + }); + }); + + describe('email or phone requirement', () => { + test('passes with email only', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: 'test@example.com', + }); + expect(result.success).toBe(true); + }); + + test('passes with phone only', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + phone: '+1234567890', + }); + expect(result.success).toBe(true); + }); + + test('passes with emailAddresses only', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + emailAddresses: 'test@example.com', + }); + expect(result.success).toBe(true); + }); + + test('passes with phoneNumbers only', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + phoneNumbers: '+1234567890', + }); + expect(result.success).toBe(true); + }); + + test('fails without email or phone', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + firstName: 'John', + }); + expect(result.success).toBe(false); + }); + }); + + describe('email field', () => { + test('passes with email as string', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: 'test@example.com', + }); + expect(result.success).toBe(true); + }); + + test('passes with email as array', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: ['test@example.com', 'other@example.com'], + }); + expect(result.success).toBe(true); + }); + + test('fails with invalid email string', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: 'not-an-email', + phone: '+1234567890', // need valid contact method + }); + expect(result.success).toBe(false); + }); + + test('fails with invalid email in array', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: ['valid@example.com', 'not-an-email'], + phone: '+1234567890', // need valid contact method + }); + expect(result.success).toBe(false); + }); + }); + + describe('passwordHasher enum', () => { + test.each(PASSWORD_HASHERS)('passes with valid hasher: %s', (hasher) => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: 'test@example.com', + password: 'hashed_password', + passwordHasher: hasher, + }); + expect(result.success).toBe(true); + }); + + test('fails with invalid passwordHasher', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: 'test@example.com', + password: 'hashed_password', + passwordHasher: 'invalid_hasher', + }); + expect(result.success).toBe(false); + }); + + test('fails when password provided without passwordHasher', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: 'test@example.com', + password: 'hashed_password', + }); + expect(result.success).toBe(false); + }); + + test('passes without password or passwordHasher (with email)', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: 'test@example.com', + }); + expect(result.success).toBe(true); + }); + }); + + describe('phone fields', () => { + test('passes with phone as array', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + phone: ['+1234567890'], + }); + expect(result.success).toBe(true); + }); + + test('passes with phone as string', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + phone: '+1234567890', + }); + expect(result.success).toBe(true); + }); + + test('passes with phoneNumbers as array', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + phoneNumbers: ['+1234567890', '+0987654321'], + }); + expect(result.success).toBe(true); + }); + + test('passes without phone when email provided', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: 'test@example.com', + }); + expect(result.success).toBe(true); + }); + }); + + describe('boolean fields', () => { + test('passes with backupCodesEnabled boolean', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: 'test@example.com', + backupCodesEnabled: false, + }); + expect(result.success).toBe(true); + }); + }); + + describe('full user object', () => { + test('passes with all valid fields', () => { + const result = userSchema.safeParse({ + userId: 'user_123', + email: ['primary@example.com', 'secondary@example.com'], + username: 'johndoe', + firstName: 'John', + lastName: 'Doe', + password: '$2a$10$hashedpassword', + passwordHasher: 'bcrypt', + phone: ['+1234567890'], + totpSecret: 'JBSWY3DPEHPK3PXP', + backupCodesEnabled: true, + backupCodes: ['code1', 'code2', 'code3'], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.userId).toBe('user_123'); + expect(result.data.email).toEqual([ + 'primary@example.com', + 'secondary@example.com', + ]); + } + }); + }); +}); diff --git a/src/migrate/validator.ts b/src/migrate/validator.ts new file mode 100644 index 0000000..bb1be74 --- /dev/null +++ b/src/migrate/validator.ts @@ -0,0 +1,91 @@ +import * as z from 'zod'; +import { passwordHasherEnum } from '../types'; + +// ============================================================================ +// +// ONLY EDIT THIS IF YOU ARE ADDING A NEW FIELD +// +// Generally you only need to add or edit a transformer and do not need to +// touch any of the schema. +// +// ============================================================================ + +/** + * User validation schema for Clerk user imports + * + * Validates user data before sending to Clerk API. + * All fields are optional except: + * - userId is required (for tracking and logging) + * - passwordHasher is required when password is provided + * - user must have at least one verified identifier (email or phone) + * + * @remarks + * Fields can accept single values or arrays (e.g., email: string | string[]) + * Metadata fields accept any value for flexibility + */ +export const userSchema = z + .object({ + userId: z.string(), + // Email fields + email: z.union([z.email(), z.array(z.email())]).optional(), + emailAddresses: z.union([z.email(), z.array(z.email())]).optional(), + unverifiedEmailAddresses: z + .union([z.email(), z.array(z.email())]) + .optional(), + // Phone fields + phone: z.union([z.string(), z.array(z.string())]).optional(), + phoneNumbers: z.union([z.string(), z.array(z.string())]).optional(), + unverifiedPhoneNumbers: z + .union([z.string(), z.array(z.string())]) + .optional(), + // User info + username: z.string().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), + // Password + password: z.string().optional(), + passwordHasher: passwordHasherEnum.optional(), + // 2FA + totpSecret: z.string().optional(), + backupCodesEnabled: z.boolean().optional(), + backupCodes: z.array(z.string()).optional(), + // Metadata - accept any value + unsafeMetadata: z.any().optional(), + publicMetadata: z.any().optional(), + privateMetadata: z.any().optional(), + // Additional Clerk API fields + bypassClientTrust: z.boolean().optional(), + createOrganizationEnabled: z.boolean().optional(), + createOrganizationsLimit: z.number().int().optional(), + createdAt: z.string().optional(), + deleteSelfEnabled: z.boolean().optional(), + legalAcceptedAt: z.string().optional(), + skipLegalChecks: z.boolean().optional(), + skipPasswordChecks: z.boolean().optional(), + }) + .refine((data) => !data.password || data.passwordHasher, { + message: 'passwordHasher is required when password is provided', + path: ['passwordHasher'], + }) + .refine( + (data) => { + // Helper to check if field has value + const hasValue = (field: unknown): boolean => { + if (!field) return false; + if (typeof field === 'string') return field.length > 0; + if (Array.isArray(field)) return field.length > 0; + return false; + }; + // Must have either verified email or verified phone + const hasVerifiedEmail = + hasValue(data.email) || hasValue(data.emailAddresses); + const hasVerifiedPhone = + hasValue(data.phone) || hasValue(data.phoneNumbers); + return hasVerifiedEmail || hasVerifiedPhone; + }, + { + message: + 'User must have either a verified email or verified phone number', + path: ['email'], + } + ); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e7ddc8b --- /dev/null +++ b/src/types.ts @@ -0,0 +1,139 @@ +import type { ClerkAPIError } from '@clerk/types'; +import type { transformers } from './migrate/transformers'; +import type { userSchema } from './migrate/validator'; +import * as z from 'zod'; + +/** + * List of supported password hashing algorithms in Clerk + * + * When migrating users with existing passwords, specify which algorithm + * was used to hash the passwords so Clerk can validate them correctly. + */ +export const PASSWORD_HASHERS = [ + 'argon2i', + 'argon2id', + 'bcrypt', + 'bcrypt_peppered', + 'bcrypt_sha256_django', + 'hmac_sha256_utf16_b64', + 'md5', + 'md5_salted', + 'pbkdf2_sha1', + 'pbkdf2_sha256', + 'pbkdf2_sha256_django', + 'pbkdf2_sha512', + 'scrypt_firebase', + 'scrypt_werkzeug', + 'sha256', + 'sha256_salted', + 'md5_phpass', + 'ldap_ssha', + 'sha512_symfony', +] as const; + +/** + * User object validated against the user schema + */ +export type User = z.infer; + +/** + * Union type of all transformer keys (e.g., "clerk" | "auth0" | "supabase" | "authjs") + */ +export type TransformerMapKeys = (typeof transformers)[number]['key']; + +/** + * Union type of all transformer configuration objects + */ +export type TransformerMapUnion = (typeof transformers)[number]; + +/** + * Error information from a failed user creation attempt + * + * @property userId - The user ID that failed to import + * @property status - HTTP status or error status + * @property errors - Array of Clerk API error objects + */ +export type ErrorPayload = { + userId: string; + status: string; + errors: ClerkAPIError[]; +}; + +/** + * Validation error information for a user that failed schema validation + * + * @property error - Description of the validation error + * @property path - Path to the field that failed validation + * @property id - User ID of the invalid user + * @property row - Row number in the source file (0-indexed) + */ +export type ValidationErrorPayload = { + error: string; + path: (string | number)[]; + id: string; + row: number; +}; + +/** + * Formatted error log entry for file storage + * + * @property type - Type of error (e.g., "User Creation Error", "Validation Error") + * @property userId - The user ID associated with the error + * @property status - HTTP status or error status + * @property error - Error message + */ +export type ErrorLog = { + type: string; + userId: string; + status: string; + error: string | undefined; +}; + +/** + * Log entry for a user import attempt + * + * @property userId - The user ID + * @property status - Whether the import succeeded or failed + * @property error - Error message if import failed + */ +export type ImportLogEntry = { + userId: string; + status: 'success' | 'error'; + error?: string; +}; + +/** + * Summary statistics for a user import operation + * + * @property totalProcessed - Total number of users processed + * @property successful - Number of successful imports + * @property failed - Number of failed imports + * @property errorBreakdown - Map of error messages to occurrence counts + */ +export type ImportSummary = { + totalProcessed: number; + successful: number; + failed: number; + validationFailed: number; + errorBreakdown: Map; +}; + +/** + * Log entry for a user deletion attempt + * + * @property userId - The user ID + * @property status - Whether the deletion succeeded or failed + * @property error - Error message if deletion failed + */ +export type DeleteLogEntry = { + userId: string; + status: 'success' | 'error'; + error?: string; +}; + +/** + * Zod enum of supported password hashing algorithms + */ +export const passwordHasherEnum = z.enum( + PASSWORD_HASHERS as unknown as [string, ...string[]] +); diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 0000000..31d7032 --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from 'vitest'; +import { + checkIfFileExists, + createImportFilePath, + getDateTimeStamp, + getFileType, + getRetryDelay, + tryCatch, +} from './utils'; +import path from 'path'; + +describe('getDateTimeStamp', () => { + test('returns ISO format without milliseconds', () => { + const result = getDateTimeStamp(); + // Format: YYYY-MM-DDTHH:mm:ss + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + }); + + test('does not include milliseconds or timezone', () => { + const result = getDateTimeStamp(); + expect(result).not.toContain('.'); + expect(result).not.toContain('Z'); + }); + + test('returns current time (within 1 second)', () => { + const result = getDateTimeStamp(); + const now = new Date().toISOString().split('.')[0]; + // Compare date portion at minimum + expect(result.substring(0, 10)).toBe(now.substring(0, 10)); + }); +}); + +describe('createImportFilePath', () => { + test('creates path relative to project root', () => { + const result = createImportFilePath('/samples/test.json'); + expect(result).toContain('samples'); + expect(result).toContain('test.json'); + expect(path.isAbsolute(result)).toBe(true); + }); + + test('handles file without leading slash', () => { + const result = createImportFilePath('users.json'); + expect(result).toContain('users.json'); + expect(path.isAbsolute(result)).toBe(true); + }); +}); + +describe('checkIfFileExists', () => { + test('returns true for existing file', () => { + const result = checkIfFileExists('/samples/clerk.json'); + expect(result).toBe(true); + }); + + test('returns false for non-existent file', () => { + const result = checkIfFileExists('/samples/does-not-exist.json'); + expect(result).toBe(false); + }); + + test('returns false for non-existent directory', () => { + const result = checkIfFileExists('/fake-dir/fake-file.json'); + expect(result).toBe(false); + }); +}); + +describe('getFileType', () => { + test('returns application/json for .json files', () => { + const result = getFileType('/samples/clerk.json'); + expect(result).toBe('application/json'); + }); + + test('returns text/csv for .csv files', () => { + // Create path that would be a CSV + const result = getFileType('/samples/test.csv'); + expect(result).toBe('text/csv'); + }); + + test('returns false for unknown file types', () => { + const result = getFileType('/samples/test.xyz123'); + expect(result).toBe(false); + }); +}); + +describe('tryCatch', () => { + test('returns [data, null] on successful promise', async () => { + const promise = Promise.resolve('success'); + const [data, error] = await tryCatch(promise); + expect(data).toBe('success'); + expect(error).toBeNull(); + }); + + test('returns [null, error] on rejected promise with Error', async () => { + const promise = Promise.reject(new Error('test error')); + const [data, error] = await tryCatch(promise); + expect(data).toBeNull(); + expect(error).toBeInstanceOf(Error); + expect(error?.message).toBe('test error'); + }); + + test('throws non-Error throwables', async () => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + const promise = Promise.reject('string error'); + await expect(tryCatch(promise)).rejects.toBe('string error'); + }); + + test('works with async functions', async () => { + const asyncFn = () => { + return Promise.resolve({ id: 1, name: 'test' }); + }; + const [data, error] = await tryCatch(asyncFn()); + expect(data).toEqual({ id: 1, name: 'test' }); + expect(error).toBeNull(); + }); + + test('handles async function errors', async () => { + const asyncFn = () => { + return Promise.reject(new Error('async error')); + }; + const [data, error] = await tryCatch(asyncFn()); + expect(data).toBeNull(); + expect(error?.message).toBe('async error'); + }); +}); + +describe('getRetryDelay', () => { + const defaultDelayMs = 10000; // 10 seconds + + test('returns default delay when no retryAfter provided', () => { + const result = getRetryDelay(0, undefined, defaultDelayMs); + expect(result.delayMs).toBe(10000); + expect(result.delaySeconds).toBe(10); + }); + + test('uses retryAfter when provided', () => { + const result = getRetryDelay(0, 15, defaultDelayMs); + expect(result.delayMs).toBe(15000); + expect(result.delaySeconds).toBe(15); + }); + + test('returns default delay for any retry count when no retryAfter', () => { + const result = getRetryDelay(1, undefined, defaultDelayMs); + expect(result.delayMs).toBe(10000); + expect(result.delaySeconds).toBe(10); + }); + + test('uses retryAfter for any retry count when provided', () => { + const result = getRetryDelay(1, 15, defaultDelayMs); + expect(result.delayMs).toBe(15000); + expect(result.delaySeconds).toBe(15); + }); + + test('returns default delay for subsequent retries when no retryAfter', () => { + const result = getRetryDelay(2, undefined, defaultDelayMs); + expect(result.delayMs).toBe(10000); + expect(result.delaySeconds).toBe(10); + }); + + test('uses retryAfter for subsequent retries when provided', () => { + const result = getRetryDelay(3, 20, defaultDelayMs); + expect(result.delayMs).toBe(20000); + expect(result.delaySeconds).toBe(20); + }); + + test('works with different default delays', () => { + const customDefault = 5000; // 5 seconds + const result = getRetryDelay(2, undefined, customDefault); + expect(result.delayMs).toBe(5000); + expect(result.delaySeconds).toBe(5); + }); +}); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..3f2a0e6 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,194 @@ +import path from 'path'; +import mime from 'mime-types'; +import fs from 'fs'; + +/** + * Gets the current date and time in ISO format without milliseconds + * @returns A string in the format YYYY-MM-DDTHH:mm:ss + * @example + * getDateTimeStamp() // "2026-01-20T14:30:45" + */ +export const getDateTimeStamp = () => { + return new Date().toISOString().split('.')[0]; // YYYY-MM-DDTHH:mm:ss +}; + +/** + * Creates an absolute file path for import files relative to the project root + * @param file - The relative file path (e.g., "samples/users.json") + * @returns The absolute file path + */ +export const createImportFilePath = (file: string) => { + return path.join(__dirname, '..', file); +}; + +/** + * Checks if a file exists at the specified path + * @param file - The relative file path to check + * @returns True if the file exists, false otherwise + */ +export const checkIfFileExists = (file: string) => { + if (fs.existsSync(createImportFilePath(file))) { + return true; + } + return false; +}; + +/** + * Determines the MIME type of a file + * @param file - The relative file path + * @returns The MIME type of the file (e.g., "application/json", "text/csv") or false if unknown + */ +export const getFileType = (file: string) => { + return mime.lookup(createImportFilePath(file)); +}; + +/** + * Wraps a promise to return a tuple of [data, error] instead of throwing + * @template T - The type of the resolved promise value + * @param promise - The promise to wrap + * @returns A tuple containing either [data, null] on success or [null, error] on failure + * @throws Re-throws if the error is not an instance of Error + * @example + * const [data, error] = await tryCatch(fetchUsers()); + * if (error) console.error(error); + */ +export const tryCatch = async ( + promise: Promise +): Promise<[T, null] | [null, Error]> => { + try { + const data = await promise; + return [data, null]; + } catch (throwable) { + if (throwable instanceof Error) return [null, throwable]; + + throw throwable; + } +}; + +/** + * Selectively flattens nested objects based on transformer configuration + * + * Only flattens paths that are explicitly referenced in the transformer config. + * This allows transformers to map nested fields (e.g., "_id.$oid" in Auth0) to + * flat fields in the target schema. + * + * @param obj - The object to flatten + * @param transformer - The transformer config mapping source paths to target fields + * @param prefix - Internal parameter for recursive flattening (current path prefix) + * @returns Flattened object with dot-notation keys for nested paths + * + * @example + * const obj = { _id: { $oid: "123" }, email: "test@example.com" } + * const transformer = { "_id.$oid": "userId", "email": "email" } + * flattenObjectSelectively(obj, transformer) + * // Returns: { "_id.$oid": "123", "email": "test@example.com" } + */ +export function flattenObjectSelectively( + obj: Record, + transformer: Record, + prefix = '' +): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + const currentPath = prefix ? `${prefix}.${key}` : key; + + // Check if this path (or any nested path) is in the transformer + const hasNestedMapping = Object.keys(transformer).some((k) => + k.startsWith(`${currentPath}.`) + ); + + if ( + hasNestedMapping && + value && + typeof value === 'object' && + !Array.isArray(value) + ) { + // This object has nested mappings, so recursively flatten it + Object.assign( + result, + flattenObjectSelectively( + value as Record, + transformer, + currentPath + ) + ); + } else { + // Either it's not an object, or it's not mapped with nested paths - keep as-is + result[currentPath] = value; + } + } + + return result; +} + +/** + * Transforms data keys from source format to Clerk's import schema + * + * Maps field names from the source platform (Auth0, Supabase, etc.) to + * Clerk's expected field names using the transformer's configuration. + * Flattens nested objects as needed and filters out empty values. + * + * @template T - The transformer type being used for transformation + * @param data - The raw user data from the source platform + * @param transformerConfig - The transformer configuration with field mapping + * @returns Transformed user object with Clerk field names + * + * @example + * const auth0User = { "_id": { "$oid": "123" }, "email": "test@example.com" } + * const transformer = transformers.find(h => h.key === "auth0") + * transformKeys(auth0User, transformer) + * // Returns: { userId: "123", email: "test@example.com" } + */ +export function transformKeys< + T extends { transformer: Record }, +>( + data: Record, + transformerConfig: T +): Record { + const transformedData: Record = {}; + const transformer = transformerConfig.transformer; + + // Selectively flatten the input data based on transformer config + const flatData = flattenObjectSelectively(data, transformer); + + // Then apply transformations + for (const [key, value] of Object.entries(flatData)) { + if (value !== '' && value !== '"{}"' && value !== null) { + const transformedKey = transformer[key] || key; + transformedData[transformedKey] = value; + } + } + + return transformedData; +} + +/** + * Calculates the delay in milliseconds for rate limit retries + * + * Uses the Retry-After value from the API response if provided, + * otherwise falls back to the default delay. + * + * @param retryCount - The current retry attempt (0-indexed, unused but kept for API compatibility) + * @param retryAfterSeconds - Optional Retry-After value from response header + * @param defaultDelayMs - Default delay in milliseconds (typically 10000ms) + * @returns Object containing delayMs (milliseconds) and delaySeconds (for logging) + * + * @example + * const { delayMs, delaySeconds } = getRetryDelay(0, undefined, 10000); + * // Returns: { delayMs: 10000, delaySeconds: 10 } + * + * @example + * const { delayMs, delaySeconds } = getRetryDelay(1, 15, 10000); + * // Returns: { delayMs: 15000, delaySeconds: 15 } + */ +export function getRetryDelay( + retryCount: number, + retryAfterSeconds: number | undefined, + defaultDelayMs: number +): { delayMs: number; delaySeconds: number } { + // Use retryAfter from response or default delay for all retries + const delayMs = retryAfterSeconds ? retryAfterSeconds * 1000 : defaultDelayMs; + const delaySeconds = retryAfterSeconds || defaultDelayMs / 1000; + return { delayMs, delaySeconds }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ebe1a54 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2021"], + "types": ["bun-types"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "noEmit": true + }, + "include": ["src/**/*", "*.ts"], + "exclude": ["node_modules", "logs", "testing"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..94ede10 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({});