diff --git a/.changeset/migrate-custom-sources.md b/.changeset/migrate-custom-sources.md index fb9542f26..a7461d279 100644 --- a/.changeset/migrate-custom-sources.md +++ b/.changeset/migrate-custom-sources.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk migrate sources`, and let `clerk migrate import --source` take the path to a source you wrote. `clerk migrate sources` lists the built-in sources with the passwords, MFA and metadata each brings across, and `clerk migrate sources ` shows where each field lands, how to export, and any caveats. A source you wrote is a `.ts` or `.js` file in your project, checked before use, so a platform with no built-in source can be imported without rebuilding the CLI. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`. +Add `clerk migrate sources`, and let `clerk migrate import --source` take the path to a source you wrote. `clerk migrate sources` lists the built-in sources with the passwords, MFA, and metadata each brings across, and `clerk migrate sources ` shows where each field lands, how to export, and any caveats. A source you wrote is a `.ts` or `.js` file in your project, checked before use, so a platform with no built-in source can be imported without rebuilding the CLI. diff --git a/.changeset/migrate-reserve-unverified.md b/.changeset/migrate-reserve-unverified.md index c7e902bc1..e67fc9d9c 100644 --- a/.changeset/migrate-reserve-unverified.md +++ b/.changeset/migrate-reserve-unverified.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk migrate import --reserve-unverified`, which creates the emails and phones a source never verified as reserved (usable for sign-in, locked to the user) instead of unverified. At a terminal, the import asks. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`. +Add `clerk migrate import --reserve-unverified`, which creates the emails and phones an auth platform never verified as reserved (usable for sign-in, locked to the user) instead of unverified. At a terminal, the import asks. diff --git a/.changeset/migrate-user-limit.md b/.changeset/migrate-user-limit.md index 0a735aa35..607bc2604 100644 --- a/.changeset/migrate-user-limit.md +++ b/.changeset/migrate-user-limit.md @@ -2,4 +2,4 @@ "clerk": patch --- -Check `clerk migrate import` against the user limit Clerk reports for the instance, so a development instance Clerk has raised no longer rejects users it has room for. `CLERK_MIGRATE_DEV_USER_LIMIT` is now only a fallback for when Clerk doesn't report a limit, and `--json` says where the limit came from. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`. +Check `clerk migrate import` against the user limit Clerk reports for the instance, so a development instance Clerk has raised no longer rejects users it has room for. `CLERK_MIGRATE_DEV_USER_LIMIT` is now only a fallback for when Clerk doesn't report a limit, and `--json` says where the limit came from. diff --git a/.changeset/migrate.md b/.changeset/migrate.md new file mode 100644 index 000000000..83171bd84 --- /dev/null +++ b/.changeset/migrate.md @@ -0,0 +1,9 @@ +--- +"clerk": minor +--- + +Make `clerk migrate` available without `CLERK_EXPERIMENTAL=migrate`: it now shows in `clerk --help` and shell completion. + +- `clerk migrate export` gets users out of Clerk, Supabase, Firebase, Auth0, WorkOS, Better Auth, or Auth.js into a run, and `clerk migrate import` checks every user against your instance, asks before it writes, and continues a run that stopped partway. +- `clerk migrate runs` shows what each run did, `clerk migrate undo` deletes the users an import created, and `clerk migrate sources` shows what each source brings across. `--source ./my-source.ts` imports with a source you wrote. +- Firebase phones import verified, because Firebase has no phone-verified field. diff --git a/.claude/rules/commands.md b/.claude/rules/commands.md index b8e27f170..72731a63a 100644 --- a/.claude/rules/commands.md +++ b/.claude/rules/commands.md @@ -22,6 +22,18 @@ When creating or modifying a command, evaluate whether it needs an agent mode. C When adding or modifying a command, ensure tab-completion stays correct. See `.claude/rules/completion.md` for the full guide. Short version: use `.choices()` on arguments and options so completions are automatic; only touch `__complete.ts` when `.choices()` is not suitable. +## Shipping a command behind `CLERK_EXPERIMENTAL` + +A command can merge to `main`, and reach `@canary` and releases, before it's ready, behind a name in `CLERK_EXPERIMENTAL`. `src/lib/experimental.ts` holds the gate: `isExperimentEnabled(name)` and `requireExperiment(name)`. `clerk migrate` shipped this way, one slice per PR. + +To gate a command: + +1. In its `register`, check `isExperimentEnabled(name, env)` before building the tree. When it's off, register a hidden stub instead, and return: `program.command(name, { hidden: true })` with `.helpOption(false)`, `.allowUnknownOption()`, `.allowExcessArguments()`, `.argument("[args...]")`, and an action that calls `requireExperiment(name, env)`. Override the stub's `helpInformation` to call `requireExperiment` too, so `clerk help ` refuses. The stub has no subcommands, so it stays out of `clerk --help` and completion, and every invocation exits 2 with `experiment_disabled`, naming the variable to set. +2. Set `CLERK_EXPERIMENTAL=` in the command's tests and E2E runs. +3. Say so in its README and every changeset: the command is experimental and needs `CLERK_EXPERIMENTAL=`. + +To ship it, remove the stub and the tests' `CLERK_EXPERIMENTAL` setup, add the command to the root README's help output, and write a changeset that announces it. Drop the "experimental" sentence from any of its earlier changesets that haven't been released yet. Keep `experimental.ts` for the next gated command. + ## Root README `README.md` at the project root contains the CLI help output. When commands are added, removed, or their options change, update the help output in `README.md` to stay in sync. You can regenerate it by running `bun run src/cli.ts --help`. diff --git a/README.md b/README.md index 19395df56..7773fc564 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Commands: init [options] Initialize Clerk in your project link [options] Link this project to a Clerk application mcp Manage the Clerk remote MCP server connection for AI editors and CLIs + migrate Migrate users into Clerk from another auth platform or another Clerk instance open Open Clerk resources in your browser security Audit an instance against Clerk's security recommendations telemetry Control CLI usage telemetry (status, disable, enable) diff --git a/packages/cli-core/src/commands/init/scan.test.ts b/packages/cli-core/src/commands/init/scan.test.ts index 0f3714aff..57128d7f1 100644 --- a/packages/cli-core/src/commands/init/scan.test.ts +++ b/packages/cli-core/src/commands/init/scan.test.ts @@ -25,7 +25,7 @@ describe("detectAuthLibraries", () => { runDetectAuthLibraries({ "next-auth": "5.0.0", next: "15.0.0" }); const output = captured.out; expect(output).toContain("NextAuth"); - expect(output).toContain("clerk.com/docs/migrations/nextauth"); + expect(output).toContain("clerk.com/docs/guides/development/migrating/authjs"); }); test("detects Auth0 via @auth0/nextjs-auth0", () => { diff --git a/packages/cli-core/src/commands/init/scan.ts b/packages/cli-core/src/commands/init/scan.ts index 25a246517..803a73bd8 100644 --- a/packages/cli-core/src/commands/init/scan.ts +++ b/packages/cli-core/src/commands/init/scan.ts @@ -27,32 +27,32 @@ const AUTH_LIBRARY_SCANS: AuthLibraryScan[] = [ { packages: ["next-auth"], name: "NextAuth / Auth.js", - docsUrl: "https://clerk.com/docs/migrations/nextauth", + docsUrl: "https://clerk.com/docs/guides/development/migrating/authjs", }, { packages: ["@auth0/nextjs-auth0", "auth0"], name: "Auth0", - docsUrl: "https://clerk.com/docs/migrations/auth0", + docsUrl: "https://clerk.com/docs/guides/development/migrating/auth0", }, { packages: ["@supabase/ssr", "@supabase/auth-helpers-nextjs"], name: "Supabase Auth", - docsUrl: "https://clerk.com/docs/migrations/supabase", + docsUrl: "https://clerk.com/docs/guides/development/migrating/supabase", }, { packages: ["firebase"], name: "Firebase", - docsUrl: "https://clerk.com/docs/migrations/firebase", + docsUrl: "https://clerk.com/docs/guides/development/migrating/firebase", }, { packages: ["passport"], name: "Passport.js", - docsUrl: "https://clerk.com/docs/migrations/overview", + docsUrl: "https://clerk.com/docs/guides/development/migrating/custom-source", }, { packages: ["better-auth"], name: "Better Auth", - docsUrl: "https://clerk.com/docs/migrations/overview", + docsUrl: "https://clerk.com/docs/guides/development/migrating/better-auth", }, { // AuthKit only: `@workos-inc/node` also serves SSO, Directory Sync and @@ -65,12 +65,12 @@ const AUTH_LIBRARY_SCANS: AuthLibraryScan[] = [ "@workos-inc/authkit-js", ], name: "WorkOS", - docsUrl: "https://clerk.com/docs/guides/development/migrating/overview", + docsUrl: "https://clerk.com/docs/guides/development/migrating/workos", }, { packages: ["@kinde-oss/kinde-auth-nextjs"], name: "Kinde", - docsUrl: "https://clerk.com/docs/migrations/overview", + docsUrl: "https://clerk.com/docs/guides/development/migrating/custom-source", }, ]; @@ -129,7 +129,7 @@ const CODE_SCANS: CodeScan[] = [ // out. The `sso` and `directorySync` modules alone hold no users. pattern: "\\bworkos\\.userManagement\\b", message: "WorkOS User Management still in use", - docsUrl: "https://clerk.com/docs/guides/development/migrating/overview", + docsUrl: "https://clerk.com/docs/guides/development/migrating/workos", }, { pattern: "getServerSession\\s*\\(", diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 3c806407d..08586914f 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -1,22 +1,11 @@ # `clerk migrate` -> **Experimental.** `clerk migrate` is hidden from help and completion, and -> refuses to run (exit 2, `experiment_disabled`), unless `CLERK_EXPERIMENTAL` -> includes `migrate`: -> -> ```sh -> CLERK_EXPERIMENTAL=migrate clerk migrate import --help -> ``` -> -> `CLERK_EXPERIMENTAL` is a comma-separated list. Names are trimmed and -> case-insensitive, and unknown names are ignored. - -Migrate users into a Clerk instance from another auth provider, or from another +Migrate users into a Clerk instance from another auth platform, or from another Clerk instance. ``` -clerk migrate export [source] [-o ] [--json] -clerk migrate import [--source ] [--dry-run] [--allow-partial] [--new-run] [--yes] [--json] +clerk migrate export [platform] [-o ] [--json] +clerk migrate import [file|export-run-id] [--source ] [--dry-run] [--allow-partial] [--new-run] [--yes] [--json] clerk migrate runs [run-id] [--json] clerk migrate undo [--dry-run] [--yes] [--include-signed-in] [--json] clerk migrate sources [source] [--json] @@ -32,8 +21,8 @@ clerk migrate import 20260929-141502-a1b2 --yes # 3. import it ``` `clerk migrate undo ` takes an import back out, and `clerk migrate runs` -shows what every run did. Every subcommand takes `--runs-dir ` (or -`CLERK_MIGRATE_DIR`) to keep its runs somewhere else. +shows what every run did. Every subcommand but `sources` takes `--runs-dir ` +(or `CLERK_MIGRATE_DIR`) to keep its runs somewhere else. `clerk migrate` on its own is a group name, not a command: it prints its help. ## The rules @@ -51,9 +40,9 @@ Every command follows these: be dropped are warnings. 3. **State lives in one place: the [run store](#the-run-store).** Each run records its target, its file, and every source ID → Clerk ID outcome, - including the error for each user who failed. `runs`, `undo`, re-runs and + including the error for each user who failed. `runs`, `undo`, re-runs, and exports all read or write it. -4. **Every command prints its target first:** the environment, app and +4. **Every command prints its target first:** the environment, app, and instance, and where the key came from. 5. **Every subcommand takes `--json`.** Exit codes: `0` all good, `1` some users failed, `2` a usage error or a refusal, and `130` (death by SIGINT) when @@ -101,7 +90,7 @@ instance — its environment, its app when the key came from one, and its ID from `GET /v1/instance` — and where the key came from: `--secret-key`, `--app`, the `CLERK_SECRET_KEY` env var, an accountless app's `.env.local`, or the linked -profile. An export names its source platform instead, and `export clerk` the +profile. An export names its auth platform instead, and `export clerk` the instance it reads. `runs` names the runs folder. `--json` carries the same facts as `target`. @@ -115,11 +104,12 @@ the key now in use still addresses the same instance. ## The run store -Every import, export and undo that gets as far as writing is a **run**, and the +Every import, export, and undo that gets as far as writing is a **run**, and the run store is the one place `clerk migrate` keeps state. A dry run, a refusal, a -run that needs consent and an empty file write none (`run: null`). An import -whose users `--require-password` all leaves out still writes one, recording them -as skipped. +run that needs consent, and an empty file write none (`run: null`). When the +import continues an earlier run, `run` is that run, and an empty file settles +it. An import that goes ahead writes one even when `--require-password` leaves +out every user, recording them as skipped. ### Where runs are kept @@ -139,11 +129,11 @@ leaves `.gitignore` alone. Each run is a folder named for its ID, `YYYYMMDD-HHmmss-xxxx`: -| File | Contents | -| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `run.json` | Kind, status, start and finish times, the target, the source, the file and its sha256, an import's flags, and the counts | -| `users.ndjson` | One line per user outcome: `sourceId`, `clerkId`, `status`, and `reason`, `error`, `code`, `pending` or `passwordDropped` when present | -| `lock` | The PID of the process writing the run, while it runs | +| File | Contents | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `run.json` | Kind, status, start and finish times, the target, the source, the file and its sha256, an import's flags, and the counts | +| `users.ndjson` | One line per user outcome: `sourceId`, `clerkId`, `status`, and `reason`, `error`, `code`, `pending`, or `passwordDropped` when present | +| `lock` | The PID of the process writing the run, while it runs | On a `failed` line, `code` is the HTTP status, such as `422` or `429`, or `unknown`. On a `skipped` line it gives the reason in machine form: @@ -152,7 +142,7 @@ checks skipped for the file's own data (invalid, a duplicate within it, an email Clerk refuses, a malformed hash), or `source_skip` for one its source asked to skip. -A user's status is `creating`, `created`, `failed`, `skipped`, `deleted` or +A user's status is `creating`, `created`, `failed`, `skipped`, `deleted`, or `exported`. The last line for each `sourceId` wins. A `429` retry, an extra email or phone that did not attach, a first phone Clerk refused (which the summary also counts), and a validation failure all land in the line's `error` @@ -188,12 +178,12 @@ and the instance, not the source, so a second import of the same file into the same instance is refused with exit 2 even before either has written a run, whichever source and `--runs-dir` each uses. It also takes a lock under its key, for when Clerk can't name the instance. When nothing names it (Clerk, -`--app` or the linked profile), that's the only lock it holds, so an import +`--app`, or the linked profile), that's the only lock it holds, so an import with a second key for that instance doesn't meet it. It lives outside the runs folder because nothing may be written there before consent. It holds the importing process's PID and is removed when the import finishes. One left by a Ctrl-C or a process that died is reclaimed by the next import. Two imports that start together over a stale lock -can both take it; Clerk still refuses the second create of any email, phone or +can both take it; Clerk still refuses the second create of any email, phone, or username, so the cost is failed lines, not duplicate users. The refusal names the file, which is safe to delete when no `clerk migrate import` is running. @@ -224,12 +214,12 @@ grep '"sourceId":"user_123"' .clerk/migrate/20260929-141502-a1b2/users.ndjson ## Commands The direction is always spelled out — `migrate import` moves users **into** -Clerk, `migrate export` gets them **out** of a source platform — so neither is +Clerk, `migrate export` gets them **out** of an auth platform — so neither is implied by the group. ### `clerk migrate export` -Gets users **out** of a source platform, so there is something to feed +Gets users **out** of an auth platform, so there is something to feed `clerk migrate import`. ```sh @@ -250,7 +240,7 @@ what Auth0 needs (a tenant domain and M2M credentials) has nothing in common with what a database export needs. **A credential the far end rejects is asked for again.** Connection strings, -Firebase service account keys and Auth0 client secrets are all long, pasted by +Firebase service account keys, and Auth0 client secrets are all long, pasted by hand, masked as they are typed, and wrong in ways nothing local can check: a typo'd host, a revoked key, an expired token, the right server but the wrong database. Only the connection or the token exchange can say, and by then the operator has answered every @@ -301,7 +291,7 @@ users, in the platform's own shape, so it can bring across what the built-in mapping leaves out, such as a WorkOS `metadata.username`. The import keeps its link to the export run, and `run.json` records the source the envelope named as `envelopeSource`. A Firebase export keeps its hash parameters through it: -the envelope's, the `--firebase-*` flags or the variables reach the source's +the envelope's, the `--firebase-*` flags, or the variables reach the source's `postTransform` as `context.firebaseHashConfig`, as they do the built-in's. The checks still read a Supabase export's sign-in providers from its raw rows, and count an Auth0 or Auth.js export's the way the built-in does. @@ -319,11 +309,11 @@ keep `--runs-dir`: the import looks the run ID up there. | Flag | Platforms | Description | | -------------------------- | ---------------------------------- | ----------------------------------------------------------------- | | `-o, --output ` | all | Write the export here instead of the run folder | -| `-y, --yes` | all | Do not prompt: fail on a bad credential | +| `-y, --yes` | all | Do not prompt: fail on a rejected credential | | `--json` | all | Print the result as JSON; never prompts | | `--runs-dir ` | all | Where runs are kept (see [the run store](#the-run-store)) | | `--db-url ` | `supabase`, `authjs`, `betterauth` | Connection string (Postgres only for `supabase`) | -| `--service-account ` | `firebase` | Path to a service account key JSON file | +| `--service-account ` | `firebase` | Service account key: a path to its JSON file, or the JSON itself | | `--domain ` | `auth0` | Tenant domain, e.g. `my-tenant.us.auth0.com`, not a custom domain | | `--client-id ` | `auth0` | Machine-to-machine application client ID | | `--client-secret ` | `auth0` | Machine-to-machine application client secret | @@ -351,7 +341,7 @@ back into itself. Instead: application's instances come **last**, marked `(linked: usually the destination)`: it is usually where the users are going, and the first row is what a reflexive Enter takes. When there are no instances to offer, it exits - 2 naming `--secret-key`, `--app`/`--instance` and `CLERK_SECRET_KEY`. When + 2 naming `--secret-key`, `--app`/`--instance`, and `CLERK_SECRET_KEY`. When listing your applications needs a login (an accountless app, or an expired session), it asks whether to export the resolved instance instead. - With nothing to resolve at all (no link, no key, no flags), you get the same @@ -363,11 +353,11 @@ application has no users to export. Its rows are searchable by what they show, so typing an application name, `production`, or an instance id all narrow it. -With nobody to ask — agent mode, `--json` or `-y` — an instance resolved on +With nobody to ask — agent mode, `--json`, or `-y` — an instance resolved on your behalf is never taken: the export exits 2 and names the flags, since exporting the destination and importing it back is the mistake this guards against. The same goes for nothing to resolve at all. Name the source with -`--secret-key`, `--app`/`--instance` or `CLERK_SECRET_KEY`. And `clerk migrate +`--secret-key`, `--app`/`--instance`, or `CLERK_SECRET_KEY`. And `clerk migrate import` refuses an export run of the instance it would import into: every user is already there. @@ -398,7 +388,7 @@ would otherwise be told what was exported and never how to import it. There is one command, not a development and a production variant, because no flag's absence means "development" — the resolved key decides, through -`--secret-key`, `--app`, `CLERK_SECRET_KEY`, the keyless project and the linked +`--secret-key`, `--app`, `CLERK_SECRET_KEY`, the keyless project, and the linked profile in that order. The export run has one line per exported user, so `clerk migrate runs` lists it @@ -406,7 +396,7 @@ alongside imports. #### Platforms that export no passwords -- **Clerk** never returns password digests, TOTP secrets or backup codes over +- **Clerk** never returns password digests, TOTP secrets, or backup codes over the API — only the `*_enabled` booleans. Migrated users must reset their password in the destination instance. - **Auth0**'s Management API does not return password hashes either; they come @@ -445,7 +435,9 @@ only opens local files and `@libsql/client` ships native optional dependencies. Nothing native ships in the binary — that is the whole reason the `engines.bun` floor exists. Resolution is `--db-url`, then `SUPABASE_DB_URL` / `AUTHJS_DB_URL` / `BETTERAUTH_DB_URL`, then a masked prompt, since a connection string carries the password inline. A password -pasted unencoded (`#`, `@`, `/` and the like) is percent-encoded for you. +pasted unencoded (`#`, `@`, `/`, and the like) is percent-encoded for you. A libsql +token comes from `?authToken=` on the URL, or from `TURSO_AUTH_TOKEN` / +`LIBSQL_AUTH_TOKEN`, and the CLI redacts it like a password. **Connection strings are redacted everywhere.** Errors show `postgres://***@host/db`, including when the password itself contains an @@ -483,7 +475,7 @@ which the export does not read, even a password column on the user table: migrate those separately, or have those users reset their password. It also reads the account table beside the user table (`Account`, `account`, -`accounts` or `next_auth.accounts`) for each user's OAuth providers, kept in +`accounts`, or `next_auth.accounts`) for each user's OAuth providers, kept in the export as `providers` and printed as an **OAuth providers** block. The import counts them in its checks, which offer to turn on any Clerk has off. @@ -543,11 +535,11 @@ hashes themselves: the same permission covers both. It says which permission is missing. A redacted hash can't be brought back by anything the import is given, so to keep passwords, grant the permission and export again. When the hashes came through and only the parameters are missing, the import needs -`--firebase-signer-key`, `--firebase-salt-separator`, `--firebase-rounds` and +`--firebase-signer-key`, `--firebase-salt-separator`, `--firebase-rounds`, and `--firebase-mem-cost` (from **Authentication → Users → ⋮ → Password hash parameters** in the Firebase console). Each flag can come from its environment variable instead, `CLERK_FIREBASE_SIGNER_KEY`, -`CLERK_FIREBASE_SALT_SEPARATOR`, `CLERK_FIREBASE_ROUNDS` and +`CLERK_FIREBASE_SALT_SEPARATOR`, `CLERK_FIREBASE_ROUNDS`, and `CLERK_FIREBASE_MEM_COST`, which keeps the signer key out of `ps`. Set it from a secret manager or a file (`export CLERK_FIREBASE_SIGNER_KEY="$(cat signer.key)"`) rather than typing the key, or it lands in shell history anyway. A flag wins over the @@ -564,9 +556,11 @@ file _or_ the key's JSON pasted whole, so a key kept in a password manager or a CI secret never has to be written to disk. The prompt is masked, since the key carries a private key. Agent mode cannot prompt, so it names the flag instead. -Either way the key is validated before anything reaches the network, so -downloading the web app config by mistake fails in a second with the right -console page named rather than after an auth round-trip. Key material never +Either way the CLI checks the key before anything reaches the network, so the +wrong file fails in a second rather than after an auth round-trip. A key whose +`type` isn't `service_account` fails naming the console page to download one +from (Project settings → Service accounts). A web app config, which has no +`type`, fails with `"project_id" is missing`. Key material never appears in output. Firebase's scrypt is a modified variant, so a digest is worthless without the @@ -618,7 +612,7 @@ than Auth0 counted. Either way the file holds what came back, `--json` carries importing it by run ID warns that it is not every user, among the checks' warnings, so `--json` carries it too. The bulk job downloads gzipped NDJSON: unzip it before importing. Ask the job for `user_metadata`, -`app_metadata` and `blocked` by name, since a job with no `fields` leaves +`app_metadata`, and `blocked` by name, since a job with no `fields` leaves them out. Pages are read in `created_at` order, and a user a shifted page repeats is @@ -626,7 +620,7 @@ exported once. A `429` backs off, honouring `Retry-After`, as the import does; a free or development tenant allows only a few Management API requests a second. -A credential the platform rejects (400, 401 or 403) is asked for again at a +A credential the platform rejects (400, 401, or 403) is asked for again at a terminal, and so is an Auth0 token whose granted scopes leave out `read:users`. When Auth0's token response lists no scopes, the first users request finds the missing permission instead, and exits. An outage, a refused connection, or a @@ -637,7 +631,7 @@ not fix it, and it exits 1. Needs a secret API key — the one starting `sk_`, from the WorkOS dashboard under API Keys. Resolved from `--api-key`, then `WORKOS_API_KEY`, then a -prompt at an interactive terminal; `--json`, `-y` and agent mode exit naming +prompt at an interactive terminal; `--json`, `-y`, and agent mode exit naming both instead. There is no `--db-url` sibling because there is no database to point it at. @@ -734,7 +728,7 @@ clerk migrate import # a human is asked | `--json` | Output as JSON. Never prompts, so importing needs `--yes` | | `--runs-dir ` | Where runs are kept (see [the run store](#the-run-store)) | -Plus the targeting flags from the table above: `--secret-key`, `--app` and +Plus the targeting flags from the table above: `--secret-key`, `--app`, and `--instance`. An export run ID stands for the file that run wrote: if that file is gone, or @@ -758,7 +752,7 @@ what to pass. Without either — an agent, a non-TTY run, `--json` — the run prints the checks and exits 2 with the exact command to run. Printed commands shell-quote their paths, keep `--json`, and put `` in place of a secret key and -`SIGNER_KEY`, `SALT_SEPARATOR`, `ROUNDS` and `MEM_COST` in place of the +`SIGNER_KEY`, `SALT_SEPARATOR`, `ROUNDS`, and `MEM_COST` in place of the Firebase parameters. **Every run prints its target first**, then which [case](#re-running) applies, @@ -780,7 +774,7 @@ something to retry: fix the file, which makes a new run. `--json` returns `{ target, run, resume, checks, result }`. When a run stops before importing, it carries one of `dryRun: true`, `refused: true`, -`consent: "required"` or `nothingToImport: true` in place of `result`, and a +`consent: "required"`, or `nothingToImport: true` in place of `result`, and a file already imported in full returns `alreadyImported: true`. With `--require-password`, `withoutPassword` counts the users it left out before the checks, so on a first import `checks.total` plus it is the file's size. A @@ -849,7 +843,7 @@ after them. They sort the users four ways: - it failed schema validation - its only emails are ones Clerk refuses: malformed, or a domain that can't receive mail (`.local`, `.invalid`, `.test`, `.example`, `.arpa`, - `.internal`, `.lan`, `.corp` and the like). Such an email is dropped from + `.internal`, `.lan`, `.corp`, and the like). Such an email is dropped from any other user, with a warning - its only phones are not in E.164 form, and the instance has numeric usernames on, which makes Clerk require E.164. Such a phone is dropped from @@ -870,7 +864,7 @@ after them. They sort the users four ways: that turned off. Importing it without them would take away its second factor, so the checks offer to turn the setting on instead - it has no password, and password is the instance's only way to sign in - (no code, link, social or SSO sign-in; a passkey or a password reset does + (no code, link, social, or SSO sign-in; a passkey or a password reset does not count, as in Clerk) - it has no legal acceptance on record, and the instance requires legal consent. `--skip-legal-checks`, or a yes at the prompt, imports these users @@ -882,19 +876,19 @@ after them. They sort the users four ways: form and the instance has phones on, since the setting would make Clerk refuse them; the reject counts them - its password is not the shape its hasher says (`bcrypt`, with a cost of 4 - to 15, `scrypt_firebase`, `argon2i`/`argon2id` and `scrypt_werkzeug` are + to 15, `scrypt_firebase`, `argon2i`/`argon2id`, and `scrypt_werkzeug` are checked; other hashers are not, and Clerk refuses a bad one at create) - Supabase: its only providers are ones Clerk has off, or doesn't offer at all (Figma, Kakao, Keycloak, WorkOS, Zoom, Fly), and it has no verified or reserved email or phone the instance signs in with by code or link. The checks offer to turn on the first kind; nothing can turn on the second - - its source ID, primary email, primary phone or username repeats an + - its source ID, primary email, primary phone, or username repeats an earlier user in the file that passes the checks above. Only what the create sends counts: an extra email is attached after it, and a stripped one never goes out. Usernames compare case-insensitively and phones ignore punctuation. The first record in the file is kept, whatever either holds, and the reject names it (`kept: …`) - - the instance already has a user with its source ID, email, phone or + - the instance already has a user with its source ID, email, phone, or username (a batched `GET /v1/users` lookup, 100 values a request, through the scheduler). A user a continued run found behind its own interrupted create does not count @@ -906,20 +900,20 @@ after them. They sort the users four ways: store: …`), passwords a source had to drop, emails Clerk refuses (malformed, or a domain that can't receive mail), and names Clerk refuses (a phone number, a URL, HTML, blank, or over 256 bytes: Better Auth's phone sign-up - stores the number as the name). A password, username or name whose setting + stores the number as the name). A password, username, or name whose setting is off is stored, and works or shows once it is turned on. - **Imported** — everyone else. Any reject stops the import, and it exits 2 with the command that adds `--allow-partial`. With `--allow-partial`, the rest import and each reject is recorded as `skipped` with its reason. `--dry-run` exits 2 when the real run -would be refused, and 0 otherwise. +would be refused, 1 on an API, auth, or runtime error, and 0 otherwise. ``` Checks 120 users checked ✗ 12 users rejected - 12: only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it) + 12: only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the auth platform never verified it) u_17, u_22, u_40, u_51, u_88, and 7 more ⚠ Imported, but not everything comes across 6 users have a username, which this instance does not use: it is stored, and works only once usernames are turned on @@ -950,8 +944,8 @@ so too, rather than checking the headroom against zero. #### Sign-up restrictions Every create sends `skip_restriction_checks: true`. The instance's allowlist, -blocklist, disposable-email and subaddress rules police new sign-ups, and these -users already signed up on the source platform. +blocklist, disposable-email, and subaddress rules police new sign-ups, and these +users already signed up on the auth platform. #### Additional identifiers @@ -996,7 +990,7 @@ instances have none: Clerk reports `null` for them and won't set one. An API that predates the field leaves a development instance at `CLERK_MIGRATE_DEV_USER_LIMIT`, or 100. `--allow-partial` imports up to the headroom. When a limit applies, `--json`'s -`checks.quota` says where it came from (`limitSource`: `instance`, `env` or +`checks.quota` says where it came from (`limitSource`: `instance`, `env`, or `default`). When no limit applies, `checks.quota` is omitted. Users that do exceed the limit come back in the error breakdown as @@ -1025,7 +1019,7 @@ clerk migrate runs --json | `--runs-dir ` | Read runs from somewhere else | It prints the runs folder first. The listing shows each run's ID, date, kind, -status, target, file and counts. `runs ` adds the error breakdown and the +status, target, file, and counts. `runs ` adds the error breakdown and the users that failed, were skipped, or are `creating` with no answer to confirm the create (`unconfirmed` in `--json`), with the path to the full record, then what to run next: for an interrupted or partial import, the import that continues it, @@ -1075,14 +1069,14 @@ clerk migrate undo 20260929-141502-a1b2 --json --yes | `--json` | Output as JSON. Never prompts, so deleting needs `--yes` | | `--runs-dir ` | Read runs from somewhere else | -Plus the targeting flags: `--secret-key`, `--app` and `--instance`. +Plus the targeting flags: `--secret-key`, `--app`, and `--instance`. It prints the target first, then a preview: how many users will be deleted, and how many of them have signed in since the import (from each user's `last_sign_in_at`). When users remain to delete or record as gone, nothing is deleted without consent: a yes at the prompt, or `--yes`. Without either — an agent, a non-TTY run, or `--json` — it prints the preview and exits 2 with the -command to run. That command keeps `--runs-dir`, `--app`, `--instance` and +command to run. That command keeps `--runs-dir`, `--app`, `--instance`, and `--secret-key` (as ``) when they were passed, or it would look in another runs folder or reach another instance. When none remain, undo completes without prompting. @@ -1128,7 +1122,7 @@ object: `import` is the run being undone. `run` is the undo's own run, `null` until one is written, as an import's is. Once the deletes run, `run` is the undo run, and -`result` holds `deleted`, `failed` and an `errors` breakdown. When undo stops +`result` holds `deleted`, `failed`, and an `errors` breakdown. When undo stops before that, the object says why: - `"dryRun": true`: a dry run. @@ -1198,7 +1192,7 @@ needs to. Every source shows the same note instead: enable the same providers in Clerk, and a user who signs in with one is linked to their imported account by verified email. See [account linking](https://clerk.com/docs/guides/configure/auth-strategies/social-connections/account-linking). -The import's checks count the providers in a Supabase, Auth0 or Auth.js file and offer +The import's checks count the providers in a Supabase, Auth0, or Auth.js file and offer to enable any that are off. Auth0's come from each `user_id`'s prefix (`github|…`), so a user who linked a second provider counts under the first. @@ -1206,7 +1200,7 @@ to enable any that are off. Auth0's come from each `user_id`'s prefix `clerk migrate import` takes `--source `: -- A value starting with `./`, `../` or `/`, or ending in `.ts`, `.js` or +- A value starting with `./`, `../`, or `/`, or ending in `.ts`, `.js`, or `.mjs`, is loaded as a [custom source](#custom-sources). - Anything else must be a built-in key. An unknown key exits 2 with the list of valid keys. @@ -1233,7 +1227,7 @@ export default { description: "Exports from My Platform's admin console.", transformer: { account_ref: "userId", // required: becomes the Clerk user's external_id - // Created verified: map an address the source never confirmed to "unverifiedEmailAddresses". + // Created verified: map an address the auth platform never confirmed to "unverifiedEmailAddresses". contact_email: "email", pw_bcrypt: "password", // full_name is left unmapped: postTransform splits it below. @@ -1264,10 +1258,10 @@ Two optional hooks run around the `transformer` mapping: ignored. It must be synchronous: an `async` hook, or one that returns a promise, stops the import, since its changes would arrive after the user was checked. One that throws stops the import with the source's key, the - row number and the error. `context` holds per-run values a built-in needs, + row number, and the error. `context` holds per-run values a built-in needs, such as Firebase's hash parameters; a custom source can ignore it. - `preTransform(filePath, fileType)` runs once, before the file is read, and - may be `async`. `fileType` is `"application/json"`, `"text/csv"` or `""`. + may be `async`. `fileType` is `"application/json"`, `"text/csv"`, or `""`. Return `{ filePath, data?, csvHeaders? }`: `filePath` is the file to read (the same one, or one you wrote in the same format as `fileType`), `data` is the users when you parsed them yourself and skips reading the file, and `csvHeaders` names the columns of @@ -1278,7 +1272,7 @@ A `transformer` target that isn't a Clerk field, like `full_name: "name"`, is only a rename: Clerk won't store it unless `postTransform` moves it. TypeScript is fine — Bun's transpiler is part of the runtime, so `interface`, -`satisfies` and `as const` all work in a file the compiled binary imports. +`satisfies`, and `as const` all work in a file the compiled binary imports. Plain `.js` works too. An import run records a custom source's key and a hash of the file, so an @@ -1314,7 +1308,7 @@ migration re-runnable. ### Metadata -Metadata a user can edit on the source platform — Auth0's `user_metadata`, +Metadata a user can edit on the auth platform — Auth0's `user_metadata`, Supabase's `raw_user_meta_data` — goes to Clerk's `unsafe_metadata`, which is the user-editable one. Public metadata is read-only to the user, so putting it there would take away an edit the user had. Metadata only the source's backend @@ -1336,12 +1330,12 @@ per user: | Stored value | Sent as | | --------------------------- | ---------------------------------------------------------- | | `<32 hex>:<128 hex>` | `scrypt:16384:16:1$$`, hasher `scrypt_werkzeug` | -| `$2a$`, `$2b$` or `$2y$` | `bcrypt` | +| `$2a$`, `$2b$`, or `$2y$` | `bcrypt` | | `$argon2id$` or `$argon2i$` | `argon2id` or `argon2i` | | anything else | dropped | Better Auth's scrypt uses the hex salt string as the salt and a 64-byte key, -which is what `scrypt_werkzeug` verifies once N, r and p are written inline. It +which is what `scrypt_werkzeug` verifies once N, r, and p are written inline. It also normalizes a password to NFKC before hashing, and Clerk does not, so a password whose NFKC form differs will not verify and that user resets it. `clerk migrate sources betterauth` lists that caveat. @@ -1362,20 +1356,28 @@ address stays on the user, unverified. ### Verified vs unverified identifiers Every platform records verification differently, and each source declares -which style it uses. An identifier the source never confirmed is routed to +which style it uses. An identifier the auth platform never confirmed is routed to `unverifiedEmailAddresses` / `unverifiedPhoneNumbers` rather than the primary field, because Clerk creates primary identifiers **verified** — sending an unconfirmed address there would silently promote it. - **Boolean** (`auth0`, `betterauth`, `firebase`, `workos`): `true`/`false`. A CSV export stringifies these, so `"false"` is - read as false, not as a non-empty string. `TRUE`, `FALSE`, `t` and `f` read - too, as a spreadsheet or psql writes them. -- **Timestamp** (`authjs`, `supabase`): a nullable confirmation time. Any real value - means verified; `""`, `null` and `\N` do not. For `authjs`, neither does `0` - (the epoch) or a value that isn't a date. + read as false, not as a non-empty string. `TRUE`, `FALSE`, `t`, and `f` read + too, as a spreadsheet or psql writes them. `firebase`'s flag is `emailVerified`, + which covers email only. +- **Timestamp** (`authjs`, `supabase`): a nullable confirmation time. `""`, + `null`, `nil`, `undefined`, and `\N`, in any case, mean not verified; any other + value means verified, even a string that isn't a date. For `authjs`, `0` (the + epoch) and a value that isn't a date mean not verified too. A Clerk export keeps an unverified primary email or phone unverified. +Firebase phones import verified, so `--reserve-unverified` doesn't apply to them. +Firebase has no phone-verified field: a user confirms a number by SMS, and an +admin vouches for one set through the Admin SDK, as with a phone an admin adds in +Clerk. A number on the record signs in by SMS when the Firebase project has the +Phone provider on. + **Unverified or reserved.** By default an unverified identifier is attached after the user exists (`POST /v1/email_addresses` or `POST /v1/phone_numbers`, `verified: false`). The user @@ -1385,8 +1387,8 @@ any, creates them **reserved** instead, on `POST /v1/users` through `email_address_identification_status` / `phone_number_identification_status`. A reserved identifier is unverified, but usable for sign-in and locked to the user, and becomes verified the first time the user signs in with a code or -link sent to it. That is how most source platforms treat an unconfirmed address, but it lets the user -sign in with one nobody proved they own, so it is opt-in. `-y`, `--json` and +link sent to it. That is how most auth platforms treat an unconfirmed address, but it lets the user +sign in with one nobody proved they own, so it is opt-in. `-y`, `--json`, and agent mode never ask, and keep them unverified without the flag. A run records its choice (`reserveUnverified` in `run.json`), and a continued @@ -1410,7 +1412,7 @@ rejects users you want, import without reserving. Firebase uses a modified scrypt, so Clerk needs the project's four parameters alongside each digest. Find them in the Firebase console under **Authentication → Users → (⋮) → Password hash parameters**, and put them in -place of `SIGNER_KEY`, `SALT_SEPARATOR` and the two numbers: +place of `SIGNER_KEY`, `SALT_SEPARATOR`, and the two numbers: ```sh clerk migrate import users.json --source firebase -y \ @@ -1462,13 +1464,13 @@ reject the user as invalid. Each accepts a single value or an array. | `unverifiedPhoneNumbers` | `string \| string[]` | Unverified phones | | `username` | `string` | Username | -**Profile, password and 2FA.** +**Profile, password, and 2FA.** | Field | Type | Description | | -------------------- | ---------- | --------------------------------------------------- | | `firstName` | `string` | First name | | `lastName` | `string` | Last name | -| `password` | `string` | The hashed password from the source platform | +| `password` | `string` | The hashed password from the auth platform | | `passwordHasher` | `enum` | **Required whenever `password` is set** (see below) | | `totpSecret` | `string` | TOTP secret | | `backupCodesEnabled` | `boolean` | Whether backup codes are enabled | @@ -1592,5 +1594,5 @@ only. - CSV input is coerced before validation: `a@x.dev,b@x.dev` and `["a@x.dev"]` both become arrays, `"true"`/`1` become booleans, and JSON metadata columns are parsed. An empty column is dropped rather than sent as null. -- A user must end up with at least one identifier (email, phone or username). +- A user must end up with at least one identifier (email, phone, or username). Users that do not are rejected by the checks as invalid. diff --git a/packages/cli-core/src/commands/migrate/export/auth0.ts b/packages/cli-core/src/commands/migrate/export/auth0.ts index 95841dadc..d717ef29f 100644 --- a/packages/cli-core/src/commands/migrate/export/auth0.ts +++ b/packages/cli-core/src/commands/migrate/export/auth0.ts @@ -68,7 +68,7 @@ export function normalizeAuth0Domain(domain: string): string { /** * True for a bare host name. The client secret goes to this host, so userinfo - * (`tenant.auth0.com@elsewhere`), a port, a path or a query is refused rather + * (`tenant.auth0.com@elsewhere`), a port, a path, or a query is refused rather * than letting URL parsing pick a different host. */ export function isAuth0Domain(domain: string): boolean { @@ -355,7 +355,7 @@ export async function fetchAllAuth0Users(options: { * Keeps the fields the `auth0` transformer maps from. * * Deliberately a copy rather than the raw record: an Auth0 user carries - * identities, session counts and tenant internals that would bloat the export + * identities, session counts, and tenant internals that would bloat the export * and mean nothing to the import. */ export function mapAuth0UserToExport(user: Auth0User): Record { diff --git a/packages/cli-core/src/commands/migrate/export/authjs.ts b/packages/cli-core/src/commands/migrate/export/authjs.ts index 30cbabf0d..df0c7dce4 100644 --- a/packages/cli-core/src/commands/migrate/export/authjs.ts +++ b/packages/cli-core/src/commands/migrate/export/authjs.ts @@ -256,7 +256,7 @@ export function buildAuthJsExport( // A zero date is no verification, as a number or as a driver's Date. const exported = row.email_verified ? exportValue(row.email_verified) : undefined; // As the import reads it: the epoch (a driver's zero Date serializes to - // it), "0" and text that isn't a date are no verification, so they are + // it), "0", and text that isn't a date are no verification, so they are // neither exported nor counted as one. const verified = exported !== undefined && isAuthJsVerified(exported) ? exported : undefined; const providers = options.accounts?.get(userId) ?? []; diff --git a/packages/cli-core/src/commands/migrate/export/clerk.ts b/packages/cli-core/src/commands/migrate/export/clerk.ts index fd2bdc3de..41ac39602 100644 --- a/packages/cli-core/src/commands/migrate/export/clerk.ts +++ b/packages/cli-core/src/commands/migrate/export/clerk.ts @@ -3,13 +3,13 @@ * * Ported from the standalone migration-tool's `src/export/clerk.ts`, rewritten * onto `bapiRequest` instead of `@clerk/backend` so it shares the CLI's auth - * resolution, `--verbose` request tracing and error taxonomy. + * resolution, `--verbose` request tracing, and error taxonomy. * - * The output feeds `clerk migrate import --transformer clerk` unedited, which is + * The output feeds `clerk migrate import --source clerk` unedited, which is * what makes development → production a two-command operation. * * **Passwords do not come out of this endpoint.** Clerk never returns password - * digests, TOTP secrets or backup codes over the API; only the `*_enabled` + * digests, TOTP secrets, or backup codes over the API; only the `*_enabled` * booleans. The coverage report says how many users *have* a password so the * gap is visible before the import, not after. */ diff --git a/packages/cli-core/src/commands/migrate/export/db-options.ts b/packages/cli-core/src/commands/migrate/export/db-options.ts index 8abb2ff1c..ee4c93efe 100644 --- a/packages/cli-core/src/commands/migrate/export/db-options.ts +++ b/packages/cli-core/src/commands/migrate/export/db-options.ts @@ -107,7 +107,7 @@ function parsesAsUrl(value: string): boolean { * Percent-encodes the credentials when the raw string will not parse as a URL. * * Dashboards hand out `postgres://user:[YOUR-PASSWORD]@host/db` and people - * paste their real password in verbatim. A `#`, `@`, `/` or `^` in it makes the + * paste their real password in verbatim. A `#`, `@`, `/`, or `^` in it makes the * whole string unparseable — here and later inside `Bun.SQL` — so encode it for * them rather than bouncing a paste they cannot even see (the prompt is * masked). Strings that already parse are returned untouched, so a password diff --git a/packages/cli-core/src/commands/migrate/export/firebase.test.ts b/packages/cli-core/src/commands/migrate/export/firebase.test.ts index b5e4808d0..7af8fa22b 100644 --- a/packages/cli-core/src/commands/migrate/export/firebase.test.ts +++ b/packages/cli-core/src/commands/migrate/export/firebase.test.ts @@ -585,7 +585,9 @@ describe("exportFirebase", () => { // Human runs get a prompt instead; an agent has nobody to ask, so it is told // which flag to pass. test("agent mode names the flag rather than prompting", async () => { - await expect(exportFirebase({})).rejects.toThrow(/needs a service account key file/); + await expect(exportFirebase({})).rejects.toThrow( + /needs a service account key and cannot prompt here/, + ); }); test("validates the key file before making any request", async () => { diff --git a/packages/cli-core/src/commands/migrate/export/firebase.ts b/packages/cli-core/src/commands/migrate/export/firebase.ts index 8a516a169..73c872d6c 100644 --- a/packages/cli-core/src/commands/migrate/export/firebase.ts +++ b/packages/cli-core/src/commands/migrate/export/firebase.ts @@ -159,8 +159,8 @@ async function resolveServiceAccount(options: ExportFirebaseOptions): Promise.", + "`clerk migrate export firebase` needs a service account key and cannot prompt here. " + + "Pass --service-account with the path to the key file, or the key's JSON.", DOCS_URL, undefined, [ @@ -446,7 +446,7 @@ export async function fetchHashConfig( /** * Keeps the fields the `firebase` transformer maps from. * - * A Firebase user also carries provider records, custom claims and sign-in + * A Firebase user also carries provider records, custom claims, and sign-in * timestamps that would bloat the export and mean nothing to the import. */ export function mapFirebaseUserToExport(user: FirebaseUser): Record { @@ -492,7 +492,7 @@ export function buildFirebaseExport( const counts = { email: 0, verified: 0, password: 0, name: 0, phone: 0 }; // Password users Firebase returned no hash for: it only returns digests it // made itself (scrypt), and an empty string for users uploaded with bcrypt, - // HMAC or any other hasher. + // HMAC, or any other hasher. let unreadablePasswords = 0; // Hashes Firebase redacted because the caller may not read them. let redactedPasswords = 0; diff --git a/packages/cli-core/src/commands/migrate/export/index.ts b/packages/cli-core/src/commands/migrate/export/index.ts index 413e94d47..4e3cbf844 100644 --- a/packages/cli-core/src/commands/migrate/export/index.ts +++ b/packages/cli-core/src/commands/migrate/export/index.ts @@ -79,7 +79,7 @@ const DB_PLATFORMS = [ key: "authjs", summary: "Export users from an Auth.js database", envVar: "AUTHJS_DB_URL", - dbUrl: "Postgres, MySQL, libsql/Turso or SQLite connection string", + dbUrl: "Postgres, MySQL, libsql/Turso, or SQLite connection string", example: "mysql://user:password@127.0.0.1:3306/authjs", flags: [ [ @@ -92,7 +92,7 @@ const DB_PLATFORMS = [ key: "betterauth", summary: "Export users from a Better Auth database", envVar: "BETTERAUTH_DB_URL", - dbUrl: "Postgres, MySQL, libsql/Turso or SQLite connection string", + dbUrl: "Postgres, MySQL, libsql/Turso, or SQLite connection string", example: "./db.sqlite", }, ] as const; @@ -101,7 +101,7 @@ const DB_PLATFORMS = [ export function registerMigrateExport(migrateCommand: Command<[], Record>): void { const exportCommand = migrateCommand .command("export") - .description("Export users from a source platform, ready for `clerk migrate import`") + .description("Export users from an auth platform, ready for `clerk migrate import`") .setExamples([ { command: "clerk migrate export", description: "Pick a platform interactively" }, { @@ -149,7 +149,7 @@ export function registerMigrateExport(migrateCommand: Command<[], Record @@ -191,7 +191,10 @@ export function registerMigrateExport(migrateCommand: Command<[], Record", "Path to a service account key JSON file") + .option( + "--service-account ", + "Service account key: a path to its JSON file, or the JSON itself", + ) .option("-o, --output ", "Write the export here instead of the run folder") .option("-y, --yes", "Do not prompt: fail on a rejected credential") .option(RUNS_DIR_FLAG, RUNS_DIR_DESCRIPTION) diff --git a/packages/cli-core/src/commands/migrate/export/registry.ts b/packages/cli-core/src/commands/migrate/export/registry.ts index 385953b20..66980cb68 100644 --- a/packages/cli-core/src/commands/migrate/export/registry.ts +++ b/packages/cli-core/src/commands/migrate/export/registry.ts @@ -6,7 +6,7 @@ * registry uses. * * `run` takes no arguments on purpose: each platform resolves its own flags, - * environment variables and prompts, because what Auth0 needs (a tenant domain + * environment variables, and prompts, because what Auth0 needs (a tenant domain * and M2M credentials) has nothing in common with what a database export needs. */ @@ -52,7 +52,7 @@ export const exportPlatforms: ExportRegistryEntry[] = [ { key: "authjs", label: "Auth.js (NextAuth)", - description: "An Auth.js database — Postgres, MySQL or SQLite", + description: "An Auth.js database — Postgres, MySQL, or SQLite", sourceKey: "authjs", run: async (options) => exportAuthJs(options), }, diff --git a/packages/cli-core/src/commands/migrate/export/shared.ts b/packages/cli-core/src/commands/migrate/export/shared.ts index 6ab26f018..5b10c0027 100644 --- a/packages/cli-core/src/commands/migrate/export/shared.ts +++ b/packages/cli-core/src/commands/migrate/export/shared.ts @@ -127,7 +127,7 @@ export function formatSection(section: ExportSection): string[] { * * One command rather than a development and a production variant, because * there is no flag whose absence means "development": the key decides, through - * `--secret-key`, `--app`, `CLERK_SECRET_KEY`, the keyless project and the + * `--secret-key`, `--app`, `CLERK_SECRET_KEY`, the keyless project, and the * linked profile in that order. A line labelled "development" would be wrong * for anyone holding `CLERK_SECRET_KEY=sk_live_…`, which is the reader who can * least afford it. So the note names what picks the instance instead. @@ -194,7 +194,7 @@ export function finishExport(input: FinishExportInput): FinishedExport { ...(input.truncated ? { truncated: true } : {}), }); const record = run.finish(); - // Only the runs folder: the export's --secret-key, --app and --instance name + // Only the runs folder: the export's --secret-key, --app, and --instance name // the source, and would aim the import at it. const carry = carryFlags({ runsDir: options.runsDir }); const next = withFlags(`clerk migrate import ${record.id}`, carry); diff --git a/packages/cli-core/src/commands/migrate/export/supabase.ts b/packages/cli-core/src/commands/migrate/export/supabase.ts index 4d43058f8..020b7f397 100644 --- a/packages/cli-core/src/commands/migrate/export/supabase.ts +++ b/packages/cli-core/src/commands/migrate/export/supabase.ts @@ -25,7 +25,7 @@ import { /** * Only the metadata's own `first_name` and `last_name` are pulled out as - * columns. A `display_name`, `full_name` or OAuth `name` stays in + * columns. A `display_name`, `full_name`, or OAuth `name` stays in * `raw_user_meta_data` for the Supabase source to split, which it does only * when `first_name` is empty: coalescing them in here would keep "Jane Doe" * as one first name, and let a display name outrank a real first name. diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts index 190677d2f..af1739ded 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -60,7 +60,7 @@ const USER_PROGRESS_EVERY_PAGES = 10; const NO_PROVIDER_LABEL = "no OAuth provider"; const NOT_READABLE_LABEL = "not readable"; -const DOCS_URL = "https://clerk.com/docs/guides/development/migrating/overview"; +const DOCS_URL = "https://clerk.com/docs/guides/development/migrating/workos"; export type ExportWorkOsOptions = { apiKey?: string; @@ -253,7 +253,7 @@ export async function fetchAllWorkOsUsers(options: { * returns can be imported, because `POST /v1/users` has no external-accounts * field. It is a line in the coverage report, and a record kept in the file. * - * Agent mode, `--json` and `-y` get the flag's answer and no question: there + * Agent mode, `--json`, and `-y` get the flag's answer and no question: there * is nobody to ask, and `-y` takes the prompt's default, no, rather than * committing to one request per user unasked. */ @@ -415,7 +415,7 @@ export function buildIdentityReport( * they were fetched. * * A copy rather than the raw record: WorkOS also returns `locale`, - * `profile_picture_url`, `last_sign_in_at` and `updated_at`, which the import + * `profile_picture_url`, `last_sign_in_at`, and `updated_at`, which the import * does not carry. `identities` has no target field either and is * dropped at validation, so it rides along purely as a record for whoever runs * the migration. diff --git a/packages/cli-core/src/commands/migrate/gate.test.ts b/packages/cli-core/src/commands/migrate/gate.test.ts deleted file mode 100644 index 7179fc22a..000000000 --- a/packages/cli-core/src/commands/migrate/gate.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { useIntegrationTestHarness, clerk } from "../../test/integration/lib/harness.ts"; -import { generateCompletions } from "../completion/__complete.ts"; -import { createProgram } from "../../cli-program.ts"; - -useIntegrationTestHarness(); - -let saved: string | undefined; -beforeEach(() => { - saved = process.env.CLERK_EXPERIMENTAL; - delete process.env.CLERK_EXPERIMENTAL; -}); -afterEach(() => { - if (saved === undefined) delete process.env.CLERK_EXPERIMENTAL; - else process.env.CLERK_EXPERIMENTAL = saved; -}); - -describe("with CLERK_EXPERIMENTAL unset", () => { - test("clerk --help leaves migrate out", () => { - expect(createProgram().helpInformation()).not.toContain("migrate"); - }); - - test.each([ - [["migrate"]], - [["migrate", "--help"]], - [["migrate", "import", "users.json", "--yes"]], - [["migrate", "--not-a-flag"]], - [["help", "migrate"]], - [["--verbose", "migrate", "import", "x.json"]], - ])("clerk %p exits 2 with experiment_disabled", async (args) => { - const result = await clerk.raw("--mode", "agent", ...args); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain('"code":"experiment_disabled"'); - expect(result.stderr).toContain("CLERK_EXPERIMENTAL=migrate"); - }); - - test("completion offers no subcommands under migrate", () => { - const names = generateCompletions(createProgram(), ["migrate", ""]).completions.map( - (c) => c.name, - ); - expect(names.filter((name) => !name.startsWith("-"))).toEqual([]); - }); -}); - -describe("with CLERK_EXPERIMENTAL=migrate", () => { - test("clerk --help lists migrate", () => { - process.env.CLERK_EXPERIMENTAL = "migrate"; - expect(createProgram().helpInformation()).toContain("migrate"); - }); -}); diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index eb4bde001..2813b7ce6 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -8,7 +8,7 @@ * - The request body is BAPI's snake_case shape directly, so `created_at` and * `legal_accepted_at` stay RFC3339 strings rather than round-tripping * through `Date`. - * - `banned`, `delete_self_enabled` and the organization limits are all + * - `banned`, `delete_self_enabled`, and the organization limits are all * accepted by `POST /v1/users`, so the follow-up `updateUser`/`banUser` * calls the SDK version needed are gone. * @@ -101,7 +101,7 @@ function createIdentifiers( * Optional fields are omitted rather than sent as null so Clerk applies its own * defaults for anything the source platform did not record. * - * @param reserveUnverified - Create the identifiers the source never verified + * @param reserveUnverified - Create the identifiers the auth platform never verified * as reserved, in this request, rather than attaching them unverified. */ export function buildCreateUserBody( @@ -110,7 +110,7 @@ export function buildCreateUserBody( skipPasswordRequirement: boolean, reserveUnverified = false, ): Record { - // The instance's allowlist, blocklist, disposable-email and subaddress rules + // The instance's allowlist, blocklist, disposable-email, and subaddress rules // police sign-ups. These users already signed up, on the source platform. const body: Record = { external_id: user.userId, skip_restriction_checks: true }; @@ -171,7 +171,7 @@ type CreateContext = { quota: AbortController; /** Aborted by a run-store write that failed. Nothing goes out after it. */ halt: AbortSignal; - /** Aborted by a Ctrl-C, the quota or a halt. No create goes out after it. */ + /** Aborted by a Ctrl-C, the quota, or a halt. No create goes out after it. */ stop: AbortSignal; /** Unverified identifiers go on the create as reserved, not attached after. */ reserveUnverified: boolean; @@ -456,7 +456,7 @@ export type ImportUsersOptions = { adoptedPending?: Map; /** Allow users that carry no password. */ skipPasswordRequirement?: boolean; - /** Create the identifiers the source never verified as reserved. */ + /** Create the identifiers the auth platform never verified as reserved. */ reserveUnverified?: boolean; /** Carried into the summary so the report covers the whole file. */ validationFailed?: number; @@ -693,7 +693,7 @@ export async function importUsers(options: ImportUsersOptions): Promise { - saved = process.env.CLERK_EXPERIMENTAL; - process.env.CLERK_EXPERIMENTAL = "migrate"; -}); -afterAll(() => { - if (saved === undefined) delete process.env.CLERK_EXPERIMENTAL; - else process.env.CLERK_EXPERIMENTAL = saved; -}); - function findCommand(names: string[]) { let current = createProgram().commands.find((cmd) => cmd.name() === names[0]); for (const name of names.slice(1)) { diff --git a/packages/cli-core/src/commands/migrate/index.ts b/packages/cli-core/src/commands/migrate/index.ts index 45fdb0e7f..f47a3fe5d 100644 --- a/packages/cli-core/src/commands/migrate/index.ts +++ b/packages/cli-core/src/commands/migrate/index.ts @@ -1,5 +1,4 @@ import type { Program } from "../../cli-program.ts"; -import { isExperimentEnabled, requireExperiment } from "../../lib/experimental.ts"; import { markMigrateRequests } from "../../lib/fetch.ts"; import { parseIntegerOption } from "../../lib/option-parsers.ts"; import { setMode } from "../../mode.ts"; @@ -13,30 +12,10 @@ import { undo } from "./undo.ts"; import { CUSTOM_SOURCES_URL } from "./sources/load-custom.ts"; import { list as sources } from "./sources/list.ts"; -export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = process.env): void { - if (!isExperimentEnabled("migrate", env)) { - // A hidden stub rather than no command at all: `clerk migrate` then says how - // to turn it on instead of "unknown command". It has no subcommands, so - // completion has nothing to walk, and it accepts anything, so every - // invocation reaches the refusal. - const stub = program - .command("migrate", { hidden: true }) - .helpOption(false) - .allowUnknownOption() - .allowExcessArguments() - .argument("[args...]") - .action(() => requireExperiment("migrate", env)); - // `clerk help migrate` renders help without running the action. - stub.helpInformation = () => { - requireExperiment("migrate", env); - return ""; - }; - return; - } - +export function registerMigrate(program: Program): void { const migrateCommand = program .command("migrate") - .description("Migrate users into Clerk from another auth provider or another Clerk instance") + .description("Migrate users into Clerk from another auth platform or another Clerk instance") .setExamples([ { command: "clerk migrate export supabase", @@ -107,7 +86,7 @@ export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = proce ) .option( "--reserve-unverified", - "Create emails and phones the source never verified as reserved (usable for sign-in, locked to the user) instead of unverified", + "Create emails and phones the auth platform never verified as reserved (usable for sign-in, locked to the user) instead of unverified", ) .option( "--firebase-signer-key ", @@ -198,7 +177,7 @@ export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = proce { command: "clerk migrate runs", description: "List every run, newest first" }, { command: "clerk migrate runs 20260929-141502-a1b2", - description: "Show one run: counts, errors and the users that did not make it", + description: "Show one run: counts, errors, and the users that did not make it", }, { command: "clerk migrate runs --json", description: "Machine-readable listing" }, ]) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 1f8816fbb..c770ddbae 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -311,7 +311,7 @@ describe("rejects", () => { }), ).toEqual({ unverified: - "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it)", + "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the auth platform never verified it)", none: "no email, which this instance requires", }); }); diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 57e5942a8..90ef5a8de 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -222,7 +222,7 @@ function missingRequiredIdentifier( const identifiers = splitIdentifiers(user); const reserved = (unverified: string[]) => reserveUnverified && unverified.length > 0; const hint = suggestReserve - ? " (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it)" + ? " (--reserve-unverified imports it reserved: usable for sign-in, though the auth platform never verified it)" : ""; if ( @@ -586,7 +586,7 @@ const hasAnyIdentifier = (user: User) => const DUPLICATE_SOURCE_ID = "duplicate source ID in the file"; /** - * First user in the file to claim each email, phone and source ID. + * First user in the file to claim each email, phone, and source ID. * * Keyed by record, not source ID: two records with one source ID must not * share a verdict, or the one kept would be rejected along with its copy. @@ -1181,7 +1181,7 @@ export async function checkImport(input: CheckInput): Promise { // No setting would let a duplicate in, so none earns a fix. const duplicates = new Set(); - // An adopted user already holds its email, phone and username in Clerk, so + // An adopted user already holds its email, phone, and username in Clerk, so // it claims them first, wherever it sits in the file: a fresh record sharing // one is the create Clerk would refuse, and is not kept in its place. const { reasons: fileDuplicates, keptBy } = findFileDuplicates( diff --git a/packages/cli-core/src/commands/migrate/lib/clerk-config.ts b/packages/cli-core/src/commands/migrate/lib/clerk-config.ts index e693117f7..a15fa5615 100644 --- a/packages/cli-core/src/commands/migrate/lib/clerk-config.ts +++ b/packages/cli-core/src/commands/migrate/lib/clerk-config.ts @@ -22,7 +22,7 @@ import { retryOn429 } from "./retry.ts"; * Supabase provider keys whose Clerk strategy is not simply `oauth_`. * * Everything not listed here maps by prefix, which covers google, github, - * discord, spotify, twitch, notion, figma, gitlab, bitbucket and the rest. + * discord, spotify, twitch, notion, figma, gitlab, bitbucket, and the rest. */ const CLERK_STRATEGY_ALIASES: Record = { azure: "oauth_microsoft", diff --git a/packages/cli-core/src/commands/migrate/lib/db.ts b/packages/cli-core/src/commands/migrate/lib/db.ts index 65f9ed611..63ecf8c7a 100644 --- a/packages/cli-core/src/commands/migrate/lib/db.ts +++ b/packages/cli-core/src/commands/migrate/lib/db.ts @@ -1,8 +1,8 @@ /** - * One query interface over Postgres, MySQL and SQLite. + * One query interface over Postgres, MySQL, and SQLite. * * Rewritten from the standalone migration-tool's `src/lib/db.ts`, which used - * `pg`, `mysql2` and `better-sqlite3`. None of those belong in a statically + * `pg`, `mysql2`, and `better-sqlite3`. None of those belong in a statically * compiled binary — `better-sqlite3` is a native addon outright — so this runs * on `Bun.sql` (Postgres and MySQL) and `bun:sqlite`, both built into the * runtime. That swap is the entire reason the `engines.bun` floor exists. @@ -87,7 +87,7 @@ export function redactConnectionString(connectionString: string): string { * replaced by `***`. * * {@link redactConnectionString} covers the string we print; this covers what - * the server or driver says back, which can quote the URL, the password or a + * the server or driver says back, which can quote the URL, the password, or a * libsql token. A short password can blank an unrelated word too, and a * mangled message is the better failure. */ diff --git a/packages/cli-core/src/commands/migrate/lib/failures.ts b/packages/cli-core/src/commands/migrate/lib/failures.ts index c6dbbf4ff..300c3828f 100644 --- a/packages/cli-core/src/commands/migrate/lib/failures.ts +++ b/packages/cli-core/src/commands/migrate/lib/failures.ts @@ -1,6 +1,6 @@ /** * How a failed request reads in a run's lines and summaries, shared by import, - * undo and `runs`. + * undo, and `runs`. */ import type { BapiError } from "../../../lib/errors.ts"; diff --git a/packages/cli-core/src/commands/migrate/lib/input-retry.ts b/packages/cli-core/src/commands/migrate/lib/input-retry.ts index 4598f7113..cda9d6143 100644 --- a/packages/cli-core/src/commands/migrate/lib/input-retry.ts +++ b/packages/cli-core/src/commands/migrate/lib/input-retry.ts @@ -29,7 +29,7 @@ import { terminal } from "./terminal.ts"; * already written a file, or a long fetch the credential has already been * accepted for, does not belong in here. * - * `-y`, `--json`, agent mode and a non-TTY get the failure unchanged: there is + * `-y`, `--json`, agent mode, and a non-TTY get the failure unchanged: there is * nobody to ask, and a loop that cannot prompt is a loop that cannot end. A cancelled * prompt throws {@link UserAbortError}, which is not a `CliError` and so leaves * the loop — declining the question is an answer. @@ -82,7 +82,7 @@ export function isCredentialStatus(status: number): boolean { /** * Throws an API failure: a usage error (exit 2, and a fresh prompt under * {@link withInputRetry}) when the status blames the credential; a plain - * error (exit 1) for a 429, a 5xx or anything else another credential would + * error (exit 1) for a 429, a 5xx, or anything else another credential would * not fix. */ export function throwApiFailure(status: number, message: string, docsUrl?: string): never { diff --git a/packages/cli-core/src/commands/migrate/lib/modify-settings.ts b/packages/cli-core/src/commands/migrate/lib/modify-settings.ts index 6ed1cc506..4e858617c 100644 --- a/packages/cli-core/src/commands/migrate/lib/modify-settings.ts +++ b/packages/cli-core/src/commands/migrate/lib/modify-settings.ts @@ -47,7 +47,7 @@ type ChangeWrites = { enable: SettingWrite[]; relax: SettingWrite[] }; * no way to verify it — `422 phone_number: verifiable attributes need to have * at least one verification` — and switching the attribute off empties * `verification_strategies`, so whatever turns it back on has to put a strategy - * back. Username, password and the name fields are not verifiable and take one + * back. Username, password, and the name fields are not verifiable and take one * write each. */ const ATTRIBUTE_WRITES: Record = { diff --git a/packages/cli-core/src/commands/migrate/lib/run-store.ts b/packages/cli-core/src/commands/migrate/lib/run-store.ts index 29fc68dd5..f6b0e4db1 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -1,7 +1,7 @@ /** * The run store: the one place `clerk migrate` keeps state. * - * Every import, export and undo is a run. A run is a folder under the runs + * Every import, export, and undo is a run. A run is a folder under the runs * directory holding: * * - `run.json` — what ran, against what, from which file, and how it ended. @@ -11,7 +11,7 @@ * second writer; a dead one, or a run with no `finishedAt`, means the run * was interrupted. * - * `runs`, `undo` and re-runs all read or write it, so there is no second + * `runs`, `undo`, and re-runs all read or write it, so there is no second * record to drift out of step. * * Appends are synchronous so a run interrupted with Ctrl-C still leaves a @@ -132,7 +132,7 @@ export type UserLine = { error?: string; /** * Failed: the HTTP status, or "unknown". Skipped: a machine reason, - * NO_PASSWORD_CODE, IN_FILE_CODE or SOURCE_SKIP_CODE. + * NO_PASSWORD_CODE, IN_FILE_CODE, or SOURCE_SKIP_CODE. */ code?: string; passwordDropped?: boolean; @@ -305,7 +305,7 @@ function acquireLockFile( } // ponytail: two processes that both read the same stale lock can both // remove it, and the second can remove the first's new one. Clerk still - // refuses the second create of any email, phone or username, so the cost + // refuses the second create of any email, phone, or username, so the cost // is failed lines, not duplicate users. A lock on the reclaim would close it. const holder = livePidIn(file); if (holder !== undefined) refuse(holder, file); @@ -574,7 +574,7 @@ export type Run = { /** Merges fields into `run.json`. */ update(patch: Partial): void; /** - * Counts the outcomes, settles the status and releases the lock. + * Counts the outcomes, settles the status, and releases the lock. * * `partial` when any user failed, was skipped, may not have been created, * was never sent (`notSent`: they have no line to count) or has identifiers @@ -638,7 +638,7 @@ function openRun(runsDir: string, record: RunRecord): Run { export type StartRunInit = Omit; -/** Creates a run folder, takes its lock and writes the first `run.json`. */ +/** Creates a run folder, takes its lock, and writes the first `run.json`. */ export function startRun(runsDir: string, init: StartRunInit): Run { // Owner-only: a run's files hold user data. fs.mkdirSync(runsDir, { recursive: true, mode: 0o700 }); diff --git a/packages/cli-core/src/commands/migrate/lib/transform.ts b/packages/cli-core/src/commands/migrate/lib/transform.ts index 07b8891b2..b5ce1b597 100644 --- a/packages/cli-core/src/commands/migrate/lib/transform.ts +++ b/packages/cli-core/src/commands/migrate/lib/transform.ts @@ -354,7 +354,7 @@ function addDefaultFields( } /** - * Maps, normalizes and (unless disabled) validates a batch of raw users. + * Maps, normalizes, and (unless disabled) validates a batch of raw users. * * @param options.validate - Set `false` to get the mapped shape without * dropping invalid rows, for analysis passes that count fields. diff --git a/packages/cli-core/src/commands/migrate/readme.test.ts b/packages/cli-core/src/commands/migrate/readme.test.ts index 88e5f6af4..c39c1cf69 100644 --- a/packages/cli-core/src/commands/migrate/readme.test.ts +++ b/packages/cli-core/src/commands/migrate/readme.test.ts @@ -9,19 +9,10 @@ * be written down — so neither renaming a flag nor adding one passes silently. */ -import { afterAll, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import type { Command } from "commander"; import { createProgram } from "../../cli-program.ts"; -// Without it `migrate` is the gate's stub, with no subcommands to check. Set at -// load, because the cases below are built from the command tree. -const savedExperimental = process.env.CLERK_EXPERIMENTAL; -process.env.CLERK_EXPERIMENTAL = "migrate"; -afterAll(() => { - if (savedExperimental === undefined) delete process.env.CLERK_EXPERIMENTAL; - else process.env.CLERK_EXPERIMENTAL = savedExperimental; -}); - const README = await Bun.file(new URL("./README.md", import.meta.url)).text(); /** Fenced blocks only, so prose that merely mentions a flag is not parsed. */ diff --git a/packages/cli-core/src/commands/migrate/run-interactive.test.ts b/packages/cli-core/src/commands/migrate/run-interactive.test.ts index 8409ef19e..475f9d508 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -408,7 +408,7 @@ describe("unverified identifiers", () => { await run(importOptions); expect(confirmMessages).toEqual([ - "1 user has an email or phone the source never verified. Create them reserved (usable for sign-in, locked to the user) instead of unverified? A reserved one another user already holds rejects its user, not just that address.", + "1 user has an email or phone the auth platform never verified. Create them reserved (usable for sign-in, locked to the user) instead of unverified? A reserved one another user already holds rejects its user, not just that address.", "Import 2 users?", ]); expect(statuses()).toEqual([undefined, ["verified", "reserved"]]); diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 345fbe0e2..b0b3a24f4 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -1497,7 +1497,7 @@ describe("run", () => { await run({ ...baseOptions, dryRun: true }); expect(captured.err).toContain( - "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it)", + "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the auth platform never verified it)", ); expect(captured.err).toContain( `clerk config patch --app APP_ID --instance ins_1 --json '{"auth_email":{"required_for_sign_up":false}}'`, diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 7cb522091..eaed25d13 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -7,9 +7,9 @@ * An import goes through the same steps every time: * * 1. Settle the file (an export run ID stands for the file that run wrote), - * the source (a file from `clerk migrate export` names its own) and the + * the source (a file from `clerk migrate export` names its own), and the * target instance, and print the target. - * 2. Decide whether this continues an earlier run of the same file, source and + * 2. Decide whether this continues an earlier run of the same file, source, and * instance, from the run store. * 3. Run `checkImport()` against the real instance. `--dry-run` stops here. * 4. Refuse on any reject unless `--allow-partial`, then ask for consent. @@ -131,7 +131,7 @@ export type MigrateRunOptions = { */ skipLegalChecks?: boolean; /** - * Create the emails and phones the source never verified as reserved, not + * Create the emails and phones the auth platform never verified as reserved, not * unverified. Without it, a prompt asks; where nobody can be asked, they * stay unverified. */ @@ -246,7 +246,7 @@ export function explainErrors(errors: Iterable, instanceType: InstanceTy return notes; } -// --- Settling the file, source and target ---------------------------------- +// --- Settling the file, source, and target ---------------------------------- /** * Makes sure there is somewhere to import *into* before anything else happens. @@ -500,7 +500,7 @@ function undoneInFull(runsDir: string, record: RunRecord): boolean { ); } -/** How this import relates to earlier runs of the same file, source and instance. */ +/** How this import relates to earlier runs of the same file, source, and instance. */ type ResumeCase = | { kind: "new" } | { kind: "continue"; record: RunRecord; because: "interrupted" | "partial" } @@ -508,7 +508,7 @@ type ResumeCase = /** * Finds the latest import run of this file (by sha256), this source (and a - * custom source's hash) and this instance, and decides what a re-run does: + * custom source's hash), and this instance, and decides what a re-run does: * * - none, or undone: a new run * - interrupted: continue it, skipping the users it created @@ -1196,7 +1196,7 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi } // Checked again after the last read of the file, before anything is - // created: the envelope, the users and the provider rows all came from + // created: the envelope, the users, and the provider rows all came from // one revision, the one an export run recorded if there is one. if (expectedSha256 !== undefined && sha256File(filePath) !== expectedSha256) { throwChanged(filePath); @@ -1252,7 +1252,7 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi canPrompt(options) ) { reserveUnverified = await confirm({ - message: `${plural(withUnverified, "user")} ${withUnverified === 1 ? "has" : "have"} an email or phone the source never verified. Create them reserved (usable for sign-in, locked to the user) instead of unverified? A reserved one another user already holds rejects its user, not just that address.`, + message: `${plural(withUnverified, "user")} ${withUnverified === 1 ? "has" : "have"} an email or phone the auth platform never verified. Create them reserved (usable for sign-in, locked to the user) instead of unverified? A reserved one another user already holds rejects its user, not just that address.`, default: false, }); } @@ -1441,7 +1441,7 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi } // Rule 1: nothing is written without consent — `--yes`, or a yes at a - // prompt. An agent, a non-TTY run and `--json` never prompt. With users + // prompt. An agent, a non-TTY run, and `--json` never prompt. With users // skipped or attaches left, the question names them: "Import 0 users?" // hides both. const toCreate = plural(checks.importable.length, "user"); diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index d8b68dc28..5e4e4b43e 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -1,5 +1,5 @@ /** - * `clerk migrate runs [run-id]` — what every import, export and undo did. + * `clerk migrate runs [run-id]` — what every import, export, and undo did. * * Reads the run store and nothing else. `runs` alone lists every run, newest * first. `runs ` shows one: its header, counts, the error breakdown, the @@ -180,7 +180,7 @@ function nextCommands( const again = continueCommand(record, carry); if (!again || state === "complete" || !continuesThisRun(runsDir, record)) return [undo]; if (state === "partial" && !hasRetryableUsers(record, latest)) return [undo]; - // Continuing is what a stopped run is for: the same file, source and + // Continuing is what a stopped run is for: the same file, source, and // target pick it up where it left off. const why = state === "interrupted" ? "continue it" : "retry the users it didn't finish"; // The record knows a source you wrote only by its hash. diff --git a/packages/cli-core/src/commands/migrate/sources/authjs.ts b/packages/cli-core/src/commands/migrate/sources/authjs.ts index c86a0d9c5..69598a1fb 100644 --- a/packages/cli-core/src/commands/migrate/sources/authjs.ts +++ b/packages/cli-core/src/commands/migrate/sources/authjs.ts @@ -58,7 +58,7 @@ const authjsSource = { mfa: { level: "no", note: "Auth.js has no MFA of its own." }, metadata: { level: "no", - note: "Only `id`, `name`, `email`, `email_verified`, `created_at` and the account table's OAuth providers are read.", + note: "Only `id`, `name`, `email`, `email_verified`, `created_at`, and the account table's OAuth providers are read.", }, }, transformer: { diff --git a/packages/cli-core/src/commands/migrate/sources/betterauth.ts b/packages/cli-core/src/commands/migrate/sources/betterauth.ts index 3043734a4..918bdf43f 100644 --- a/packages/cli-core/src/commands/migrate/sources/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/sources/betterauth.ts @@ -55,11 +55,11 @@ const betterAuthSource = { key: "betterauth", label: "Better Auth", description: - "Works with the Better Auth export. Detects scrypt, bcrypt and argon2 passwords per user, and carries the admin plugin's banned flag while its ban has not expired.", + "Works with the Better Auth export. Detects scrypt, bcrypt, and argon2 passwords per user, and carries the admin plugin's banned flag while its ban has not expired.", carries: { passwords: { level: "yes", - note: "Better Auth's scrypt, bcrypt and argon2 hashes come across, detected per user. Any other hash is dropped, and that user resets their password.", + note: "Better Auth's scrypt, bcrypt, and argon2 hashes come across, detected per user. Any other hash is dropped, and that user resets their password.", }, mfa: { level: "no", @@ -109,7 +109,7 @@ const betterAuthSource = { // Only carry `banned` when it is actually true — Better Auth writes false // for every user that was never banned, and sending that to Clerk is noise. - // SQLite, libSQL and MySQL hand back 1/0 and CSV hands back "true", so this + // SQLite, libSQL, and MySQL hand back 1/0 and CSV hands back "true", so this // runs before normalizeUserData and must accept those too. // A ban whose `banExpires` has passed is over: Better Auth lifts it only // at the user's next sign-in, so the column still says banned. An expiry diff --git a/packages/cli-core/src/commands/migrate/sources/clerk.ts b/packages/cli-core/src/commands/migrate/sources/clerk.ts index 6c63ef062..83880e7ab 100644 --- a/packages/cli-core/src/commands/migrate/sources/clerk.ts +++ b/packages/cli-core/src/commands/migrate/sources/clerk.ts @@ -22,7 +22,7 @@ const clerkSource = { }, metadata: { level: "partial", - note: "With `clerk migrate export clerk`, public, private and unsafe metadata keep their places; a Dashboard CSV carries no metadata, ban or legal acceptance. `export clerk` moves each user's external_id to private metadata as `clerkExternalId`, because the import uses external_id for the old Clerk ID.", + note: "With `clerk migrate export clerk`, public, private, and unsafe metadata keep their places; a Dashboard CSV carries no metadata, ban, or legal acceptance. `export clerk` moves each user's external_id to private metadata as `clerkExternalId`, because the import uses external_id for the old Clerk ID.", }, }, transformer: { diff --git a/packages/cli-core/src/commands/migrate/sources/firebase.ts b/packages/cli-core/src/commands/migrate/sources/firebase.ts index 3a2266922..073d45875 100644 --- a/packages/cli-core/src/commands/migrate/sources/firebase.ts +++ b/packages/cli-core/src/commands/migrate/sources/firebase.ts @@ -64,12 +64,17 @@ const firebaseSource = { mfa: { level: "no", note: "Firebase exports no MFA enrolments. Users enrol again in Clerk." }, metadata: { level: "no", note: "Custom claims are not exported." }, }, + caveats: [ + "Phones import verified: Firebase has no phone-verified field, because a user confirms a number by SMS and an admin vouches for one set through the Admin SDK.", + ], transformer: { localId: "userId", email: "email", emailVerified: "emailVerified", passwordHash: "passwordHash", passwordSalt: "salt", + // Verified, as Clerk treats a phone an admin adds: Firebase has no + // phone-verified field, and nothing here routes it unverified. phoneNumber: "phone", displayName: "name", disabled: "banned", diff --git a/packages/cli-core/src/commands/migrate/sources/list.test.ts b/packages/cli-core/src/commands/migrate/sources/list.test.ts index e452580f3..12ec338e9 100644 --- a/packages/cli-core/src/commands/migrate/sources/list.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/list.test.ts @@ -62,7 +62,7 @@ describe("sources", () => { test("says how to add one", async () => { await list(undefined); expect(captured.err).toContain("--source ./my-source.ts"); - expect(captured.err).toContain("README.md#custom-sources"); + expect(captured.err).toContain("docs/guides/development/migrating/custom-source"); }); test("--json lists every built-in with its carries on stdout", async () => { diff --git a/packages/cli-core/src/commands/migrate/sources/list.ts b/packages/cli-core/src/commands/migrate/sources/list.ts index af69263d5..278f5a63b 100644 --- a/packages/cli-core/src/commands/migrate/sources/list.ts +++ b/packages/cli-core/src/commands/migrate/sources/list.ts @@ -4,7 +4,7 @@ * * A compiled binary's users have no source tree to read the mappings in, so * this is where they are shown. `sources` alone lists every source with its - * passwords, MFA and metadata at a glance. `sources ` shows one in + * passwords, MFA, and metadata at a glance. `sources ` shows one in * full: what each field becomes, how to export from it, and its caveats. */ diff --git a/packages/cli-core/src/commands/migrate/sources/load-custom.test.ts b/packages/cli-core/src/commands/migrate/sources/load-custom.test.ts index af604ab25..2c3592a6c 100644 --- a/packages/cli-core/src/commands/migrate/sources/load-custom.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/load-custom.test.ts @@ -172,7 +172,9 @@ describe("validateSource", () => { validateSource(null, "f.ts"); throw new Error("expected a rejection"); } catch (error) { - expect((error as CliError).docsUrl).toContain("README.md#custom-sources"); + expect((error as CliError).docsUrl).toContain( + "docs/guides/development/migrating/custom-source", + ); } }); diff --git a/packages/cli-core/src/commands/migrate/sources/load-custom.ts b/packages/cli-core/src/commands/migrate/sources/load-custom.ts index b9c6e3959..bf02e1fd4 100644 --- a/packages/cli-core/src/commands/migrate/sources/load-custom.ts +++ b/packages/cli-core/src/commands/migrate/sources/load-custom.ts @@ -11,7 +11,7 @@ * `import()` an arbitrary external `.ts` file at runtime, including TypeScript * that needs transpiling. Bun's transpiler is part of the runtime, not only the * bundler. Confirmed with a throwaway compiled binary on darwin-arm64, - * linux-arm64 (glibc), linux-arm64-musl and linux-x64. + * linux-arm64 (glibc), linux-arm64-musl, and linux-x64. * * The file is user-supplied code the CLI executes, so its shape is validated * up front and rejected with a specific message rather than crashing deep in @@ -23,9 +23,9 @@ import path from "node:path"; import { CliError, ERROR_CODE, throwUsageError } from "../../../lib/errors.ts"; import type { CarryLevel, SourceEntry } from "../types.ts"; -/** How to write a source. The README ships nowhere, so link the published copy. */ +/** How to write a source: the custom-source migration guide. */ export const CUSTOM_SOURCES_URL = - "https://github.com/clerk/cli/blob/main/packages/cli-core/src/commands/migrate/README.md#custom-sources"; + "https://clerk.com/docs/guides/development/migrating/custom-source"; const LEVELS: readonly CarryLevel[] = ["yes", "no", "partial"]; diff --git a/packages/cli-core/src/commands/migrate/sources/sources.test.ts b/packages/cli-core/src/commands/migrate/sources/sources.test.ts index 1a4be8998..3fe7bcb5a 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -552,6 +552,17 @@ describe("firebase", () => { ).toBeUndefined(); }); + // Firebase has no phone-verified field, so nothing routes a phone unverified. + test("imports a phone verified", () => { + const user = one("firebase", { localId: "p1", phoneNumber: "+14165550123" }); + expect(user?.phone).toBe("+14165550123"); + expect(user?.unverifiedPhoneNumbers).toBeUndefined(); + }); + + test("says in its caveats that phones import verified", () => { + expect(getSource("firebase").caveats?.join(" ")).toContain("Phones import verified"); + }); + const withHash = { ...base, passwordHash: "SGFzaA==", salt: "U2FsdA==" }; test("builds the scrypt digest Clerk expects, parameters inline", async () => { diff --git a/packages/cli-core/src/commands/migrate/sources/supabase.ts b/packages/cli-core/src/commands/migrate/sources/supabase.ts index cd4ae1faf..8c9f057f9 100644 --- a/packages/cli-core/src/commands/migrate/sources/supabase.ts +++ b/packages/cli-core/src/commands/migrate/sources/supabase.ts @@ -40,7 +40,7 @@ const supabaseSource = { key: "supabase", label: "Supabase", description: - "Works with a Supabase `auth.users` export. Users whose only social provider is not enabled in Clerk are rejected by the import's checks, unless they can sign in by email code, email link or phone code.", + "Works with a Supabase `auth.users` export. Users whose only social provider is not enabled in Clerk are rejected by the import's checks, unless they can sign in by email code, email link, or phone code.", carries: { passwords: { level: "yes", diff --git a/packages/cli-core/src/commands/migrate/types.ts b/packages/cli-core/src/commands/migrate/types.ts index eaf3746bb..227be5c95 100644 --- a/packages/cli-core/src/commands/migrate/types.ts +++ b/packages/cli-core/src/commands/migrate/types.ts @@ -124,7 +124,7 @@ export type SourceCarries = { passwords: Carry; mfa: Carry; metadata: Carry }; * shape. * * @property transformer - Source field path → Clerk field name. - * @property carries - What comes across: passwords, MFA and metadata. + * @property carries - What comes across: passwords, MFA, and metadata. * @property caveats - Anything else worth knowing before importing. * @property defaults - Values merged into every user from this platform. * @property preTransform - Runs before field mapping. diff --git a/packages/cli-core/src/commands/migrate/validator.ts b/packages/cli-core/src/commands/migrate/validator.ts index 5196b5078..86154c83b 100644 --- a/packages/cli-core/src/commands/migrate/validator.ts +++ b/packages/cli-core/src/commands/migrate/validator.ts @@ -29,9 +29,9 @@ export const passwordHasherEnum = z.enum(PASSWORD_HASHERS, { * Validates user data before sending it to Clerk. * * Everything is optional except: - * - `userId`, required for tracking, re-runs and `undo` + * - `userId`, required for tracking, re-runs, and `undo` * - `passwordHasher`, required whenever `password` is present - * - at least one identifier (email, phone or username) + * - at least one identifier (email, phone, or username) * * Identifier fields accept either a single value or an array. */ diff --git a/packages/cli-core/src/commands/migrate/wizard.ts b/packages/cli-core/src/commands/migrate/wizard.ts index 3d7249590..2d4e5ce2f 100644 --- a/packages/cli-core/src/commands/migrate/wizard.ts +++ b/packages/cli-core/src/commands/migrate/wizard.ts @@ -5,7 +5,7 @@ * the source when the file does not name its own, and Firebase's hash * parameters when neither the flags nor the export carry them. * - * Nothing here runs for an agent, a non-TTY run or `--json`: `run` raises a + * Nothing here runs for an agent, a non-TTY run, or `--json`: `run` raises a * usage error naming what to pass instead. */ diff --git a/packages/cli-core/src/lib/experimental.ts b/packages/cli-core/src/lib/experimental.ts index 7fa4b03c5..69d2821b8 100644 --- a/packages/cli-core/src/lib/experimental.ts +++ b/packages/cli-core/src/lib/experimental.ts @@ -1,10 +1,38 @@ /** - * Commands that ship to `main` before they're finished stay behind a name in - * `CLERK_EXPERIMENTAL`, a comma-separated list. Names are trimmed and - * case-insensitive, and unknown names are ignored. + * Gates for commands that ship to `main` before they're finished. + * + * A gated command stays behind a name in `CLERK_EXPERIMENTAL`, a + * comma-separated list (`CLERK_EXPERIMENTAL=migrate,other`). Names are + * trimmed and case-insensitive, and the gate ignores names it doesn't know. + * Nothing is gated right now; `.claude/rules/commands.md` describes how to + * gate a command and how to remove the gate when it ships. + * + * @example + * // In the command group's register(), before building the real tree: + * if (!isExperimentEnabled("my-command", env)) { + * const stub = program + * .command("my-command", { hidden: true }) + * .helpOption(false) + * .allowUnknownOption() + * .allowExcessArguments() + * .argument("[args...]") + * .action(() => requireExperiment("my-command", env)); + * stub.helpInformation = () => { + * requireExperiment("my-command", env); + * return ""; + * }; + * return; + * } */ import { CliError, ERROR_CODE, EXIT_CODE } from "./errors.ts"; +/** + * Parse `CLERK_EXPERIMENTAL` into the set of experiments it turns on. + * + * @param env - The environment to read `CLERK_EXPERIMENTAL` from. + * @returns The experiment names, trimmed and lowercased, with empty entries + * dropped. An unset or empty variable gives an empty set. + */ function enabledExperiments(env: NodeJS.ProcessEnv): Set { return new Set( (env.CLERK_EXPERIMENTAL ?? "") @@ -14,10 +42,47 @@ function enabledExperiments(env: NodeJS.ProcessEnv): Set { ); } +/** + * Whether `CLERK_EXPERIMENTAL` turns on an experiment. + * + * Use it to decide what to register, for example a hidden stub in place of + * the real command tree, so a gated command stays out of `clerk --help` and + * shell completion. + * + * @param name - The experiment's name, usually the command's (`"migrate"`). + * Case-insensitive. + * @param env - The environment to read `CLERK_EXPERIMENTAL` from. Defaults to + * `process.env`; tests pass their own. + * @returns `true` when the list names the experiment, otherwise `false`. + * + * @example + * isExperimentEnabled("migrate", { CLERK_EXPERIMENTAL: "other, Migrate" }); // true + * isExperimentEnabled("migrate", {}); // false + */ export function isExperimentEnabled(name: string, env: NodeJS.ProcessEnv = process.env): boolean { return enabledExperiments(env).has(name.toLowerCase()); } +/** + * Stop the command unless `CLERK_EXPERIMENTAL` turns on its experiment. + * + * Call it from the gated command's action, and from its `helpInformation` + * so `clerk help ` refuses too, so the user learns how to turn the + * command on instead of seeing "unknown command". + * + * @param name - The experiment's name. The error message names it as + * `clerk ` and as the value to set. + * @param env - The environment to read `CLERK_EXPERIMENTAL` from. Defaults to + * `process.env`. + * @returns Nothing. It returns normally when the experiment is on. + * @throws {CliError} When the experiment is off: code + * `ERROR_CODE.EXPERIMENT_DISABLED` (`experiment_disabled`), exit code + * `EXIT_CODE.USAGE` (2), and a message naming `CLERK_EXPERIMENTAL=`. + * + * @example + * requireExperiment("migrate", { CLERK_EXPERIMENTAL: "migrate" }); // returns + * requireExperiment("migrate", {}); // throws: `clerk migrate` is experimental… + */ export function requireExperiment(name: string, env: NodeJS.ProcessEnv = process.env): void { if (isExperimentEnabled(name, env)) return; throw new CliError( diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index d27b152d9..16a406e1e 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -236,26 +236,19 @@ describe("generateCompletions", () => { }); test("completes --source with the built-in migrate sources", () => { - const saved = process.env.CLERK_EXPERIMENTAL; - process.env.CLERK_EXPERIMENTAL = "migrate"; - try { - const result = generateCompletions(createProgram(), ["migrate", "import", "--source", ""]); - // A source you wrote is a path, so the shell's file completion stays on. - expect(result.directive).toBe(0); - const names = result.completions.map((c) => c.name); - expect(names).toEqual([ - "clerk", - "auth0", - "authjs", - "betterauth", - "firebase", - "supabase", - "workos", - ]); - } finally { - if (saved === undefined) delete process.env.CLERK_EXPERIMENTAL; - else process.env.CLERK_EXPERIMENTAL = saved; - } + const result = generateCompletions(createProgram(), ["migrate", "import", "--source", ""]); + // A source you wrote is a path, so the shell's file completion stays on. + expect(result.directive).toBe(0); + const names = result.completions.map((c) => c.name); + expect(names).toEqual([ + "clerk", + "auth0", + "authjs", + "betterauth", + "firebase", + "supabase", + "workos", + ]); }); test("returns empty for options with unknown values (file paths)", () => { @@ -333,18 +326,18 @@ describe("generateCompletions", () => { }); test("migrate sources: suggests the built-in sources", () => { - const saved = process.env.CLERK_EXPERIMENTAL; - process.env.CLERK_EXPERIMENTAL = "migrate"; - try { - const result = generateCompletions(createProgram(), ["migrate", "sources", ""]); - expect(result.directive).toBe(0); - const names = result.completions.map((c) => c.name); - expect(names).toContain("supabase"); - expect(names).toContain("workos"); - } finally { - if (saved === undefined) delete process.env.CLERK_EXPERIMENTAL; - else process.env.CLERK_EXPERIMENTAL = saved; - } + const result = generateCompletions(createProgram(), ["migrate", "sources", ""]); + expect(result.directive).toBe(0); + const names = result.completions.map((c) => c.name); + expect(names).toContain("supabase"); + expect(names).toContain("workos"); + }); + + test("completes migrate subcommands", () => { + expect(completionNames("")).toContain("migrate"); + expect(completionNames("migrate", "")).toEqual( + expect.arrayContaining(["import", "export", "undo", "runs", "sources"]), + ); }); test("open dashboard: filters subpaths by prefix", () => { diff --git a/test/e2e/migrate.test.ts b/test/e2e/migrate.test.ts index 24710feda..eead42ac6 100644 --- a/test/e2e/migrate.test.ts +++ b/test/e2e/migrate.test.ts @@ -39,7 +39,6 @@ async function cli(args: string[]) { ...process.env, CLERK_CONFIG_DIR: join(workDir, "config"), CLERK_MIGRATE_DIR: join(workDir, "runs"), - CLERK_EXPERIMENTAL: "migrate", CLERK_TELEMETRY_DISABLED: "1", }) .cwd(workDir) @@ -279,7 +278,7 @@ test("a user whose only email is unverified is refused where email is required", expect.objectContaining({ status: "skipped", reason: - "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the source never verified it)", + "only has an unverified email, and this instance requires an email (--reserve-unverified imports it reserved: usable for sign-in, though the auth platform never verified it)", }), ]); }, 60_000);