From 82d8552714e160cc66c0552f9e821ef727408519 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 12:59:23 -0400 Subject: [PATCH 01/39] feat(migrate): port clerk migrate import, its README and E2E from #479 at 7a820406 Copies these files from #479 (ra/integrate-migration-tool-into-cli) at 7a820406 unchanged, so the next commit shows exactly what slice 1 cuts. This commit does not build on its own. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/completion/__complete.ts | 12 + .../cli-core/src/commands/migrate/README.md | 1154 ++++++++++++++++- .../src/commands/migrate/index.test.ts | 270 ++++ .../cli-core/src/commands/migrate/index.ts | 205 ++- .../src/commands/migrate/lib/assume-yes.ts | 32 + .../src/commands/migrate/readme.test.ts | 178 +++ .../commands/migrate/run-interactive.test.ts | 214 +++ .../cli-core/src/commands/migrate/run.test.ts | 1128 ++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 917 +++++++++++++ .../src/commands/migrate/wizard.test.ts | 131 ++ .../cli-core/src/commands/migrate/wizard.ts | 96 ++ packages/cli-core/src/lib/json-body.ts | 2 +- packages/cli-core/src/lib/next-steps.ts | 33 +- packages/cli-core/src/lib/spinner.ts | 4 +- .../src/test/integration/completion.test.ts | 12 + test/e2e/migrate.test.ts | 161 +++ 16 files changed, 4515 insertions(+), 34 deletions(-) create mode 100644 packages/cli-core/src/commands/migrate/index.test.ts create mode 100644 packages/cli-core/src/commands/migrate/lib/assume-yes.ts create mode 100644 packages/cli-core/src/commands/migrate/readme.test.ts create mode 100644 packages/cli-core/src/commands/migrate/run-interactive.test.ts create mode 100644 packages/cli-core/src/commands/migrate/run.test.ts create mode 100644 packages/cli-core/src/commands/migrate/run.ts create mode 100644 packages/cli-core/src/commands/migrate/wizard.test.ts create mode 100644 packages/cli-core/src/commands/migrate/wizard.ts create mode 100644 test/e2e/migrate.test.ts diff --git a/packages/cli-core/src/commands/completion/__complete.ts b/packages/cli-core/src/commands/completion/__complete.ts index 7921e307e..642281f9e 100644 --- a/packages/cli-core/src/commands/completion/__complete.ts +++ b/packages/cli-core/src/commands/completion/__complete.ts @@ -1,4 +1,5 @@ import type { CommandUnknownOpts, Option } from "@commander-js/extra-typings"; +import { sources } from "../migrate/sources/registry.ts"; import { KNOWN_DASHBOARD_PATHS } from "../open/dashboard-paths.ts"; const DIRECTIVE = { @@ -33,6 +34,15 @@ const HTTP_METHOD_COMPLETIONS: Completion[] = [ { name: "DELETE", description: "Delete resource" }, ]; +/** + * The built-in migrate sources. `--source` also takes a path to a source you + * wrote, so it cannot use `.choices()`. + */ +const SOURCE_COMPLETIONS: Completion[] = sources.map((entry) => ({ + name: entry.key, + description: entry.label, +})); + /** * Hardcoded option-value completions for options that don't use Commander's `.choices()`. * Keys are the long or short flag (e.g., "--mode", "-X"). @@ -52,6 +62,7 @@ const KNOWN_OPTION_VALUES: Record = { { name: "latest", description: "Latest stable release" }, { name: "canary", description: "Latest canary (pre-release) build" }, ], + "--source": SOURCE_COMPLETIONS, "--for": [ { name: "orgs", description: "Organizations only" }, { name: "users", description: "Users only" }, @@ -66,6 +77,7 @@ const KNOWN_OPTION_VALUES: Record = { * to root, e.g. "open dashboard". */ const KNOWN_POSITIONAL_COMPLETIONS: Record = { + "migrate sources": SOURCE_COMPLETIONS, "open dashboard": KNOWN_DASHBOARD_PATHS.map((path) => ({ name: path, description: "Dashboard subpath", diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 944fb7c50..25bf72494 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -1,9 +1,1151 @@ # `clerk migrate` -> **Experimental.** `clerk migrate` is hidden and refuses to run unless `CLERK_EXPERIMENTAL` includes `migrate`: -> -> ```sh -> CLERK_EXPERIMENTAL=migrate clerk migrate --help -> ``` +Migrate users into a Clerk instance from another auth provider, or from another +Clerk instance. -Moves users into Clerk from another auth provider or another Clerk instance. +``` +clerk migrate export [-o ] [--json] +clerk migrate import [--source ] [--dry-run] [--allow-partial] [--new-run] [--yes] [--json] +clerk migrate runs [run-id] [--json] +clerk migrate undo [--dry-run] [--yes] [--json] +clerk migrate sources [source] [--json] +clerk migrate help +``` + +A migration is usually three steps: + +```sh +clerk migrate export supabase # 1. a run, holding export.json +clerk migrate import 20260929-141502-a1b2 --dry-run # 2. check it against the instance +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. `clerk migrate` on its own +is a group name, not a command: it prints its help. + +## The rules + +Every command follows these: + +1. **Nothing writes without consent.** Consent is a yes at a terminal prompt, or + `--yes`. Without either, `import` and `undo` print what they would do and + exit 2 with the command to run. `--json` means non-interactive: it never + prompts. +2. **`--dry-run` checks against the real instance, and writes nothing.** An + import's [checks](#checks) run before anything is written. Predicted + rejects stop the import unless `--allow-partial` is passed; fields that would + 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 + exports all read or write it. +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 + Ctrl-C stops an import or an undo partway. The UI goes to stderr and data to + stdout. + + While users are created or deleted, a terminal shows a bar and the counts + under it, not a spinner: + + ``` + │ ██████████████████████████████████████████████████████░░░░░░░░░░░░░░░░░░ 75% + │ 7,500/10,000 users · ✓ 7,425 created · ✗ 75 failed · ~25s left + ``` + + A spinner takes the keyboard and exits 0 on Ctrl-C, so a script that deletes + the export once the import succeeds would delete it after an interrupted + one. Without a terminal, the counts are printed at each 10%. + +## Targeting And Auth + +`clerk migrate import` resolves its Backend API key through the CLI's standard +chain: + +| Flag | Description | +| -------------------- | ------------------------------------------------------------- | +| `--secret-key ` | Use a specific Backend API secret key directly | +| `--app ` | Target an application directly, even outside a linked project | +| `--instance ` | Target `dev`, `prod`, or a full instance ID | + +Resolution order: `--secret-key` → `--app` + Platform API lookup → +`CLERK_SECRET_KEY` → the keyless project's own key → a linked project profile +from `clerk link`. + +With a key from `--secret-key` or `CLERK_SECRET_KEY`, the key alone picks the +instance, so `import` and `undo` refuse (exit 2) an `--instance` that names a +different one. `--instance dev` next to an exported `sk_live_…` key would +otherwise write to production. + +The **instance type is read from the key**: `sk_live_…` is treated as +production, anything else as development. That choice drives the throughput +defaults and the development-instance user limit below. + +**Every command prints its target first.** `import` and `undo` name the +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 +instance it reads. `runs` names the runs folder. `--json` carries the same +facts as `target`. + +``` +Target: My App (app_2x9k…), production instance ins_2x9k… +Key from: linked profile +``` + +The instance ID is what a run records, so `undo` and re-runs can tell whether +the key now in use still addresses the same instance. + +## The run store + +Every import, export and undo is a **run**, and the run store is the one place +`clerk migrate` keeps state. + +### Where runs are kept + +The first of these that is set: + +1. `--runs-dir ` +2. `CLERK_MIGRATE_DIR` +3. `/.clerk/migrate/` + +The project root is the linked profile's directory, then the git toplevel, then +the current directory. Writing to the default location adds `.clerk/` to the +project's `.gitignore` first, because run files carry user data. + +### What a run holds + +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, 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 | + +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, and a validation failure all land in +`error`. + +`creating` is written as a user's `POST /v1/users` goes out. It stays the +latest line when no answer says whether the create landed: an abort, a +network error, or a 5xx. A `created` line with `pending` lists the extra emails +and phones not yet attached. + +A run is `partial` when any user failed, was skipped or is still `creating`, +and `complete` otherwise. A run whose process died, or that never recorded a finish time, +lists as `interrupted`. A lock held by another live process refuses a second +writer with exit 2, and names the lock file to delete if that process is not a +migrate run. A lock holding this process's own PID is stale: in a container the +CLI often gets the same PID every run. + +Run folders are created owner-only (`0700`), and export files `0600`: they hold +password hashes and user data. + +`users.ndjson` writes are synchronous appends, so a run interrupted with Ctrl-C +still leaves a complete record of everything already processed. A line that +cannot be written stops that user's create from going out. An export's +file lands in its run folder as `export.json` unless `--output` says otherwise. + +### Why `users.ndjson` is NDJSON + +One JSON object per line, rather than one JSON array per file. A migration is a +long append-only stream, and that format is the one that survives it: + +- **Appendable.** Each entry is written as it happens, without rewriting the + file. A JSON array would have to be re-serialized on every user. +- **Crash-safe.** Kill the process at any point and every line already written + is still valid. A truncated array is not parseable at all. +- **Streamable.** `tail -f` shows a long import progressing live, and analysis + reads line by line instead of loading a million-user record into memory. + +Which is also why it greps usefully without any tooling: + +```sh +grep '"status":"created"' .clerk/migrate/20260929-141502-a1b2/users.ndjson | wc -l +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 +implied by the group. + +### `clerk migrate export` + +Gets users **out** of a source platform, so there is something to feed +`clerk migrate import`. + +```sh +clerk migrate export # pick a platform +clerk migrate export clerk --output users.json +clerk migrate export auth0 --domain my-tenant.us.auth0.com \ + --client-id … --client-secret … +clerk migrate export workos --api-key sk_… +``` + +The platform is an optional positional. Omitted, you get a picker built from +the registry; given, it runs directly. Each platform resolves its own flags — +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 +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 other question the command asked. So that step — +and only that step, never a fetch already under way or a file already written — +runs inside a retry: the failure is explained, the prompt comes back, and the +rest of the export continues against whichever credential worked. Agent mode +and a non-TTY fail outright instead, having nobody to ask, and `-y` fails too, +having been told not to. + +| Platform | Source | Feeds | +| ------------ | -------------------------------- | --------------------- | +| `clerk` | Clerk Backend API | `--source clerk` | +| `auth0` | Auth0 Management API | `--source auth0` | +| `supabase` | Supabase Postgres (`auth.users`) | `--source supabase` | +| `authjs` | Auth.js database | `--source authjs` | +| `betterauth` | Better Auth database | `--source betterauth` | +| `firebase` | Firebase Identity Toolkit | `--source firebase` | +| `workos` | WorkOS User Management API | `--source workos` | + +Every export is a [run](#the-run-store), and the file lands in the run +folder as `export.json`. `--output` writes it somewhere else instead, +resolved against the **current directory** like every other path flag here; +the run still records where. Nothing is asked about where the file goes. + +The file is an envelope around the users: + +```json +{ + "clerkMigrate": 1, + "source": "clerk", + "exportedAt": "2026-09-29T14:15:02.000Z", + "runId": "20260929-141502-a1b2", + "users": [ … ] +} +``` + +`source` is what lets `clerk migrate import ` run with no +`--source`. A Firebase export adds `firebase`, the project's hash +parameters, so the import needs no `--firebase-*` flags. + +`--json` prints the result on stdout instead — `{ target, run, output, users, +coverage, next }` — and never prompts, so a missing credential exits 2 naming +the flag to pass. + +| 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 | +| `--json` | all | Print the result as JSON; never prompts | +| `--db-url ` | `supabase`, `authjs`, `betterauth` | Postgres, MySQL, libsql/Turso or SQLite connection string | +| `--service-account ` | `firebase` | Path to a service account key JSON file | +| `--domain ` | `auth0` | Tenant domain, e.g. `my-tenant.us.auth0.com` | +| `--client-id ` | `auth0` | Machine-to-machine application client ID | +| `--client-secret ` | `auth0` | Machine-to-machine application client secret | +| `--api-key ` | `workos` | WorkOS secret API key, the one starting `sk_` | +| `--with-identities` | `workos` | Also record each user's OAuth providers | +| `--no-with-identities` | `workos` | Skip the OAuth provider fan-out without being asked | + +`export clerk` also takes the targeting flags — it reads from a Clerk instance, +so it resolves a key the same way `clerk migrate import` does, with one extra +step. The linked project is usually the migration's _destination_, so taking it +as the source without asking is how a run exports an instance and imports it +back into itself. Instead: + +- **Naming the instance runs unquestioned.** `--secret-key `, `--app`, + `--instance`, or an exported `CLERK_SECRET_KEY` — any of them is a sentence + you typed for this run, so none of them opens a picker. That is what makes + the export scriptable outside agent mode, and it keeps an exported key + outranking the linked profile here the way it does everywhere else in the + CLI. +- Anything resolved on your behalf — the linked project, a keyless app — is + never taken silently. A picker of every **instance** on your account opens + instead — one flat row each, `my-app - Production instance (ins_…)`, not an + application picker followed by an instance picker — with the resolved + application's instances listed **first** so taking one is still a single + Enter. Only when there are no instances to offer does it stop and list + `--secret-key`, `--app`/`--instance` and `clerk link` instead. +- With nothing to resolve at all (no link, no key, no flags), you get the + application picker `clerk users` uses — `Select a Clerk application to use:`, + followed by an instance picker when the application has more than one — + rather than an error about an unlinked directory. That is `clerk link`'s + picker, so it does offer `+ Create a new application`; a brand-new + application has no users to export, so it is never the answer here. + +The instance picker (the second tier) has no "create a new application" choice. +Its rows are searchable by what they show, so typing an application name, +`production`, or an instance id all narrow it. + +In agent mode the resolved instance is used without a prompt; pass +`--secret-key` or `--app`/`--instance` to be explicit. + +After each export you get a field-coverage table — which Clerk-relevant fields +were present on how many users — so you know the data is thin _before_ you +import it, not after: + +``` +Field coverage + ✓ 3/3 have an email address + ✗ 0/3 have a phone number + ! 1/3 have a username + ! 2/3 have a password (not exportable — see below) + +Exported 3 users to /project/.clerk/migrate/20260929-141502-a1b2/export.json +Run 20260929-141502-a1b2. See each user with `clerk migrate runs 20260929-141502-a1b2`. + +Import them with: + clerk migrate import 20260929-141502-a1b2 + + Imports into whichever instance the resolved secret key belongs to. + For production, add `--instance prod` or use a production secret key. +``` + +The import command prints through the same channel as the coverage table +rather than the gutter's **Next steps** outro, which is human-only — an agent +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 +profile in that order. + +The export run has one line per exported user, so `clerk migrate runs` lists it +alongside imports. Every export takes `--runs-dir ` to keep that run +somewhere else. + +#### Three platforms export no passwords + +- **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 + only from a support request. Add a `passwordHash` field to each user before + importing, or migrate without passwords. +- **WorkOS** returns neither password hashes nor TOTP secrets, and has no + support-request escape hatch: hashes go in on import and never come back, and + `totp.secret` is returned on enrol only. There is nothing to add to the file. + +All three say so on every run. The coverage row counts users who _have_ a +password, so the size of the gap is visible up front — `workos` prints that row +at zero unconditionally, because zero is the only value it can take. + +#### Database-backed exports (`supabase`, `authjs`, `betterauth`) + +These three read the database directly, over **`--db-url`**: + +```sh +clerk migrate export supabase --db-url "postgres://postgres:...@db.xxx.supabase.co:5432/postgres" +clerk migrate export authjs --db-url "mysql://user:...@127.0.0.1:3306/authjs" +clerk migrate export betterauth --db-url "./db.sqlite" +clerk migrate export betterauth --db-url "libsql://app-org.turso.io?authToken=..." # or set TURSO_AUTH_TOKEN +``` + +Postgres and MySQL go through `Bun.sql`; SQLite through `bun:sqlite`; +`libsql://` (Turso) over the server's HTTP pipeline endpoint, since `bun:sqlite` +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 libsql token comes from `?authToken=` on the URL, or from +`TURSO_AUTH_TOKEN` / `LIBSQL_AUTH_TOKEN`, and is redacted like a password. + +**Connection strings are redacted everywhere.** Errors show +`postgres://***@host/db`, including when the password itself contains an +unencoded `@` — the most common mistake, and exactly when the string ends up in +an error message. + +Connection failures get a hint rather than a driver error. Bun reports both an +unreachable host and a closed port as "Connection closed", so: + +| Situation | What you are told | +| ------------------------ | ------------------------------------------------------------------------------------------------- | +| Host or port unreachable | Check the host and port. On Supabase: use the pooler connection string, or enable the IPv4 add-on | +| Credentials rejected | Check the user and password | +| Table missing | Check the database name and SELECT permission. On Supabase: enable Auth, connect as `postgres` | +| SQLite file missing | Check the path and that the file is readable | + +**`supabase` reads the database rather than the Admin API** because +`encrypted_password` exists only there. An API-based export would force every +user to reset their password; this one carries the bcrypt digests across. It +also keeps `raw_app_meta_data`, which is what the import's +[checks](#checks) read for each user's providers. + +**`authjs` tries `User`, then `user`, then `users`.** Auth.js has no single +schema — Prisma capitalizes the table, Drizzle does not, and Postgres treats +the difference as significant once quoted. The run reports which one it found. +The verified column is read as `emailVerified`, or `email_verified` on a legacy +NextAuth table. Auth.js core stores no passwords, so its users arrive without +credentials. + +**`betterauth` reads its schema before it queries.** It finds the tables +(`user` and `account`, or `users` and `accounts` under `usePlural: true`), how +the columns are spelled (camelCase, or the snake_case Better Auth's Drizzle +generator writes by default), and which plugin columns exist. The username +plugin adds `username`, admin adds `banned` (carried only while `banExpires` is +unset or in the future), phone-number adds `phoneNumber`, and so on; selecting a column that is not there fails the whole query, and the +database answers the question better than the user can. Passwords come from a +`LEFT JOIN` onto the credential `account` row — left, not inner, so a user who +only ever signed in with OAuth is still exported. + +#### `firebase` + +```sh +clerk migrate export firebase --service-account ./service-account.json +``` + +Create a key at **Project settings → Service accounts → Generate new private +key**. The account needs to read users and the project's password hash +parameters (`signIn.hashConfig`). Either of these works, both verified live: + +- **Firebase Authentication Admin** (`roles/firebaseauth.admin`). The simplest + option, but it can also modify users and auth settings. +- **Read-only:** Firebase Authentication Viewer (`roles/firebaseauth.viewer`) + plus a custom role that adds `firebaseauth.configs.getHashConfig`: + + ```sh + gcloud iam roles create clerkMigrateHashExport --project=PROJECT_ID \ + --title="Clerk migrate hash export" \ + --permissions=firebaseauth.configs.get,firebaseauth.configs.getHashConfig,firebaseauth.users.get + gcloud projects add-iam-policy-binding PROJECT_ID \ + --member=serviceAccount:SA_EMAIL --role=roles/firebaseauth.viewer + gcloud projects add-iam-policy-binding PROJECT_ID \ + --member=serviceAccount:SA_EMAIL --role=projects/PROJECT_ID/roles/clerkMigrateHashExport + ``` + +Without `firebaseauth.configs.getHashConfig`, users still export, but the +export can't read the hash parameters. It says which permission is missing, and +the import then needs `--firebase-signer-key`, `--firebase-salt-separator`, +`--firebase-rounds` and `--firebase-mem-cost` (from **Authentication → Users → +⋮ → Password hash parameters** in the Firebase console). + +Without `--service-account` you are prompted for it, the way `export supabase` +prompts for its connection string. The answer can be a path to the downloaded +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 +appears in output. + +Firebase's scrypt is a modified variant, so a digest is worthless without the +project's four hash parameters. The export **reads them from the project** and +saves them in the export file's envelope, so the import needs nothing more: + +``` +Password hash parameters +Read from the project and saved in the export file, so the import needs nothing more. +``` + +Reading the config needs a broader role than listing users, so if it is denied +the export still succeeds and points at **Authentication → Users → (⋮) → +Password hash parameters** instead. An export with no password hashes says so +and asks for nothing. + +A user whose hash is present but whose salt is not (or the reverse) has both +dropped: half a credential produces a user nobody can sign in as. + +`FIREBASE_AUTH_EMULATOR_HOST` is honoured, so this works against the local +Firebase emulator as well as production. The target line names the emulator +when it is set. + +**No `firebase-admin`.** The spike the plan called for was run and _passed_ — a +compiled binary can import the SDK and complete `listUsers`, so the known +Firestore-under-compile bug does not reach the Auth Admin surface. It was still +not adopted: the SDK is 74 MB across 158 packages, including Firestore and +Cloud Storage, which would roughly double the ~62 MB binary every user +downloads, to serve one subcommand. What it does here is two REST calls and an +RS256 JWT, and Bun's Web Crypto signs RS256 with no dependency at all. + +#### Auth0 credentials + +Needs a machine-to-machine application with the `read:users` scope +(Applications → APIs → Auth0 Management API → Machine to Machine +Applications). Resolved from flags, then `AUTH0_DOMAIN` / `AUTH0_CLIENT_ID` / +`AUTH0_CLIENT_SECRET`, then a prompt. In agent mode a prompt is impossible, so +it exits naming **every** missing credential at once rather than one per run. + +Auth0 pages this endpoint only through the first **1000** users. Past that the +export stops and says so, pointing at Auth0's bulk export job — silently +returning the first thousand would read as "that is everyone". It still exits +0, and `--json` carries `truncated: true`. A tenant of exactly 1000 is +complete, and gets no warning. The bulk job's NDJSON file imports as it is. + +A credential the platform rejects (400, 401 or 403) is asked for again at a +terminal. An outage, a `429` or a refused connection is not: another +credential would not fix it, and it exits 1. + +#### WorkOS credentials + +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; agent mode exits naming both instead. + +There is no `--db-url` sibling because there is no database to point it at. +WorkOS is API-only: apps commonly mirror users into their own store through +webhooks, but that mirror is a derived copy holding no credentials, so the +User Management API is the only source. Pagination is cursor-based, so unlike +Auth0 there is no record ceiling — `after` runs to the end of the tenant. + +**`--with-identities` is off by default, and it is not free.** WorkOS has no +bulk endpoint for OAuth identities, so it is one request per user: ten requests +becomes 1,010 for a thousand users. Nothing it returns can be imported — +`POST /v1/users` has no external-accounts field — so it buys a provider +breakdown in the coverage report, and an `identities` array kept in the export +file for whoever runs the migration. The interactive path asks once, after the +user count is known, defaulting to no; agent mode takes the flag's answer and +asks nothing. `-y` answers the question `yes`, so pass +`--no-with-identities` to skip the fan-out without being asked. + +The breakdown prints as its own **OAuth providers** block under the coverage +table, not as extra coverage rows: + +``` +Field coverage + ✓ 6/6 have an email address + ✗ 0/6 have a password (WorkOS returns none) + +OAuth providers + GoogleOAuth 2 users + MicrosoftOAuth 1 user + no OAuth provider 2 users + not readable 1 user + Those users have no `identities` field in the export, rather than an empty one. +``` + +Separate because the two kinds of row do not mean the same thing. A coverage +row is "N of the M users have this field"; a provider row has no such +denominator — one user holding two providers is counted under both, so the +counts can sum past the user count, and `not readable` is not a property of the +user at all. + +A lookup that fails is counted on its own row rather than folded into +`no OAuth provider`. "Lookup failed" and "has no providers" are different +facts, and flattening the first into the second would understate social +sign-in. + +Non-interactive runs get progress on stderr every 500 users during the fan-out, +and every 10 pages during the user fetch. `withSpinner` hands a no-op to +anything that is not a TTY, so without this an agent exporting a large tenant +would see nothing at all until the run finished. + +### `clerk migrate import` + +Reads an exported user file, maps it onto Clerk's user schema, checks every +user against the destination instance, and creates them through the Backend +API. + +```sh +clerk migrate import 20260929-141502-a1b2 --dry-run # check, write nothing +clerk migrate import 20260929-141502-a1b2 --yes # an export run +clerk migrate import users.json --source clerk --yes # any other file +clerk migrate import # a human is asked +``` + +| Flag | Description | +| --------------------------------------- | ------------------------------------------------------------------- | +| `[file\|export-run-id]` | The export file, or the ID of the export run that wrote it | +| `--source ` | Where the file came from: a [source](#sources), or one you wrote | +| `--dry-run` | Run the [checks](#checks) against the instance, and write nothing | +| `--allow-partial` | Import the users that pass, and record the rest as skipped | +| `--new-run` | Start a new run instead of [continuing](#re-running) an earlier one | +| `--require-password` | Import only users that carry a password digest | +| `--skip-legal-checks` | Import users with no legal acceptance into an instance requiring it | +| `--firebase-signer-key ` | Firebase base64 signer key (overrides the export file) | +| `--firebase-salt-separator ` | Firebase base64 salt separator | +| `--firebase-rounds ` | Firebase scrypt rounds | +| `--firebase-mem-cost ` | Firebase scrypt memory cost | +| `-y, --yes` | Import without prompting | +| `--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 +`--instance`. + +An export run ID stands for the file that run wrote, and the import records it +as `fromExport`. A file `clerk migrate export` wrote carries its source, so it +needs no `--source`, and a `--source` that contradicts it exits 2. Any other +file — a bare JSON array, a CSV, Firebase's own `{ "users": [...] }` — needs +`--source`. NDJSON, one user per line (what Auth0's bulk export job writes), is +read too: always for `.ndjson` and `.jsonl`, and for a `.json` file that +doesn't parse whole. A leading BOM is ignored in JSON and CSV. + +**What a human is asked, and what an agent is told.** A human at a terminal who +leaves out the file is asked for its path, and is asked for a source only when +the file does not name one. An agent, a non-TTY run, or `--json` without the +file exits 2 naming what to pass. + +**Nothing is written without consent.** After the checks, a human is asked +`Import N users?`, and declining writes nothing. `--yes` skips the question. +Without either — an agent, a non-TTY run, `--json` — the run prints the checks +and exits 2 with the exact command to run. + +**Every run prints its target first**, then which [case](#re-running) applies, +then the checks. + +Failures do not stop the run: each user's outcome is written to the +[run](#the-run-store) and the import continues. A `429` backs off — +honouring `Retry-After` when the response carries it — and retries up to 5 +times before the user is recorded as failed. The command exits 1 if any user +failed. + +An **unrecognized password hasher** aborts the whole run before anything is +sent, because it would import credentials nobody can sign in with. + +`--json` returns `{ target, run, resume, checks, result }`. + +#### Re-running + +Running the same import again continues where it left off. The match is the +file's sha256, the source (and a custom source's content hash), and the +instance ID; the latest matching import run decides what happens: + +| Latest match | Re-running does | +| ----------------------------------------- | ---------------------------------------------------------------------- | +| none | a new run | +| interrupted (dead lock or no finish time) | continues the same run, skipping the users it created | +| `partial` | continues the same run, retrying the users that failed or were skipped | +| `complete` | nothing: prints "Already imported in run …" and exits 0 | +| `undone` | a new run | +| has an undo that did not finish | exits 2, naming the `clerk migrate undo` that finishes it | + +`--new-run` skips the lookup. A run another live process holds exits 2. + +A continued run also finishes what the last one left open: + +- A user still `creating` is looked up by `external_id`. One Clerk holds is + adopted as `created`, and not created again; one it doesn't is created. +- A user whose `created` line has `pending` identifiers gets just those + attaches. + +`--require-password` records each user it leaves out as `skipped`, so the run +ends `partial`. + +When an import completes, it names the folders it no longer needs: the export it +read, which holds your users' data, and its own run, which only `undo` needs. +Each comes with the `rm -rf` to remove it. + +#### Checks + +Every import runs the checks before writing anything, and `--dry-run` stops +after them. They sort the users three ways: + +- **Rejected** — users Clerk would refuse. Each gets the first reason that + applies: + - it failed schema validation + - its source requested a skip (Better Auth: an anonymous guest; Supabase: a + soft-deleted user) + - 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 + any other user, with a warning + - its source ID, email, phone or username repeats an earlier user in the file + that passes the other checks. 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: …`) + - it lacks an identifier the instance requires. An email or phone counts + only when it is verified, because an unverified one is attached after the + user exists + - it has no identifier left once those the instance has turned off are + stripped + - it lacks a first or last name the instance requires + - it has an authenticator app secret or backup codes, and the instance has + 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 + - 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 + without it (`skip_legal_checks`), with a warning + - its username breaks the instance's username rules (length, letters, + the allowed special characters) + - its password is not the shape its hasher says (`bcrypt`, with a cost up 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). The checks offer to turn on + the first kind; nothing can turn on the second + - 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 + - a development instance: it is past the 100-user headroom + (`CLERK_MIGRATE_DEV_USER_LIMIT` when Clerk raised it), counted in file + order +- **Imported, but not everything comes across** — fields the instance is not + set up to store, fields Clerk has no place for (`Clerk won't 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, URL or + HTML: Better Auth's phone sign-up stores the number as the name). +- **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. + +``` +Checks + 120 users checked + ✗ 12 users rejected + 12: only has an unverified email, and this instance requires an email + 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 is not set up to store + Clerk won't store: department (120 users) + ✓ 108 users to import + +Or change the instance instead + Make Email optional at sign-up + clerk config patch --app app_… --instance ins_… --json '{"auth_email":{"required_for_sign_up":false}}' + Enable Username + clerk config patch --app app_… --instance ins_… --json '{"auth_username":{"used_for_sign_up":true}}' +``` + +Each fix names its instance with `--instance`, so it changes the instance the +import targets, whatever the key's source. When Clerk could not name the +instance (a `key_…` fallback ID), the fix points at the Dashboard instead: in +`--json` it carries `url` in place of `command`. + +The fixes are offers, not corrections: an instance that requires an email is +configured as its owner intended, and fixing the export may be the answer. When +the instance settings cannot be read (BAPI `/v1/domains` → the instance's +Frontend API `/v1/environment`), required fields are not checked and the run +says so. + +#### 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. + +#### Additional identifiers + +Only the first verified email and phone go on `POST /v1/users`. Every +additional verified identifier, and every unverified one, is attached +afterwards with its own request, ahead of any create still queued, and backs +off on a `429` like the create. A refusal there is logged and the user still +counts as imported — a duplicate secondary email should not undo an otherwise +successful user. An attach with no answer stays `pending` for a re-run. + +The first phone gets the same treatment when Clerk refuses it — a country the +instance does not support, or a number that is not E.164 — and the user has an +email: the create is retried without the phone, and the refusal is logged. + +#### Throughput + +Defaults follow Clerk's documented `POST /v1/users` limits: 100 req/s for +production instances, 10 req/s for development. Concurrency defaults to ~95% of +that, assuming ~100ms of API latency. Both are overridable: + +| Variable | Effect | +| --------------------------------- | ------------------------------------------------------------ | +| `CLERK_MIGRATE_RATE_LIMIT` | Requests per second | +| `CLERK_MIGRATE_CONCURRENCY_LIMIT` | Concurrent in-flight requests | +| `CLERK_MIGRATE_DEV_USER_LIMIT` | Development-instance user limit the checks use (default 100) | + +A non-numeric or non-positive value is ignored in favour of the default. + +A development instance's user limit is checked with the other +[checks](#checks): new development instances are created with a 100-user limit, +production instances have none, and the run reads the live count +(`GET /v1/users/count`). The limit itself is not served by any API, so for a +development instance Clerk has raised, set `CLERK_MIGRATE_DEV_USER_LIMIT` to +the raised limit; `--allow-partial` imports up to the headroom. + +Users that do exceed the limit come back in the error breakdown as +`You have reached your limit of N users`, annotated with what a development +instance can do about it. + +### `clerk migrate runs` + +`runs` reads the [run store](#the-run-store). + +```sh +clerk migrate runs # every run, newest first +clerk migrate runs 20260929-141502-a1b2 # one run in full +clerk migrate runs --json +``` + +| Flag | Description | +| ------------------- | ----------------------------- | +| `[run-id]` | Show one run instead of all | +| `--json` | The same data, on stdout | +| `--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 +users that failed or were skipped, with the path to the full record. An unknown +ID exits 2. + +### `clerk migrate undo` + +Deletes the users an import run created. The import run is the whole record of +what to delete: every source ID whose latest line is `created`, by the Clerk ID +recorded beside it. + +The one search is for a source ID whose latest line is `creating`: the run +stopped with that user's `POST /v1/users` sent and unanswered, so Clerk may +hold the user without its ID on record. Those are looked up by `external_id`. +The import's checks refused any source ID the instance already held, but a +later import of the same source IDs could have created one since. So a user +that another import run in the runs folder records as created is left out. + +```sh +clerk migrate undo 20260929-141502-a1b2 --dry-run # preview, delete nothing +clerk migrate undo 20260929-141502-a1b2 # confirms first +clerk migrate undo 20260929-141502-a1b2 --yes # no prompt +``` + +| Flag | Description | +| ------------------- | -------------------------------------------------------- | +| `` | The import run to undo | +| `--dry-run` | Show the preview and delete nothing | +| `-y, --yes` | Delete without prompting | +| `--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`. + +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`). 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. + +It refuses with exit 2, and deletes nothing, when: + +- the resolved key addresses a different instance than the run imported into + (the error names both) +- the run is an export or undo run +- the run has already been undone + +Deletes go through the same scheduler and `429` backoff as the import. A user +already gone from the instance counts as deleted. The undo is a run of its own, +`kind: "undo"` with `undoes: `. The import is marked `undone` only when +every user is deleted. A partial undo exits 1, and running `undo` again retries +the users that failed, in the same undo run. + +### `clerk migrate sources` + +```sh +clerk migrate sources # every source, with what it carries +clerk migrate sources betterauth # one source in full +clerk migrate sources ./my-source.ts # a source you wrote +clerk migrate sources --json +``` + +| Flag | Description | +| ---------- | ---------------------------------------------------------------- | +| `[source]` | A built-in key, or the path to a source you wrote, to show fully | +| `--json` | The same data, on stdout | + +`sources` alone prints the table above. `sources ` shows one source in +full: its export command, what it carries with a note for each, where each +field lands (`encrypted_password → password`), its fixed defaults, and any +caveats. An unknown key exits 2 and lists the valid ones. There is no +intro/outro gutter: this reads a static registry rather than running anything. + +### `clerk migrate help` + +`clerk migrate help` and `clerk migrate --help` print the help for the +group or one command, with examples. `clerk migrate help ` does the +same. + +## Sources + +A source maps one platform's export onto Clerk's user schema, and says what it +brings across. Adding a platform is one file in `sources/` plus one line in +`sources/registry.ts`; `--source`'s tab-completion reads from that array. + +| Key | Reads | Passwords | MFA | Metadata | +| ------------ | ----------------------------- | --------- | ------- | -------- | +| `clerk` | Clerk Dashboard export | partial | partial | partial | +| `auth0` | Auth0 Export Users API | partial | no | yes | +| `authjs` | Auth.js / NextAuth user table | no | no | no | +| `betterauth` | Better Auth export | yes | no | no | +| `firebase` | `firebase auth:export` | yes | no | no | +| `supabase` | Supabase `auth.users` export | yes | no | partial | +| `workos` | WorkOS User Management API | no | no | yes | + +There is no column for social sign-ins, because no source copies them and none +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). + +### `--source` + +`clerk migrate import` takes `--source `: + +- 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. + +A file `clerk migrate export` wrote names its own source, so it needs none. + +### Custom sources + +Migrating from a platform with no built-in, without recompiling the CLI: + +```sh +clerk migrate import users.json --source ./my-platform.ts +``` + +The file lives in **your** project, not in the CLI, and is imported at runtime. +It exports the same shape the built-ins use — plain data, no imports, since +there is nothing in a compiled binary for your file to import from: + +```ts +export default { + key: "myplatform", + label: "My Platform", + description: "Exports from My Platform's admin console.", + transformer: { + account_ref: "userId", // required: becomes the Clerk user's external_id + contact_email: "email", + given: "firstName", + family: "lastName", + pw_bcrypt: "password", + }, + carries: { + passwords: { level: "yes", note: "bcrypt hashes from the pw_bcrypt column." }, + mfa: { level: "no", note: "Not exported." }, + metadata: { level: "no", note: "Not exported." }, + }, + defaults: { passwordHasher: "bcrypt" }, + postTransform: (user) => { + if (!user.firstName) delete user.firstName; + }, +}; +``` + +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. +Plain `.js` works too. + +An import run records a custom source's key and a hash of the file, so an +edited source counts as a different source. + +#### Validation + +The file is code the CLI executes, so its shape is checked before use and +rejected with the specific problem rather than crashing mid-pipeline: + +| Problem | Message | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Path does not exist | `No source file at /abs/path.ts.` | +| No default export, but a named one | ``has no default export. Found named export `myPlatform` — did you mean `export default`?`` | +| Does not parse | `Could not load ./f.ts: Expected identifier but found ","` | +| Nothing maps to `userId` | ``no source field maps to `userId`. Every user needs one — it becomes the Clerk user's external_id`` | +| No `carries` | `` `carries` must say what the source brings across: { passwords, mfa, metadata }, each { level, note } `` | +| `key` clashes with a built-in | `key is "clerk", which is already a built-in source` | +| A hook is not a function | `postTransform must be a function when present` | + +The `userId` check is the load-bearing one: without it the import would run to +completion and create every user with no `external_id`, which is what makes a +migration re-runnable. + +### Metadata + +Metadata a user can edit on the source platform — Auth0's `user_metadata`, +Supabase's `raw_user_meta_data`, WorkOS's `metadata` — 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. Auth0's +`app_metadata` goes to `private_metadata`. + +### Better Auth passwords + +Better Auth hashes with its own scrypt by default and lets an app swap in bcrypt +or argon2, so one database can hold more than one kind. The hasher is detected +per user: + +| Stored value | Sent as | +| --------------------------- | ---------------------------------------------------------- | +| `<32 hex>:<128 hex>` | `scrypt:16384:16:1$$`, hasher `scrypt_werkzeug` | +| `$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 +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. + +A password Clerk cannot verify is **dropped, not rejected**: the user imports +without it and can sign in another way or reset it. Their run line carries +`passwordDropped: true`. + +### 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 +`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`): `true`/`false`. A CSV export + stringifies these, so `"false"` is read as false, not as a non-empty string. +- **Timestamp** (`authjs`, `supabase`): a nullable confirmation time. Any real + value means verified; `""`, `null` and `\N` do not. + +### Firebase hash parameters + +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**. + +```sh +clerk migrate import users.json --source firebase -y \ + --firebase-signer-key --firebase-salt-separator \ + --firebase-rounds 8 --firebase-mem-cost 14 +``` + +All four are **required as a set** — supplying some but not all is a usage error +naming what is missing. A partial set produces a well-formed digest that +verifies against nothing, so users would import successfully and then be unable +to sign in. + +The flags are read first, then the export file's envelope, which carries the +parameters when `clerk migrate export firebase` could read them from the +project. The flags win, so a rotated key can be passed without re-exporting. +The envelope sits in the gitignored run folder, since the signer key is a +Firebase secret. + +An export with no password hashes needs no parameters at all. + +## Schema fields + +What a source maps _onto_. Every user is validated against this schema +before any request is made, so a field a source produces that is not listed +here is silently dropped — Zod strips unknown keys — and never reaches Clerk. +Writing a custom source means targeting these names exactly. + +The schema lives in `validator.ts`; adding a platform means adding a source, +not editing it. + +**Required:** `userId` (`string`). It becomes the Clerk user's `external_id`, +which is what makes a migration re-runnable. + +**Identifiers.** At least one of these must be present, or the import's checks +reject the user as invalid. Each accepts a single value or an array. + +| Field | Type | Description | +| -------------------------- | -------------------- | ---------------------------------- | +| `email` | `string \| string[]` | Primary verified email address(es) | +| `emailAddresses` | `string \| string[]` | Additional verified emails | +| `unverifiedEmailAddresses` | `string \| string[]` | Unverified emails | +| `phone` | `string \| string[]` | Primary verified phone number(s) | +| `phoneNumbers` | `string \| string[]` | Additional verified phones | +| `unverifiedPhoneNumbers` | `string \| string[]` | Unverified phones | +| `username` | `string` | Username | + +**Profile, password and 2FA.** + +| Field | Type | Description | +| -------------------- | ---------- | --------------------------------------------------- | +| `firstName` | `string` | First name | +| `lastName` | `string` | Last name | +| `password` | `string` | The hashed password from the source platform | +| `passwordHasher` | `enum` | **Required whenever `password` is set** (see below) | +| `totpSecret` | `string` | TOTP secret | +| `backupCodesEnabled` | `boolean` | Whether backup codes are enabled | +| `backupCodes` | `string[]` | Backup codes | + +Clerk verifies the digest as-is, so `passwordHasher` must name the algorithm the +source actually used: + +`argon2i`, `argon2id`, `awscognito`, `bcrypt`, `bcrypt_peppered`, +`bcrypt_sha256_django`, `hmac_sha256_utf16_b64`, `ldap_ssha`, `md5`, +`md5_phpass`, `md5_salted`, `phpass`, `pbkdf2_sha1`, `pbkdf2_sha256`, +`pbkdf2_sha256_django`, `pbkdf2_sha512`, `pbkdf2_sha512_hex`, `scrypt_firebase`, +`scrypt_werkzeug`, `sha256`, `sha256_salted`, `sha512_symfony` + +An unrecognized hasher aborts the run rather than importing credentials nobody +can sign in with. + +**Metadata.** + +| Field | Type | Description | +| ----------------- | -------- | -------------------------------------------------------- | +| `unsafeMetadata` | `object` | Readable **and writable** by the client — never trust it | +| `publicMetadata` | `object` | Readable by the client, writable only server-side | +| `privateMetadata` | `object` | Server-side only | + +**Account state.** These are passed straight through to `POST /v1/users`, and +are how a Clerk-to-Clerk migration keeps original signup dates instead of +stamping every user with today's. + +| Field | Type | Description | +| --------------------------- | --------- | ------------------------------------------------------------------------------------ | +| `createdAt` | `string` | Original creation timestamp. A number is epoch milliseconds, or seconds below `1e11` | +| `legalAcceptedAt` | `string` | When legal terms were accepted | +| `banned` | `boolean` | Whether the user is banned | +| `bypassClientTrust` | `boolean` | Skip client trust verification | +| `createOrganizationEnabled` | `boolean` | Whether the user can create orgs | +| `createOrganizationsLimit` | `number` | Maximum orgs the user can create | +| `deleteSelfEnabled` | `boolean` | Whether the user can delete their account | +| `skipLegalChecks` | `boolean` | Skip legal acceptance checks | +| `skipPasswordChecks` | `boolean` | Skip password requirements on import | + +## API Endpoints + +| Method | Path | Used by | +| -------- | ----------------------------------------------- | ------------------------------------------------------------------------------- | +| `POST` | `/v1/users` | `migrate import` — creates each user | +| `POST` | `/v1/email_addresses` | `migrate import` — attaches additional emails | +| `POST` | `/v1/phone_numbers` | `migrate import` — attaches additional phones | +| `GET` | `/v1/users?limit=&offset=&order_by=+created_at` | `migrate export clerk` — pages the whole instance, oldest first, 500 at a time | +| `GET` | `/v1/users/count` | `migrate import` — headroom against a development instance's user limit | +| `GET` | `/v1/users?external_id=…` | `migrate import` — checks for users already in the instance, 100 values a call | +| `GET` | `/v1/users?external_id=…` | `migrate undo` — finds users whose create was in flight when the import stopped | +| `GET` | `/v1/users?user_id=…` | `migrate undo` — reads the imported users back, 100 a call | +| `DELETE` | `/v1/users/{user_id}` | `migrate undo` — deletes one user | +| `GET` | `/v1/instance` | `migrate import`, `undo`, `export clerk` — names the instance behind the key | +| `GET` | `/v1/domains` | `migrate import` checks — resolves the Frontend API host | + +The checks also read the instance's Frontend API `GET /v1/environment` +(bootstrapping a dev browser first on development instances) for its +attributes and enabled social providers. Nothing in `clerk migrate` writes +instance settings: the checks print the `clerk config patch` to run instead. + +Three exports talk to their own platform rather than to Clerk: + +| Method | Path | Used by | +| ------ | ---------------------------------------------- | -------------------------------------------------------- | +| `POST` | `https:///oauth/token` | `export auth0` — Management API access token | +| `GET` | `https:///api/v2/users` | `export auth0` — 100 per page, 1000 users maximum | +| `POST` | `https://oauth2.googleapis.com/token` | `export firebase` — RS256 assertion → access token | +| `GET` | `…/v1/projects/{project_id}/accounts:batchGet` | `export firebase` — pages users, 1000 at a time | +| `GET` | `…/admin/v2/projects/{project_id}/config` | `export firebase` — reads the scrypt hash parameters | +| `GET` | `…/user_management/users` | `export workos` — 100 per page, cursor-paginated | +| `GET` | `…/user_management/users/{id}/identities` | `export workos` — `--with-identities` only, one per user | + +The two Identity Toolkit paths are on `identitytoolkit.googleapis.com`, or on +`FIREBASE_AUTH_EMULATOR_HOST` when that is set. The two WorkOS paths are on +`api.workos.com`. + +The three database exports (`supabase`, `authjs`, `betterauth`) make no HTTP +calls at all — they connect over `--db-url`. + +## Notes + +- `userId` in the source file becomes the Clerk user's `external_id`. That is + what makes a migration re-runnable and reversible. +- 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). + Users that do not are rejected by the checks as invalid. diff --git a/packages/cli-core/src/commands/migrate/index.test.ts b/packages/cli-core/src/commands/migrate/index.test.ts new file mode 100644 index 000000000..10a636f8e --- /dev/null +++ b/packages/cli-core/src/commands/migrate/index.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getMode, setMode } from "../../mode.ts"; +import { createProgram } from "../../cli-program.ts"; +import { exportPlatformKeys } from "./export/registry.ts"; +import { isAssumeYes, setAssumeYes } from "./lib/assume-yes.ts"; + +function findCommand(names: string[]) { + let current = createProgram().commands.find((cmd) => cmd.name() === names[0]); + for (const name of names.slice(1)) { + current = current?.commands.find((cmd) => cmd.name() === name); + } + return current; +} + +describe("registerMigrate", () => { + test("registers migrate as a top-level command group", () => { + const migrate = findCommand(["migrate"]); + expect(migrate).toBeDefined(); + expect(migrate?.description()).toContain("Migrate users"); + }); + + test("registers the import subcommand", () => { + expect(findCommand(["migrate", "import"])).toBeDefined(); + }); + + // The direction is never implied: `import` and `export` are siblings, so a + // default would make one of them the meaning of the bare group name. + test("leaves migrate with no default subcommand", () => { + const migrate = findCommand(["migrate"]) as unknown as { _defaultCommandName?: string }; + expect(migrate._defaultCommandName).toBeFalsy(); + }); + + test.each([ + "--source", + "--dry-run", + "--allow-partial", + "--new-run", + "--require-password", + "--json", + "--firebase-signer-key", + "--firebase-salt-separator", + "--firebase-rounds", + "--firebase-mem-cost", + "--yes", + "--secret-key", + "--app", + "--instance", + ])("migrate import accepts %s", (flag) => { + const flags = findCommand(["migrate", "import"])?.options.map((option) => option.long); + expect(flags).toContain(flag); + }); + + test("registers sources with an optional source and --json", () => { + const sources = findCommand(["migrate", "sources"]); + expect(sources?.registeredArguments[0]?.required).toBe(false); + expect(sources?.options.map((option) => option.long)).toEqual(["--json"]); + }); + + test.each([[["transformers"]], [["transformers", "list"]]])("no longer registers %p", (names) => { + expect(findCommand(["migrate", ...names])).toBeUndefined(); + }); + + test.each([ + "--transformer", + "--transformer-file", + "--file", + "--resume-after", + "--skip-unsupported-providers", + ])("migrate import drops %s", (flag) => { + expect(findCommand(["migrate", "import"])?.options.map((o) => o.long)).not.toContain(flag); + }); + + test.each([ + [["export"]], + [["export", "clerk"]], + [["export", "auth0"]], + [["export", "supabase"]], + [["export", "authjs"]], + [["export", "betterauth"]], + [["export", "firebase"]], + ])("registers migrate %p", (names) => { + expect(findCommand(["migrate", ...names])).toBeDefined(); + }); + + // Bare `migrate export` runs the picker rather than defaulting to a + // platform, so nobody exports from the wrong place by pressing enter. + test("leaves export with no default subcommand", () => { + const group = findCommand(["migrate", "export"]) as unknown as { + _defaultCommandName?: string; + }; + expect(group._defaultCommandName).toBeFalsy(); + }); + + test("registers an export subcommand per registered platform", () => { + const registered = findCommand(["migrate", "export"])?.commands.map((c) => c.name()); + for (const key of exportPlatformKeys()) expect(registered).toContain(key); + }); + + test.each(["--output", "--secret-key", "--app", "--instance"])( + "export clerk accepts %s", + (flag) => { + expect(findCommand(["migrate", "export", "clerk"])?.options.map((o) => o.long)).toContain( + flag, + ); + }, + ); + + test.each(["--domain", "--client-id", "--client-secret", "--output"])( + "export auth0 accepts %s", + (flag) => { + expect(findCommand(["migrate", "export", "auth0"])?.options.map((o) => o.long)).toContain( + flag, + ); + }, + ); + + test.each(["supabase", "authjs", "betterauth"])("export %s accepts --db-url", (platform) => { + expect(findCommand(["migrate", "export", platform])?.options.map((o) => o.long)).toContain( + "--db-url", + ); + }); + + test("export firebase accepts --service-account", () => { + expect(findCommand(["migrate", "export", "firebase"])?.options.map((o) => o.long)).toContain( + "--service-account", + ); + }); + + test.each(exportPlatformKeys())( + "migrate export %s names the run folder as the default output", + (platform) => { + const output = findCommand(["migrate", "export", platform])?.options.find( + (option) => option.long === "--output", + ); + expect(output?.description).toContain("instead of the run folder"); + }, + ); + + test.each(exportPlatformKeys())("migrate export %s accepts --json", (platform) => { + const flags = findCommand(["migrate", "export", platform])?.options.map((o) => o.long); + expect(flags).toContain("--json"); + }); + + test("registers runs with an optional run ID", () => { + const runs = findCommand(["migrate", "runs"]); + expect(runs?.registeredArguments[0]?.required).toBe(false); + expect(runs?.options.map((option) => option.long)).toEqual(["--json", "--runs-dir"]); + }); + + test("registers undo with a required run ID and its flags", () => { + const undo = findCommand(["migrate", "undo"]); + expect(undo?.registeredArguments[0]?.required).toBe(true); + expect(undo?.options.map((option) => option.long)).toEqual([ + "--dry-run", + "--yes", + "--json", + "--secret-key", + "--app", + "--instance", + "--runs-dir", + ]); + }); + + test("the logs group is gone: runs replaces it", () => { + expect(findCommand(["migrate", "logs"])).toBeUndefined(); + }); + + // Every command reads or writes the run store, so each one can be pointed + // somewhere else. + test.each([ + [["import"]], + [["runs"]], + [["undo"]], + [["export"]], + ...exportPlatformKeys().map((platform) => [["export", platform]]), + ])("migrate %p accepts --runs-dir", (names) => { + expect(findCommand(["migrate", ...names])?.options.map((option) => option.long)).toContain( + "--runs-dir", + ); + }); + + // It also takes a path, so it cannot use `.choices()`: completion offers the + // built-in keys through `KNOWN_OPTION_VALUES` instead. + test("--source accepts any value, so a path to a source you wrote gets through", () => { + const option = findCommand(["migrate", "import"])?.options.find((o) => o.long === "--source"); + expect(option?.argChoices).toBeUndefined(); + }); + + test("takes the file, or the export run that wrote it, as an optional argument", () => { + const [argument] = findCommand(["migrate", "import"])?.registeredArguments ?? []; + expect(argument?.name()).toBe("file|export-run-id"); + expect(argument?.required).toBe(false); + }); + + test.each([["-y", "--yes"]])("exposes %s as the short form of %s", (short, long) => { + const option = findCommand(["migrate", "import"])?.options.find((o) => o.long === long); + expect(option?.short).toBe(short); + }); + + // Every export takes `-y`: it is what turns the credential-retry loop off, + // and the loop is on every one of them. + test.each(exportPlatformKeys())("migrate export %s accepts --yes", (platform) => { + const flags = findCommand(["migrate", "export", platform])?.options.map((o) => o.long); + expect(flags).toContain("--yes"); + }); +}); + +/** + * The hook is the only link between the parsed flag and the two places that + * read it, three layers down. If it stopped firing — a Commander upgrade that + * dropped hook inheritance, an action registered outside the group — both + * behaviours would silently revert and every unit test around them would still + * pass, because they set the flag directly. + */ +describe("the migrate group's -y hook", () => { + async function parse(argv: string[]) { + const program = createProgram(); + // `exitOverride` so a usage error inside the action throws here instead of + // taking the test runner down with it; the hook has already run by then. + program.exitOverride(); + try { + await program.parseAsync(["node", "clerk", ...argv]); + } catch { + // The action is allowed to fail — only the hook's effect is under test. + } + return isAssumeYes(); + } + + test("records -y on an export", async () => { + expect(await parse(["migrate", "export", "supabase", "-y", "--db-url", "./none.sqlite"])).toBe( + true, + ); + }); + + // `--json` means nobody reads a prompt, and agent mode is how every prompt in + // this tree already knows to stand down. + test("--json runs the command in agent mode", async () => { + const original = getMode(); + const runsDir = fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-json-")); + try { + setMode("human"); + await parse(["migrate", "runs", "--json", "--runs-dir", runsDir]); + expect(getMode()).toBe("agent"); + } finally { + setMode(original); + fs.rmSync(runsDir, { recursive: true, force: true }); + } + }); + + // The export group declares --json too, and takes the flag; the hook has to + // read the group's options as well as the subcommand's. + test("--json on an export subcommand runs it in agent mode", async () => { + const original = getMode(); + try { + setMode("human"); + await parse(["migrate", "export", "supabase", "--json", "--db-url", "./none.sqlite"]); + expect(getMode()).toBe("agent"); + } finally { + setMode(original); + } + }); + + test("records its absence, so a previous run cannot leak into this one", async () => { + setAssumeYes(true); + expect(await parse(["migrate", "export", "supabase", "--db-url", "./none.sqlite"])).toBe(false); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/index.ts b/packages/cli-core/src/commands/migrate/index.ts index 34847075c..7e29975dc 100644 --- a/packages/cli-core/src/commands/migrate/index.ts +++ b/packages/cli-core/src/commands/migrate/index.ts @@ -1,28 +1,183 @@ import type { Program } from "../../cli-program.ts"; -import { isExperimentEnabled, requireExperiment } from "../../lib/experimental.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; - } - - program +import { parseIntegerOption } from "../../lib/option-parsers.ts"; +import { setMode } from "../../mode.ts"; +import { setAssumeYes } from "./lib/assume-yes.ts"; +import { registerMigrateExport } from "./export/index.ts"; +import { RUNS_DIR_DESCRIPTION, RUNS_DIR_FLAG } from "./lib/run-store.ts"; +import { run } from "./run.ts"; +import { runs } from "./runs.ts"; +import { undo } from "./undo.ts"; +import { list as sources } from "./sources/list.ts"; + +const migrate = { run, runs, undo, sources }; + +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 provider or another Clerk instance") + .setExamples([ + { + command: "clerk migrate export supabase", + description: "Export users from Supabase into a new run", + }, + { + command: "clerk migrate import 20260929-141502-a1b2 --dry-run", + description: "Check an export against the instance, and write nothing", + }, + { + command: "clerk migrate import 20260929-141502-a1b2 --yes", + description: "Import it", + }, + { command: "clerk migrate runs", description: "List every migration run" }, + { + command: "clerk migrate undo 20260929-141502-a1b2", + description: "Delete the users an import created", + }, + { command: "clerk migrate sources", description: "What each source brings across" }, + ]); + + // `-y` is read several layers down — by the credential-retry loop and the + // export commands — so it is resolved once here rather than threaded + // through every export handler. Hooks are inherited, so this fires for every + // subcommand under `migrate`; one that declares no `-y` resolves to false. + // + // `--json` means nobody is reading a prompt, so it runs the command in agent + // mode: every prompt in this tree already stands down for an agent, with the + // usage error naming what to pass instead. + migrateCommand.hook("preAction", (_thisCommand, actionCommand) => { + // With globals: `export auth0 --json` lands on the export group's own + // --json, which the subcommand's opts() never sees. + const opts = actionCommand.optsWithGlobals(); + setAssumeYes(Boolean(opts.yes)); + if (opts.json) setMode("agent"); + }); + + // Named, not `isDefault`. `import` and `export` are the two directions this + // group moves users in, and neither is implied by the bare group name — a + // default would make `clerk migrate --file users.json` mean "import" while + // its sibling has to be spelled out. Bare `clerk migrate` prints help. + // + // The flags stay here rather than on `migrate`, matching how `config` keeps + // its own on `pull`/`patch`/`put` — a group's help is a list of subcommands + // and examples, not a merge of everything underneath it. + migrateCommand + .command("import") + .description("Import users from an exported JSON or CSV file") + .argument("[file|export-run-id]", "The export file, or the ID of the export run that wrote it") + .option( + "--source ", + "Where the file came from: a built-in source, or a source you wrote. Not needed for a file from `clerk migrate export`", + ) + .option("--dry-run", "Check the file against the instance, report, and write nothing") + .option("--allow-partial", "Import the users that pass the checks, and skip the rest") + .option("--new-run", "Start a new run instead of continuing an earlier one of this file") + .option("--require-password", "Import only users that have a password") + .option( + "--skip-legal-checks", + "Import users with no legal acceptance on record into an instance that requires it", + ) + .option("--firebase-signer-key ", "Firebase base64 signer key (overrides the export file)") + .option("--firebase-salt-separator ", "Firebase base64 salt separator") + .option("--firebase-rounds ", "Firebase scrypt rounds", (value: string) => + parseIntegerOption(value, "--firebase-rounds", { min: 1 }), + ) + .option("--firebase-mem-cost ", "Firebase scrypt memory cost", (value: string) => + parseIntegerOption(value, "--firebase-mem-cost", { min: 1 }), + ) + .option("-y, --yes", "Import without prompting") + .option("--json", "Output as JSON; never prompts, so pair it with --yes to import") + .option("--secret-key ", "Backend API secret key to use") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option(RUNS_DIR_FLAG, RUNS_DIR_DESCRIPTION) + .setExamples([ + { + command: "clerk migrate import 20260929-141502-a1b2 --dry-run", + description: "Check what an export run wrote, and write nothing", + }, + { + command: "clerk migrate import 20260929-141502-a1b2 --yes", + description: "Import it. Run it again to continue after a failure", + }, + { + command: "clerk migrate import users.json --source clerk --allow-partial --yes", + description: "Import a Clerk Dashboard export, skipping users that would be rejected", + }, + { + command: "clerk migrate import users.json --source ./my-source.ts --yes", + description: "Import with a source you wrote", + }, + ]) + .action(async (input, _opts, cmd) => + migrate.run({ + ...(cmd.optsWithGlobals() as Parameters[0]), + ...(input ? { input } : {}), + }), + ); + + registerMigrateExport(migrateCommand); + + // Flat, not under a noun group: this is the one command in the tree that + // destroys data in Clerk, and it is worth keeping short and prominent. + migrateCommand + .command("undo") + .description("Delete the users an import run created") + .argument("", "The import run to undo (see `clerk migrate runs`)") + .option("--dry-run", "Show what would be deleted, and delete nothing") + .option("-y, --yes", "Delete without prompting") + .option("--json", "Output as JSON; never prompts, so pair it with --yes to delete") + .option("--secret-key ", "Backend API secret key to use") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .option(RUNS_DIR_FLAG, RUNS_DIR_DESCRIPTION) + .setExamples([ + { + command: "clerk migrate undo 20260929-141502-a1b2 --dry-run", + description: "Preview what would be deleted", + }, + { + command: "clerk migrate undo 20260929-141502-a1b2 --yes", + description: "Delete without prompting", + }, + ]) + .action(async (runId, _opts, cmd) => + migrate.undo(runId, cmd.optsWithGlobals() as Parameters[1]), + ); + + migrateCommand + .command("runs") + .description("List migration runs, or show one") + .argument("[run-id]", "A run to show in full") + .option("--json", "Output as JSON") + .option(RUNS_DIR_FLAG, RUNS_DIR_DESCRIPTION) + .setExamples([ + { 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", + }, + { command: "clerk migrate runs --json", description: "Machine-readable listing" }, + ]) + .action(async (runId, _opts, cmd) => + migrate.runs(runId, cmd.optsWithGlobals() as Parameters[1]), + ); + + // A compiled binary has no source tree to grep, so the available mappings + // need a command rather than only appearing in the interactive picker. + migrateCommand + .command("sources") + .description("List the sources an import can read, or show one in full") + .argument("[source]", "A built-in source, or the path to a source you wrote") + .option("--json", "Output as JSON") + .setExamples([ + { command: "clerk migrate sources", description: "What each source brings across" }, + { + command: "clerk migrate sources betterauth", + description: "Where each field lands, how to export, and caveats", + }, + { command: "clerk migrate sources ./my-source.ts", description: "Check a source you wrote" }, + ]) + .action(async (source, _opts, cmd) => + migrate.sources(source, cmd.optsWithGlobals() as Parameters[1]), + ); } diff --git a/packages/cli-core/src/commands/migrate/lib/assume-yes.ts b/packages/cli-core/src/commands/migrate/lib/assume-yes.ts new file mode 100644 index 000000000..dd277dad0 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/lib/assume-yes.ts @@ -0,0 +1,32 @@ +/** + * Whether this run was given `-y`. + * + * `-y` is not the same question as {@link isAgent}. Agent mode says the CLI + * *cannot* prompt; `-y` says the operator does not want it to. Most prompts + * only care about the first — a confirm is skipped by either — but the two + * places that take a default instead of asking need to know a human chose it, + * so the two cannot be collapsed into one flag. + * + * Held per-run rather than threaded through, because the readers are three + * layers below the command that parses it: `withInputRetry` sits under every + * credential prompt, and the export commands read it to decide what to print. + * Passing it down would put a `yes` parameter on every export handler + * signature on the way. This mirrors `mode.ts`, which resolves + * `--mode` once in a `preAction` hook and is read the same way. + * + * Set by the `migrate` group's `preAction` hook, so every subcommand under it + * is covered whether or not it declares the flag — one that does not simply + * resolves to `false`. + */ + +let assumeYes = false; + +/** Records this run's `-y`. Called once per invocation, before the action. */ +export function setAssumeYes(value: boolean): void { + assumeYes = value; +} + +/** Whether `-y` was passed to the command now running. */ +export function isAssumeYes(): boolean { + return assumeYes; +} diff --git a/packages/cli-core/src/commands/migrate/readme.test.ts b/packages/cli-core/src/commands/migrate/readme.test.ts new file mode 100644 index 000000000..b64b5a1fb --- /dev/null +++ b/packages/cli-core/src/commands/migrate/readme.test.ts @@ -0,0 +1,178 @@ +/** + * Keeps README.md and the command tree honest about each other. + * + * This README documents six commands, seven export platforms, seven sources + * and the run store across ~1000 lines. Checking it by eye at review time does not + * scale, and a doc that names a flag the binary rejects is worse than no doc: + * the reader trusts it and gets a usage error. + * + * Both directions are checked — every example must resolve, and every flag must + * be written down — so neither renaming a flag nor adding one passes silently. + */ + +import { describe, expect, test } from "bun:test"; +import type { Command } from "commander"; +import { createProgram } from "../../cli-program.ts"; + +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. */ +function fencedBlocks(markdown: string): string[] { + return [...markdown.matchAll(/^```[a-z]*\n([\s\S]*?)^```/gm)].map((match) => match[1] ?? ""); +} + +/** + * Every `clerk migrate …` invocation the README puts in front of a reader, from + * fenced blocks and inline backticks alike — both get copied. + */ +function documentedCommands(markdown: string): string[] { + const found = new Set(); + + for (const block of fencedBlocks(markdown)) { + // Line continuations first: the Firebase example spans three lines. + for (const line of block.replace(/\\\n\s*/g, " ").split("\n")) { + const start = line.indexOf("clerk migrate"); + // A command never contains a backtick, a `#`, or a run of two spaces; + // the sample error output that quotes `clerk migrate` mid-sentence does, + // and so does a pasted listing whose descriptions sit in a padded column. + if (start !== -1) + found.add( + line + .slice(start) + .split(/[`#]|\s{2,}/)[0]! + .trim(), + ); + } + } + + for (const match of markdown.matchAll(/`(clerk migrate[^`]*)`/g)) { + found.add(match[1]!.trim()); + } + + return [...found]; +} + +/** Walks as deep as the tree allows; the first flag or positional stops it. */ +function resolve(tokens: string[]): { command: Command; rest: string[] } { + let command = createProgram() as Command; + let index = 0; + for (; index < tokens.length; index++) { + const child = command.commands.find( + (candidate) => + candidate.name() === tokens[index] || candidate.aliases().includes(tokens[index]!), + ); + if (!child) break; + command = child; + } + return { command, rest: tokens.slice(index) }; +} + +/** + * The flags a command accepts, including those of a default subcommand. + * + * A group that registers a subcommand `isDefault` hands it everything after + * the group name, so this has to see the same flags Commander does or every + * such example reads as unsupported. + */ +function flagsOf(command: Command): string[] { + const own = command.options.flatMap( + (option) => [option.short, option.long].filter(Boolean) as string[], + ); + + const defaultChild = command.commands.find( + // Commander records the default subcommand on the parent, not the child. + (child) => child.name() === (command as any)._defaultCommandName, + ); + + return defaultChild ? [...own, ...flagsOf(defaultChild)] : own; +} + +/** Every command under `migrate`, so no subcommand escapes the flag sweep. */ +function migrateTree(): { path: string; command: Command }[] { + const collected: { path: string; command: Command }[] = []; + const visit = (command: Command, path: string) => { + collected.push({ path, command }); + for (const child of command.commands) { + if (child.name() !== "help") visit(child, `${path} ${child.name()}`); + } + }; + visit(resolve(["migrate"]).command, "migrate"); + return collected; +} + +const EXAMPLES = documentedCommands(README); + +/** + * The words of an example, minus grammar: a synopsis writes `` and + * `[--json]` to say what goes there, and those are not values to resolve. + */ +function words(example: string): string[] { + return example + .split(/\s+/) + .slice(1) + .filter((token) => !/^[<[]/.test(token) && !/[>\]]$/.test(token)); +} + +/** One case per (example, flag) pair, so a failure names the exact flag. */ +const FLAG_USES: [string, string][] = EXAMPLES.flatMap((example) => + words(example) + // Every command accepts `--help`; Commander does not list it as an option. + .filter((token) => token.startsWith("-") && token !== "--help") + .map((token) => [example, token.split("=")[0]!] as [string, string]), +); + +describe("migrate README", () => { + // Guards the extractor: a regex that silently matched nothing would make + // every check below pass vacuously. + test("finds the documented examples", () => { + expect(EXAMPLES.length).toBeGreaterThan(10); + expect(FLAG_USES.length).toBeGreaterThan(10); + }); + + test.each(EXAMPLES)("`%s` resolves to a real command", (example) => { + // `help` is Commander's own subcommand on every group, not a registered one. + const tokens = words(example).filter((token) => token !== "help"); + const { command, rest } = resolve(tokens); + const firstFlag = rest.findIndex((token) => token.startsWith("-")); + const positionals = firstFlag === -1 ? rest : rest.slice(0, firstFlag); + // Leftover words before any flag are positionals — only some commands take + // them, and a subcommand that does not exist lands here too. + if (positionals.length > 0) expect(command.registeredArguments.length).toBeGreaterThan(0); + // A parent means at least `migrate` resolved. Checking the name instead + // would be wrong: `migrate export clerk` is itself named `clerk`. + expect(command.parent).not.toBeNull(); + }); + + test.each(FLAG_USES)("`%s` uses %s, which the command accepts", (example, flag) => { + const { command } = resolve(words(example)); + expect(flagsOf(command)).toContain(flag); + }); + + // The README is organised around the commands, so each one gets a heading. + test.each(resolve(["migrate"]).command.commands.map((child) => child.name()))( + "has a section for `clerk migrate %s`", + (name) => { + expect(README).toContain(`### \`clerk migrate ${name}\``); + }, + ); + + test("states the rules every command follows", () => { + expect(README).toContain("## The rules"); + for (const rule of [ + "Nothing writes without consent", + "`--dry-run` checks against the real instance", + "State lives in one place", + "Every command prints its target first", + "Every subcommand takes `--json`", + ]) { + expect(README).toContain(rule); + } + }); + + test.each(migrateTree())("$path documents every flag it accepts", ({ command }) => { + const undocumented = flagsOf(command).filter( + (flag) => flag.startsWith("--") && flag !== "--help" && !README.includes(flag), + ); + expect(undocumented).toEqual([]); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/run-interactive.test.ts b/packages/cli-core/src/commands/migrate/run-interactive.test.ts new file mode 100644 index 000000000..991c64e04 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -0,0 +1,214 @@ +/** + * The human-mode half of `migrate import`: the prompts fill in what was not + * passed, and nothing is written until the operator says yes. + * + * Kept in its own file because `mock.module` registrations are process-lifetime, + * and `bun test --parallel` puts several files in each worker — so a mocked + * `prompts.ts` would leak into any file that later lands in the same worker and + * imports the real one. Human mode itself needs no mock: `setMode` is the + * supported override. + */ + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getMode, setMode, type Mode } from "../../mode.ts"; +import { listageStubs, useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockSelect = mock(async () => "clerk" as unknown); +const mockText = mock(async () => "export.json" as unknown); +let confirmAnswer = true; +/** Every confirmation the run put up, in order — the wording is the assertion. */ +let confirmMessages: string[] = []; +let originalMode: Mode; + +mock.module("../../lib/listage.ts", () => ({ + ...listageStubs, + select: (...args: unknown[]) => mockSelect(...(args as [])), +})); + +// Every export of the real module must appear here — a missing one is a link +// error at import time, which takes down the whole file rather than one prompt. +mock.module("../../lib/prompts.ts", () => ({ + confirm: async ({ message }: { message: string }) => { + confirmMessages.push(message); + return confirmAnswer; + }, + multiselect: async () => [], + text: (...args: unknown[]) => mockText(...(args as [])), + password: async () => "", + editor: async () => "{}", +})); + +const { run } = await import("./run.ts"); +const { UserAbortError } = await import("../../lib/errors.ts"); +const { _setConfigDir } = await import("../../lib/config.ts"); +const { listRuns, startRun } = await import("./lib/run-store.ts"); + +const captured = useCaptureLog(); + +let workDir: string; +let configDir: string; +let originalCwd: string; +let originalFetch: typeof globalThis.fetch; +let requests: { method: string; url: string }[]; + +const EXPORT = [ + { id: "u1", primary_email_address: "a@x.dev" }, + { id: "u2", primary_email_address: "b@x.dev" }, +]; + +const runsDir = () => path.join(workDir, ".clerk", "migrate"); + +beforeAll(() => { + originalMode = getMode(); + setMode("human"); + originalCwd = process.cwd(); + originalFetch = globalThis.fetch; + workDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-interactive-"))); + configDir = fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-interactive-config-")); + _setConfigDir(configDir); + process.chdir(workDir); +}); + +afterAll(() => { + setMode(originalMode); + globalThis.fetch = originalFetch; + _setConfigDir(undefined); + process.chdir(originalCwd); + fs.rmSync(workDir, { recursive: true, force: true }); + fs.rmSync(configDir, { recursive: true, force: true }); +}); + +beforeEach(() => { + requests = []; + confirmAnswer = true; + confirmMessages = []; + mockSelect.mockReset(); + mockText.mockReset(); + mockSelect.mockResolvedValue("clerk"); + mockText.mockResolvedValue("export.json"); + fs.rmSync(path.join(workDir, ".clerk"), { recursive: true, force: true }); + fs.rmSync(path.join(configDir, "config.json"), { force: true }); + fs.writeFileSync(path.join(workDir, "export.json"), JSON.stringify(EXPORT)); + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const method = init?.method ?? "GET"; + requests.push({ method, url: url.toString() }); + if (url.pathname === "/v1/instance") { + return Response.json({ object: "instance", id: "ins_1", environment_type: "development" }); + } + if (url.pathname === "/v1/users" && method === "GET") return Response.json([]); + if (url.pathname === "/v1/users/count") return Response.json({ total_count: 0 }); + return Response.json({ id: "user_created" }); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + process.exitCode = 0; +}); + +const created = () => + requests.filter((r) => r.method === "POST" && new URL(r.url).pathname === "/v1/users"); + +const importOptions = { input: "export.json", source: "clerk", secretKey: "sk_test_x" }; + +describe("prompts for what was not passed", () => { + test("bare `clerk migrate import` asks for the file, then the source, then imports", async () => { + await run({ secretKey: "sk_test_x" }); + + expect(mockText).toHaveBeenCalledTimes(1); + expect(mockSelect).toHaveBeenCalledTimes(1); + expect(created()).toHaveLength(2); + }); + + test("asks only for what was not passed", async () => { + await run(importOptions); + + expect(mockText).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + // The envelope names the source, so asking would only invite a wrong answer. + test("does not ask for a source when the file names its own", async () => { + const exportRun = startRun(runsDir(), { + kind: "export", + target: { platform: "clerk" }, + source: "clerk", + }); + const file = path.join(exportRun.dir, "export.json"); + fs.writeFileSync( + file, + JSON.stringify({ + clerkMigrate: 1, + source: "clerk", + exportedAt: "2026-09-01T00:00:00.000Z", + runId: exportRun.record.id, + users: EXPORT, + }), + ); + exportRun.update({ file: { path: file, sha256: "x" } }); + exportRun.finish(); + + await run({ input: exportRun.record.id, secretKey: "sk_test_x" }); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(created()).toHaveLength(2); + }); +}); + +describe("consent", () => { + test("asks before writing, naming how many users", async () => { + await run(importOptions); + + expect(confirmMessages).toEqual(["Import 2 users?"]); + }); + + test("prints the checks before the question", async () => { + await run(importOptions); + + expect(captured.err).toContain("Checks"); + }); + + test("declining writes nothing to Clerk, and records no run", async () => { + confirmAnswer = false; + + await expect(run(importOptions)).rejects.toThrow(UserAbortError); + + expect(created()).toHaveLength(0); + expect(listRuns(runsDir())).toHaveLength(0); + }); + + test("--yes does not ask", async () => { + await run({ ...importOptions, yes: true }); + + expect(confirmMessages).toEqual([]); + expect(created()).toHaveLength(2); + }); + + // `--json` means nobody reads a prompt, even at a terminal. + test("--json never asks, and without --yes refuses", async () => { + await expect(run({ ...importOptions, json: true })).rejects.toThrow(/needs consent/); + + expect(confirmMessages).toEqual([]); + expect(created()).toHaveLength(0); + }); + + test("an unrecognized password hasher aborts before any request", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { + id: "u1", + primary_email_address: "a@x.dev", + password_digest: "d", + password_hasher: "rot13", + }, + ]), + ); + + await expect(run(importOptions)).rejects.toThrow(/Invalid password hasher/); + expect(created()).toHaveLength(0); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts new file mode 100644 index 000000000..c733e77f2 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -0,0 +1,1128 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { _setConfigDir } from "../../lib/config.ts"; +import { type CliError, EXIT_CODE } from "../../lib/errors.ts"; +import { credentialStoreStubs, useCaptureLog } from "../../test/lib/stubs.ts"; + +// Every test below names its own `--secret-key`, which short-circuits the +// signed-in check — except the one that asserts what happens without it. +mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); +import { latestUserLines, listRuns, readRun, startRun } from "./lib/run-store.ts"; +import { __resetCustomSourcesForTesting } from "./sources/registry.ts"; +import { explainErrors, run, validateRunOptions } from "./run.ts"; + +/** A real-shaped bcrypt digest: the checks reject anything that is not. */ +const BCRYPT = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; + +let workDir: string; +let configDir: string; +let originalCwd: string; + +/** Where runs land for a project rooted at `workDir`. */ +const runsDir = () => path.join(workDir, ".clerk", "migrate"); + +beforeAll(() => { + originalCwd = process.cwd(); + workDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-run-"))); + configDir = fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-run-config-")); + _setConfigDir(configDir); + process.chdir(workDir); + fs.writeFileSync(path.join(workDir, "users.json"), "[]"); + fs.writeFileSync(path.join(workDir, "users.txt"), ""); +}); + +afterAll(() => { + _setConfigDir(undefined); + process.chdir(originalCwd); + fs.rmSync(workDir, { recursive: true, force: true }); + fs.rmSync(configDir, { recursive: true, force: true }); +}); + +describe("validateRunOptions", () => { + test("accepts a source and an existing JSON file", () => { + expect(validateRunOptions({ source: "clerk", file: "users.json" })).toEqual({ + source: "clerk", + file: "users.json", + }); + }); + + test.each([ + ["no source", { file: "users.json" }, /Missing --source/], + ["no file", { source: "clerk" }, /Missing the file to import/], + ["a missing file", { source: "clerk", file: "nope.json" }, /File not found/], + ["an unsupported extension", { source: "clerk", file: "users.txt" }, /Unsupported file type/], + ])("rejects %s", (_label, options, message) => { + expect(() => validateRunOptions(options)).toThrow(message); + }); + + test("names the valid sources when one is missing", () => { + expect(() => validateRunOptions({ file: "users.json" })).toThrow(/clerk/); + }); +}); + +type Stub = { + /** What `/v1/environment` reports; `null` makes the settings unreadable. */ + settings?: { attributes?: object; social?: object; sign_up?: object } | null; + /** Users already in the instance, as `GET /v1/users` returns them. */ + existing?: { + id: string; + external_id?: string; + username?: string; + email_addresses?: { email_address: string }[]; + }[]; + /** `GET /v1/users/count`. */ + count?: number; + /** Source IDs whose `POST /v1/users` fails with a 422. */ + failing?: Set; +}; + +describe("run", () => { + const captured = useCaptureLog(); + let originalFetch: typeof globalThis.fetch; + let requests: { method: string; url: string; body: unknown }[]; + + const export2 = [ + { + id: "u1", + primary_email_address: "a@x.dev", + password_digest: BCRYPT, + password_hasher: "bcrypt", + }, + { id: "u2", primary_email_address: "b@x.dev" }, + ]; + + /** A fake Clerk: the instance, its settings, its users, and the writes. */ + function stubClerk(stub: Stub = {}): void { + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const method = init?.method ?? "GET"; + const body = init?.body ? (JSON.parse(init.body as string) as Record) : null; + requests.push({ method, url: url.toString(), body }); + + if (url.pathname === "/v1/instance") { + return Response.json({ object: "instance", id: "ins_1", environment_type: "development" }); + } + if (url.pathname === "/v1/domains") { + if (!stub.settings) return new Response("nope", { status: 500 }); + return Response.json({ + data: [{ is_satellite: false, frontend_api_url: "https://fapi.example.com" }], + }); + } + if (url.pathname.includes("/v1/dev_browser")) return Response.json({ token: "jwt" }); + if (url.pathname.includes("/v1/environment")) { + return Response.json({ user_settings: stub.settings }); + } + if (url.pathname === "/v1/users/count") { + return Response.json({ object: "total_count", total_count: stub.count ?? 0 }); + } + if (method === "GET" && url.pathname === "/v1/users") { + // BAPI strips the `+` the lookup puts on each external_id. + const wanted = new Set( + [...url.searchParams.values()].map((value) => value.replace(/^\+(?!\d)/, "")), + ); + return Response.json( + (stub.existing ?? []).filter( + (user) => + wanted.has(user.external_id ?? "") || + wanted.has(user.username ?? "") || + (user.email_addresses ?? []).some((email) => wanted.has(email.email_address)), + ), + ); + } + if (method === "POST" && url.pathname === "/v1/users") { + const externalId = body?.external_id as string; + if (stub.failing?.has(externalId)) { + return Response.json( + { + errors: [{ code: "form_identifier_exists", message: "That email address is taken." }], + }, + { status: 422 }, + ); + } + return Response.json({ id: `user_${externalId}` }); + } + return Response.json({ id: "ok" }); + }) as typeof fetch; + } + + beforeAll(() => { + originalFetch = globalThis.fetch; + }); + + beforeEach(() => { + requests = []; + delete process.env.CLERK_MIGRATE_RATE_LIMIT; + fs.rmSync(runsDir(), { recursive: true, force: true }); + fs.rmSync(path.join(configDir, "config.json"), { force: true }); + fs.writeFileSync(path.join(workDir, "export.json"), JSON.stringify(export2)); + stubClerk(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + process.exitCode = 0; + }); + + const baseOptions = { + source: "clerk", + input: "export.json", + yes: true, + secretKey: "sk_test_x", + }; + + const created = () => + requests + .filter((r) => r.method === "POST" && r.url.endsWith("/v1/users")) + .map((r) => (r.body as { external_id: string }).external_id); + + const exitCodeOf = async (promise: Promise) => + ((await promise.catch((caught: unknown) => caught)) as CliError | undefined)?.exitCode; + + test("refuses before anything else when nobody is signed in", async () => { + const previous = process.env.CLERK_SECRET_KEY; + delete process.env.CLERK_SECRET_KEY; + try { + await expect(run({ source: "clerk", input: "export.json", yes: true })).rejects.toThrow( + /Not logged in/, + ); + expect(requests).toHaveLength(0); + } finally { + if (previous !== undefined) process.env.CLERK_SECRET_KEY = previous; + } + }); + + test("imports every user in the file end to end", async () => { + await run(baseOptions); + + expect(created()).toEqual(["u1", "u2"]); + expect(captured.err).toContain("Imported:"); + }); + + test("prints the target first", async () => { + await run(baseOptions); + expect(captured.err).toContain("Target: development instance ins_1"); + expect(captured.err.indexOf("Target:")).toBeLessThan(captured.err.indexOf("Checks")); + }); + + test("records the run in the project's run store", async () => { + await run(baseOptions); + + const [record, ...rest] = listRuns(runsDir()); + expect(rest).toHaveLength(0); + expect(record).toMatchObject({ + kind: "import", + status: "complete", + source: "clerk", + counts: { total: 2, created: 2 }, + target: { keySource: "--secret-key", instanceType: "dev", instanceId: "ins_1" }, + }); + expect(record?.file?.path).toBe(path.join(workDir, "export.json")); + expect(record?.file?.sha256).toMatch(/^[0-9a-f]{64}$/); + + const lines = [...latestUserLines(runsDir(), record!.id).values()]; + expect(lines.map((line) => [line.sourceId, line.status, line.clerkId])).toEqual([ + ["u1", "created", "user_u1"], + ["u2", "created", "user_u2"], + ]); + }); + + // A complete import leaves the export's user data behind; say how to remove it. + test("names the folders a complete import no longer needs", async () => { + await run(baseOptions); + const [record] = listRuns(runsDir()); + expect(captured.err).toContain(`rm -rf ${path.join(runsDir(), record!.id)}`); + }); + + // Pasted unquoted, `rm -rf …/app copy/…` deletes `…/app`. + test("quotes the cleanup paths", async () => { + const spaced = path.join(workDir, "app copy"); + await run({ ...baseOptions, runsDir: spaced }); + + const [record] = listRuns(spaced); + expect(captured.err).toContain(`rm -rf '${path.join(spaced, record!.id)}'`); + }); + + describe("export envelopes", () => { + /** An export run whose envelope holds `users`, as `clerk migrate export` writes it. */ + function exportRun(source: string, rows: unknown[], extra: Record = {}) { + const run = startRun(runsDir(), { kind: "export", target: { platform: source }, source }); + const file = path.join(run.dir, "export.json"); + fs.writeFileSync( + file, + JSON.stringify({ + clerkMigrate: 1, + source, + exportedAt: "2026-09-01T00:00:00.000Z", + runId: run.record.id, + users: rows, + ...extra, + }), + ); + run.update({ file: { path: file, sha256: "x" } }); + return { record: run.finish(), file }; + } + + const { source: _source, input: _input, ...noSource } = baseOptions; + + test("imports by export run ID, with the source the envelope names", async () => { + const { record } = exportRun("clerk", export2); + + await run({ ...noSource, input: record.id }); + + expect(requests.filter((r) => r.url.endsWith("/v1/users"))).toHaveLength(2); + const imported = listRuns(runsDir()).find((candidate) => candidate.kind === "import"); + expect(imported).toMatchObject({ source: "clerk", fromExport: record.id }); + }); + + test("imports an envelope file with no source named", async () => { + const { file } = exportRun("clerk", export2); + + await run({ ...noSource, input: file }); + + expect(requests.filter((r) => r.url.endsWith("/v1/users"))).toHaveLength(2); + }); + + test("refuses a source that contradicts the envelope", async () => { + const { record } = exportRun("clerk", export2); + + await expect(run({ ...noSource, source: "auth0", input: record.id })).rejects.toThrow( + /exported from clerk, but --source names auth0/, + ); + expect(requests.filter((r) => r.url.endsWith("/v1/users"))).toHaveLength(0); + }); + + test("refuses a run ID that is not an export", async () => { + await run(baseOptions); + const [imported] = listRuns(runsDir()); + + await expect(run({ ...noSource, input: imported!.id })).rejects.toThrow( + /is an import run, which has no file to import/, + ); + }); + + test("reads Firebase's hash parameters from the envelope", async () => { + const firebase = { + base64_signer_key: "SIGNER", + base64_salt_separator: "Bw==", + rounds: 8, + mem_cost: 14, + }; + const { record } = exportRun( + "firebase", + [{ localId: "f1", email: "f@x.dev", passwordHash: "HASH", salt: "SALT" }], + { firebase }, + ); + + await run({ ...noSource, input: record.id }); + + const created = requests.find((r) => r.url.endsWith("/v1/users")); + expect(created?.body).toMatchObject({ + password_hasher: "scrypt_firebase", + password_digest: "HASH$SALT$SIGNER$Bw==$8$14", + }); + }); + + test("lets the --firebase-* flags override the envelope", async () => { + const { record } = exportRun( + "firebase", + [{ localId: "f1", email: "f@x.dev", passwordHash: "HASH", salt: "SALT" }], + { + firebase: { + base64_signer_key: "OLD", + base64_salt_separator: "Bw==", + rounds: 8, + mem_cost: 14, + }, + }, + ); + + await run({ + ...noSource, + input: record.id, + firebaseSignerKey: "NEW", + firebaseSaltSeparator: "Bw==", + firebaseRounds: 8, + firebaseMemCost: 14, + }); + + const created = requests.find((r) => r.url.endsWith("/v1/users")); + expect((created!.body as { password_digest: string }).password_digest).toContain("$NEW$"); + }); + }); + + test("gitignores the project's .clerk folder before writing a run", async () => { + await run(baseOptions); + expect(fs.readFileSync(path.join(workDir, ".gitignore"), "utf-8")).toContain(".clerk/"); + }); + + test("leaves .gitignore alone when the import is refused for lack of consent", async () => { + fs.rmSync(path.join(workDir, ".gitignore"), { force: true }); + + await run({ ...baseOptions, yes: false }).catch(() => undefined); + + expect(fs.existsSync(path.join(workDir, ".gitignore"))).toBe(false); + }); + + test("--runs-dir puts the run somewhere else", async () => { + await run({ ...baseOptions, runsDir: "elsewhere" }); + expect(listRuns(path.join(workDir, "elsewhere"))).toHaveLength(1); + expect(listRuns(runsDir())).toHaveLength(0); + }); + + test("--require-password leaves out the users without one", async () => { + await run({ ...baseOptions, requirePassword: true }); + + expect(created()).toEqual(["u1"]); + expect(captured.err).toContain("leaving out 1 user without a password"); + // On record, so the run is partial rather than "Already imported". + const [record] = listRuns(runsDir()); + expect(latestUserLines(runsDir(), record!.id).get("u2")).toMatchObject({ + status: "skipped", + reason: "no password (--require-password)", + }); + expect(record?.status).toBe("partial"); + }); + + test("aborts before any API call when the hasher is unrecognized", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { + id: "u1", + primary_email_address: "a@x.dev", + password_digest: "d", + password_hasher: "rot13", + }, + ]), + ); + + const error = (await run(baseOptions).catch((caught: unknown) => caught)) as CliError; + expect(error.message).toContain("Invalid password hasher"); + // A usage error, not "some users failed" (exit 1). + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(created()).toHaveLength(0); + }); + + test("exits 1 when some users failed", async () => { + stubClerk({ failing: new Set(["u2"]) }); + + await run(baseOptions); + + expect(process.exitCode).toBe(1); + expect(listRuns(runsDir())[0]).toMatchObject({ status: "partial", counts: { failed: 1 } }); + }); + + // Tests run non-TTY, the same signal an agent gives. + describe("without a file or a source", () => { + test("names what to pass rather than prompting for the file", async () => { + await expect(run({ source: "clerk", yes: true, secretKey: "sk_test_x" })).rejects.toThrow( + /needs the file to import, or the export run that wrote it, and cannot prompt here/, + ); + expect(requests).toHaveLength(0); + }); + + test("asks for --source when the file does not name its own", async () => { + await expect( + run({ input: "export.json", yes: true, secretKey: "sk_test_x" }), + ).rejects.toThrow(/Missing --source/); + }); + }); + + describe("continuing an earlier run", () => { + test("a complete run is not imported again", async () => { + await run(baseOptions); + requests = []; + + await run(baseOptions); + + expect(created()).toHaveLength(0); + expect(captured.err).toContain("Already imported in run"); + expect(listRuns(runsDir())).toHaveLength(1); + }); + + test("--new-run imports it again as a new run", async () => { + await run(baseOptions); + requests = []; + + await run({ ...baseOptions, newRun: true }); + + expect(created()).toEqual(["u1", "u2"]); + expect(listRuns(runsDir())).toHaveLength(2); + }); + + test("a partial run retries only the users that did not make it, in the same run", async () => { + stubClerk({ failing: new Set(["u2"]) }); + await run(baseOptions); + const [first] = listRuns(runsDir()); + + requests = []; + process.exitCode = 0; + stubClerk(); + await run(baseOptions); + + expect(created()).toEqual(["u2"]); + expect(captured.err).toContain(`Continuing run ${first!.id}, which finished partial`); + const runs = listRuns(runsDir()); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ id: first!.id, status: "complete", counts: { created: 2 } }); + }); + + test("an interrupted run skips the users it already created", async () => { + stubClerk({ failing: new Set(["u2"]) }); + await run(baseOptions); + const [first] = listRuns(runsDir()); + // A crash never writes a finish time. + const record = readRun(runsDir(), first!.id)!; + delete record.finishedAt; + fs.writeFileSync(path.join(runsDir(), first!.id, "run.json"), JSON.stringify(record)); + + requests = []; + process.exitCode = 0; + stubClerk(); + await run(baseOptions); + + expect(created()).toEqual(["u2"]); + expect(captured.err).toContain("which was interrupted"); + }); + + /** Rewrites a finished run as one a crash stopped: no finish time. */ + const interrupt = (id: string, patch: Record = {}) => { + const record = readRun(runsDir(), id)!; + delete record.finishedAt; + fs.writeFileSync( + path.join(runsDir(), id, "run.json"), + JSON.stringify({ ...record, ...patch }), + ); + }; + + // The create went out and the run stopped before the answer came back. + test("an interrupted run adopts a user Clerk created with no ID on record", async () => { + stubClerk({ failing: new Set(["u2"]) }); + await run(baseOptions); + const [first] = listRuns(runsDir()); + fs.appendFileSync( + path.join(runsDir(), first!.id, "users.ndjson"), + `${JSON.stringify({ sourceId: "u2", status: "creating" })}\n`, + ); + interrupt(first!.id); + + requests = []; + process.exitCode = 0; + stubClerk({ existing: [{ id: "user_found", external_id: "u2" }] }); + await run(baseOptions); + + expect(created()).toEqual([]); + expect(captured.err).toContain("1 user whose create was cut off is already in the instance"); + expect(latestUserLines(runsDir(), first!.id).get("u2")).toMatchObject({ + status: "created", + clerkId: "user_found", + }); + expect(readRun(runsDir(), first!.id)?.status).toBe("complete"); + }); + + test("an interrupted run creates a user whose in-flight create never landed", async () => { + stubClerk({ failing: new Set(["u2"]) }); + await run(baseOptions); + const [first] = listRuns(runsDir()); + fs.appendFileSync( + path.join(runsDir(), first!.id, "users.ndjson"), + `${JSON.stringify({ sourceId: "u2", status: "creating" })}\n`, + ); + interrupt(first!.id); + + requests = []; + process.exitCode = 0; + stubClerk(); + await run(baseOptions); + + expect(created()).toEqual(["u2"]); + }); + + test("a continued run with nothing left to do is finished", async () => { + await run(baseOptions); + const [first] = listRuns(runsDir()); + interrupt(first!.id); + requests = []; + + await run(baseOptions); + + expect(created()).toEqual([]); + expect(captured.err).toContain("No users left to import"); + expect(readRun(runsDir(), first!.id)).toMatchObject({ status: "complete" }); + expect(readRun(runsDir(), first!.id)?.finishedAt).toBeDefined(); + }); + + // "Interrupted, so undo it and start over": the undo marks the run undone + // but leaves it with no finish time. + test("an interrupted run that was then undone is imported again as a new run", async () => { + await run(baseOptions); + const [first] = listRuns(runsDir()); + interrupt(first!.id, { status: "undone" }); + requests = []; + + await run(baseOptions); + + expect(created()).toEqual(["u1", "u2"]); + expect(listRuns(runsDir()).filter((record) => record.kind === "import")).toHaveLength(2); + }); + + test("a run with an undo that did not finish refuses with exit 2", async () => { + await run(baseOptions); + const [first] = listRuns(runsDir()); + const undoRun = startRun(runsDir(), { + kind: "undo", + target: { instanceId: "ins_1" }, + undoes: first!.id, + }); + undoRun.append({ sourceId: "u1", status: "deleted", clerkId: "user_u1" }); + undoRun.append({ sourceId: "u2", status: "failed", clerkId: "user_u2" }); + undoRun.finish(); + requests = []; + + const error = (await run(baseOptions).catch((caught: unknown) => caught)) as CliError; + + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain(`clerk migrate undo ${first!.id}`); + expect(created()).toEqual([]); + }); + + test("an undone run is imported again as a new run", async () => { + await run(baseOptions); + const [first] = listRuns(runsDir()); + const record = readRun(runsDir(), first!.id)!; + fs.writeFileSync( + path.join(runsDir(), first!.id, "run.json"), + JSON.stringify({ ...record, status: "undone" }), + ); + requests = []; + + await run(baseOptions); + + expect(created()).toEqual(["u1", "u2"]); + expect(listRuns(runsDir())).toHaveLength(2); + }); + + test("a run another live process holds refuses with exit 2", async () => { + stubClerk({ failing: new Set(["u2"]) }); + await run(baseOptions); + const [first] = listRuns(runsDir()); + const record = readRun(runsDir(), first!.id)!; + delete record.finishedAt; + fs.writeFileSync(path.join(runsDir(), first!.id, "run.json"), JSON.stringify(record)); + // PID 1 is always alive, and never this test. + fs.writeFileSync(path.join(runsDir(), first!.id, "lock"), "1"); + + expect(await exitCodeOf(run(baseOptions))).toBe(EXIT_CODE.USAGE); + }); + + // An edited file is a different job. + test("a changed file is a new run", async () => { + await run(baseOptions); + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([...export2, { id: "u3", primary_email_address: "c@x.dev" }]), + ); + requests = []; + + await run(baseOptions); + + expect(listRuns(runsDir())).toHaveLength(2); + }); + }); + + describe("checks", () => { + test("a reject stops the import, with the command that imports the rest", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([...export2, { id: "u3" }]), + ); + + const error = (await run(baseOptions).catch((caught: unknown) => caught)) as CliError; + + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain("1 user would be rejected, so nothing was imported"); + expect(error.examples?.[0]?.command).toContain("--allow-partial --yes"); + expect(created()).toHaveLength(0); + expect(listRuns(runsDir())).toHaveLength(0); + }); + + test("--allow-partial imports the rest and records each reject as skipped", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([...export2, { id: "u3" }]), + ); + + await run({ ...baseOptions, allowPartial: true }); + + expect(created()).toEqual(["u1", "u2"]); + const [record] = listRuns(runsDir()); + expect(record).toMatchObject({ status: "partial", counts: { created: 2, skipped: 1 } }); + expect(latestUserLines(runsDir(), record!.id).get("u3")).toMatchObject({ + status: "skipped", + reason: expect.stringContaining("invalid:"), + }); + // The operator accepted the skips; only a failed user exits 1. + expect(process.exitCode).toBe(0); + }); + + test("--dry-run writes nothing, and exits 2 when the import would be refused", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([...export2, { id: "u3" }]), + ); + + await run({ ...baseOptions, dryRun: true }); + + expect(created()).toHaveLength(0); + expect(listRuns(runsDir())).toHaveLength(0); + expect(captured.err).toContain("Dry run: nothing was written."); + expect(process.exitCode).toBe(2); + }); + + test("--dry-run exits 0 when nothing would be rejected", async () => { + await run({ ...baseOptions, dryRun: true }); + + expect(created()).toHaveLength(0); + expect(process.exitCode).toBe(0); + }); + + test("a user whose only email is unverified is rejected where email is required, with a fix", async () => { + stubClerk({ + settings: { attributes: { email_address: { enabled: true, required: true } } }, + }); + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { id: "u1", primary_email_address: "a@x.dev" }, + { id: "u2", unverified_email_addresses: "b@x.dev" }, + ]), + ); + + await run({ ...baseOptions, dryRun: true }); + + expect(captured.err).toContain( + "only has an unverified email, and this instance requires an email", + ); + expect(captured.err).toContain( + `clerk config patch --instance ins_1 --json '{"auth_email":{"required_for_sign_up":false}}'`, + ); + }); + + test("legal consent: refused without --skip-legal-checks, sent with skip_legal_checks with it", async () => { + stubClerk({ + settings: { + attributes: { email_address: { enabled: true } }, + sign_up: { legal_consent_enabled: true }, + }, + }); + + const error = (await run(baseOptions).catch((caught: unknown) => caught)) as CliError; + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(captured.err).toContain("no legal acceptance on record"); + expect(created()).toEqual([]); + + await run({ ...baseOptions, skipLegalChecks: true }); + const bodies = requests + .filter((r) => r.method === "POST" && r.url.endsWith("/v1/users")) + .map((r) => r.body); + expect(bodies).toEqual([ + expect.objectContaining({ external_id: "u1", skip_legal_checks: true }), + expect.objectContaining({ external_id: "u2", skip_legal_checks: true }), + ]); + }); + + test("a user already in the instance is rejected", async () => { + stubClerk({ + existing: [{ id: "user_old", email_addresses: [{ email_address: "b@x.dev" }] }], + }); + + await run({ ...baseOptions, dryRun: true }); + + expect(captured.err).toContain("email is already used by a user in the instance"); + expect(Bun.stripANSI(captured.err)).toContain("u2"); + }); + + test("the dev quota rejects users past the headroom; --allow-partial imports up to it", async () => { + stubClerk({ count: 99 }); + + expect(await exitCodeOf(run(baseOptions))).toBe(EXIT_CODE.USAGE); + expect(created()).toHaveLength(0); + + await run({ ...baseOptions, allowPartial: true }); + expect(created()).toEqual(["u1"]); + const [record] = listRuns(runsDir()); + expect(latestUserLines(runsDir(), record!.id).get("u2")?.reason).toContain("100-user limit"); + }); + + test("supabase users whose only provider is disabled are rejected", async () => { + stubClerk({ + settings: { + attributes: { email_address: { enabled: true } }, + social: { oauth_google: { enabled: true } }, + }, + }); + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { + id: "sb1", + email: "a@x.dev", + email_confirmed_at: "2024-01-01 00:00:00+00", + raw_app_meta_data: '{"providers":["discord"]}', + }, + { + id: "sb2", + email: "b@x.dev", + email_confirmed_at: "2024-01-01 00:00:00+00", + raw_app_meta_data: '{"providers":["email","discord"]}', + }, + ]), + ); + + await run({ ...baseOptions, source: "supabase", dryRun: true }); + + expect(captured.err).toContain("only signs in with Discord, which is not enabled in Clerk"); + expect(captured.err).toContain( + "1 user signed in with Discord, which is not enabled in Clerk", + ); + }); + + test("names the fields Clerk won't store", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify(export2.map((user) => ({ ...user, department: "Sales" }))), + ); + + await run({ ...baseOptions, dryRun: true }); + + expect(captured.err).toContain("Clerk won't store: department (2 users)"); + }); + }); + + describe("consent", () => { + test("without --yes where nobody can be asked: the preview, then exit 2 with the command", async () => { + const error = (await run({ ...baseOptions, yes: false }).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain("needs consent. Pass --yes to confirm"); + expect(error.examples?.[0]?.command).toBe( + "clerk migrate import export.json --source clerk --secret-key --yes", + ); + expect(captured.err).toContain("Checks"); + expect(created()).toHaveLength(0); + }); + + test("--json --yes returns { target, run, checks, result }", async () => { + await run({ ...baseOptions, json: true }); + + expect(JSON.parse(captured.out)).toMatchObject({ + target: { instanceId: "ins_1" }, + run: { kind: "import", status: "complete" }, + checks: { total: 2, importable: 2, rejects: [] }, + result: { created: 2, failed: 0, skipped: 0 }, + }); + }); + + test("--json without --yes returns the preview with consent required, and exits 2", async () => { + expect(await exitCodeOf(run({ ...baseOptions, yes: false, json: true }))).toBe( + EXIT_CODE.USAGE, + ); + expect(JSON.parse(captured.out)).toMatchObject({ + consent: "required", + checks: { importable: 2 }, + }); + expect(created()).toHaveLength(0); + }); + }); + + describe("--source ", () => { + const CUSTOM = `export default { + key: "myplatform", + label: "My Platform", + description: "Exports from My Platform.", + transformer: { account_ref: "userId", contact_email: "email", given: "firstName", pw: "password" }, + carries: { + passwords: { level: "yes", note: "bcrypt." }, + mfa: { level: "no", note: "None." }, + metadata: { level: "no", note: "None." }, + }, + defaults: { passwordHasher: "bcrypt" }, + postTransform: (user) => { if (!user.firstName) delete user.firstName; }, + };`; + + let customFile: string; + let customCounter = 0; + + beforeEach(() => { + // A fresh filename each time: dynamic import() caches by URL, so reusing + // one would silently return a previous test's module. + customFile = `./custom-run-${customCounter++}.ts`; + fs.writeFileSync(path.join(workDir, customFile), CUSTOM); + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { account_ref: "mp_1", contact_email: "a@x.dev", given: "Ada", pw: BCRYPT }, + { account_ref: "mp_2", contact_email: "b@x.dev", given: "", pw: BCRYPT }, + ]), + ); + }); + + afterEach(() => { + __resetCustomSourcesForTesting(); + }); + + const created = () => requests.filter((r) => r.url.endsWith("/v1/users")); + + // The registered key isn't something --source accepts; the path is. + test("the printed command names the source's path, not its key", async () => { + const error = (await run({ + input: "export.json", + source: customFile, + secretKey: "sk_test_x", + json: true, + }).catch((caught: unknown) => caught)) as CliError; + + expect(error.examples?.[0]?.command).toBe( + `clerk migrate import export.json --source ${customFile} --secret-key --json --yes`, + ); + }); + + test("imports through a user-authored source", async () => { + await run({ + input: "export.json", + source: customFile, + yes: true, + secretKey: "sk_test_x", + }); + + expect(created().map((r) => (r.body as { external_id: string }).external_id)).toEqual([ + "mp_1", + "mp_2", + ]); + expect(captured.err).toContain("myplatform"); + expect(captured.err).toContain("source from"); + }); + + test("applies the custom source's defaults and postTransform", async () => { + await run({ + input: "export.json", + source: customFile, + yes: true, + secretKey: "sk_test_x", + }); + + const bodies = created().map((r) => r.body as Record); + expect(bodies[0]).toMatchObject({ first_name: "Ada", password_hasher: "bcrypt" }); + // postTransform dropped the empty given name rather than sending "". + expect("first_name" in (bodies[1] ?? {})).toBe(false); + }); + + // An edited source is a different source, so the run records which one. + test("records the custom source's content hash on the run", async () => { + await run({ input: "export.json", source: customFile, yes: true, secretKey: "sk_test_x" }); + + const [record] = listRuns(runsDir()); + expect(record?.source).toBe("myplatform"); + expect(record?.sourceHash).toMatch(/^[0-9a-f]{64}$/); + }); + + test("an unknown built-in key is a usage error listing the valid ones", async () => { + await expect( + run({ input: "export.json", source: "okta", yes: true, secretKey: "sk_test_x" }), + ).rejects.toThrow(/Unknown source "okta". Valid sources: clerk, auth0/); + expect(created()).toHaveLength(0); + }); + + test("fails before any request when the file is not there", async () => { + await expect( + run({ + input: "export.json", + source: "./nope.ts", + yes: true, + secretKey: "sk_test_x", + }), + ).rejects.toThrow(/No source file at/); + expect(requests).toHaveLength(0); + }); + + test("fails before any request when the file is malformed", async () => { + const bad = `./bad-${customCounter++}.ts`; + fs.writeFileSync( + path.join(workDir, bad), + `export default { key: "x", label: "X", transformer: {} };`, + ); + + const error = (await run({ + input: "export.json", + source: bad, + yes: true, + secretKey: "sk_test_x", + }).catch((caught: unknown) => caught)) as CliError; + expect(error.message).toMatch(/no source field maps to `userId`/); + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(requests).toHaveLength(0); + }); + + test("still requires a file", async () => { + await expect(run({ source: customFile, yes: true, secretKey: "sk_test_x" })).rejects.toThrow( + /needs the file to import/, + ); + }); + }); + + describe("per-platform imports", () => { + /** One realistic record per platform, in that platform's export shape. */ + const PLATFORMS: [string, unknown, string][] = [ + [ + "auth0", + [ + { + user_id: "auth0|1", + email: "a@x.dev", + email_verified: true, + given_name: "Ada", + family_name: "L", + }, + ], + "auth0|1", + ], + ["authjs", [{ id: "aj1", email: "a@x.dev", email_verified: "2024-01-01T00:00:00Z" }], "aj1"], + [ + "betterauth", + [{ user_id: "ba1", email: "a@x.dev", email_verified: true, password_hash: BCRYPT }], + "ba1", + ], + [ + "supabase", + [ + { + id: "sb1", + email: "a@x.dev", + email_confirmed_at: "2024-06-29 20:25:06+00", + encrypted_password: BCRYPT, + }, + ], + "sb1", + ], + ]; + + test.each(PLATFORMS)( + "%s transforms, validates and imports its export", + async (key, records, externalId) => { + fs.writeFileSync(path.join(workDir, "export.json"), JSON.stringify(records)); + + await run({ ...baseOptions, source: key }); + + const created = requests.filter((r) => r.url.endsWith("/v1/users")); + expect(created).toHaveLength(1); + expect((created[0]?.body as { external_id: string } | undefined)?.external_id).toBe( + externalId, + ); + }, + ); + + test("firebase imports its wrapped export and builds the scrypt digest", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify({ + users: [ + { + localId: "fb1", + email: "a@x.dev", + emailVerified: true, + passwordHash: "SGFzaA==", + salt: "U2FsdA==", + }, + ], + }), + ); + + await run({ + ...baseOptions, + source: "firebase", + firebaseSignerKey: "SIGNER", + firebaseSaltSeparator: "Bw==", + firebaseRounds: 8, + firebaseMemCost: 14, + }); + + const body = requests.find((r) => r.url.endsWith("/v1/users"))?.body as Record< + string, + unknown + >; + expect(body).toMatchObject({ + external_id: "fb1", + password_digest: "SGFzaA==$U2FsdA==$SIGNER$Bw==$8$14", + password_hasher: "scrypt_firebase", + }); + }); + + test("the printed command keeps the --firebase-* flags, as placeholders", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify({ users: [{ localId: "fb1", email: "a@x.dev", emailVerified: true }] }), + ); + + const error = (await run({ + ...baseOptions, + yes: false, + source: "firebase", + firebaseSignerKey: "SIGNER", + firebaseSaltSeparator: "Bw==", + firebaseRounds: 8, + firebaseMemCost: 14, + }).catch((caught: unknown) => caught)) as CliError; + + expect(error.examples?.[0]?.command).toContain( + "--firebase-signer-key --firebase-salt-separator --firebase-rounds --firebase-mem-cost ", + ); + }); + + test("a partial firebase flag set fails before anything is read", async () => { + await expect( + run({ ...baseOptions, source: "firebase", firebaseSignerKey: "SIGNER" }), + ).rejects.toThrow(/--firebase-salt-separator/); + expect(requests).toHaveLength(0); + }); + + test("an unknown source fails listing the valid keys", async () => { + await expect(run({ ...baseOptions, source: "okta" })).rejects.toThrow( + /Unknown source "okta".*clerk.*supabase/s, + ); + }); + }); +}); + +describe("explainErrors", () => { + const COUNTRY = + "Phone numbers from this country (France) are currently not supported. For more information, please contact support."; + const QUOTA = + "You have reached your limit of 100 users. If you need more users, please use a Production instance."; + + test("names the development instance as the reason countries are blocked", () => { + const [note] = explainErrors([COUNTRY], "dev"); + expect(note).toContain("Development instances block SMS to most countries"); + expect(note).toContain("test-emails-and-phones"); + }); + + test("sends a production operator to the Dashboard instead of support", () => { + const [note] = explainErrors([COUNTRY], "prod"); + expect(note).toContain("customization/sms/settings"); + expect(note).not.toContain("Development instances"); + }); + + test("explains the user quota only where one applies", () => { + expect(explainErrors([QUOTA], "dev").join(" ")).toContain("development-instance quota"); + // Production has no such quota, and the API's message already names the + // plan upgrade in the one case it does. + expect(explainErrors([QUOTA], "prod")).toEqual([]); + }); + + test("says nothing about errors it does not recognize", () => { + expect(explainErrors(["Something else went wrong."], "dev")).toEqual([]); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts new file mode 100644 index 000000000..2d7a32ec0 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -0,0 +1,917 @@ +/** + * `clerk migrate import ` — the user import itself. + * + * Registered as the `import` subcommand. The exported handler keeps the name + * `run` because `import` is a reserved word. + * + * 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 + * target instance, and print the target. + * 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. + * Nothing is written without `--yes` or a yes at the prompt. + * 5. Import, one run line per user. + */ + +import { bold, dim, green, red, yellow } from "../../lib/color.ts"; +import { resolveProfile } from "../../lib/config.ts"; +import { hasAccountCredentials } from "../../lib/credential-store.ts"; +import { + AUTH_ERROR_REASON, + AuthError, + CliError, + ERROR_CODE, + throwUsageError, + throwUserAbort, +} from "../../lib/errors.ts"; +import { resolveKeylessTarget } from "../../lib/keyless-target.ts"; +import { quoteArg } from "../../lib/json-body.ts"; +import { log } from "../../lib/log.ts"; +import { NEXT_STEPS, printAgentNextSteps } from "../../lib/next-steps.ts"; +import { confirm } from "../../lib/prompts.ts"; +import { withGutter, withSpinner } from "../../lib/spinner.ts"; +import { isAgent, isHuman } from "../../mode.ts"; +import { importUsers } from "./import-users.ts"; +import { checkImport, type ImportChecks } from "./lib/checks.ts"; +import { fetchInstanceSettings, fetchUserCount } from "./lib/clerk-config.ts"; +import { readEnvelope, type ExportEnvelope } from "./lib/export-file.ts"; +import { resolveFirebaseHashConfig, type FirebaseHashFlags } from "./lib/firebase-hash.ts"; +import { DEV_USER_LIMIT, resolveLimits, type InstanceType } from "./lib/instance.ts"; +import { + continueRun, + latestUserLines, + listRuns, + liveLockPid, + lockFile, + readRun, + resolveRunsDir, + RUN_ID_PATTERN, + runDir, + runState, + sha256File, + startRun, + type Run, + type RunRecord, + type UserLine, +} from "./lib/run-store.ts"; +import { withProgress } from "./lib/progress.ts"; +import { createApiScheduler } from "./lib/scheduler.ts"; +import { readSupabaseRows } from "./lib/supabase-providers.ts"; +import { keyInstanceId, printTarget, resolveClerkTarget } from "./lib/target.ts"; +import { findInFlight } from "./lib/user-lookup.ts"; +import { + fileExists, + getFileType, + loadUsersFromFile, + resolveImportFilePath, +} from "./lib/transform.ts"; +import { resolveSource, sourceKeys } from "./sources/registry.ts"; +import type { ImportSummary, User } from "./types.ts"; +import { promptForFile, promptForFirebaseHashConfig, promptForSource } from "./wizard.ts"; +import { login } from "../auth/login.ts"; +import { link } from "../link/index.ts"; + +export type MigrateRunOptions = { + /** An export file, or the ID of the export run that wrote one. */ + input?: string; + /** A built-in source key, or the path to a source you wrote. */ + source?: string; + /** Content hash of a custom `--source`, set once it is loaded. */ + sourceHash?: string; + /** `--source` as typed, for printed commands: a custom source's path. Not a flag. */ + sourceArg?: string; + /** The file to read, once `input` is resolved. Not a flag. */ + file?: string; + requirePassword?: boolean; + /** + * Import users with no legal acceptance into an instance that requires it. + * Without it, a prompt asks; where nobody can be asked, they are rejected. + */ + skipLegalChecks?: boolean; + /** Check against the instance, report, and write nothing. */ + dryRun?: boolean; + /** Import the users that pass, and record the rest as skipped. */ + allowPartial?: boolean; + /** Start a fresh run even when an earlier one matches. */ + newRun?: boolean; + yes?: boolean; + json?: boolean; + secretKey?: string; + app?: string; + instance?: string; + /** Where runs are kept; overrides `CLERK_MIGRATE_DIR`. */ + runsDir?: string; +} & FirebaseHashFlags; + +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; + +/** Nobody can answer a prompt: an agent, a non-TTY run, or `--json`. */ +function canPrompt(options: MigrateRunOptions): boolean { + return !options.json && isHuman() && !isAgent(); +} + +/** + * Validates what a run needs before anything is read or sent. + * + * `--source` has already been resolved to a registered key by the time this + * runs, custom sources included. + * + * @returns The source key and file path, both guaranteed present. + */ +export function validateRunOptions(options: MigrateRunOptions): { + source: string; + file: string; +} { + if (!options.source) { + throwUsageError( + `Missing --source. Valid values: ${sourceKeys().join(", ")}, or the path to a source you wrote.`, + undefined, + ERROR_CODE.USAGE_ERROR, + [ + { + command: "clerk migrate import users.json --source clerk --yes", + description: "Import a Clerk export", + }, + ], + ); + } + if (!options.file) { + throwUsageError( + "Missing the file to import (a JSON or CSV export, or an export run ID).", + undefined, + ERROR_CODE.USAGE_ERROR, + [ + { + command: "clerk migrate import users.json --source clerk --yes", + description: "Import a Clerk export", + }, + ], + ); + } + if (!fileExists(options.file)) { + throw new CliError(`File not found: ${options.file}`, { code: ERROR_CODE.FILE_NOT_FOUND }); + } + if (!getFileType(options.file)) { + throwUsageError(`Unsupported file type for ${options.file}. Provide a .json or .csv file.`); + } + + return { source: options.source, file: options.file }; +} + +/** Where a production instance's operator changes the SMS country blocklist. */ +const SMS_SETTINGS_URL = "https://dashboard.clerk.com/~/customization/sms/settings"; + +/** Clerk's fictional email addresses and phone numbers, for development. */ +const TEST_NUMBERS_URL = "https://clerk.com/docs/guides/development/testing/test-emails-and-phones"; + +/** + * What the API's error messages leave out: whether the operator can do + * something about them, and where. + * + * Both of these read as account-level restrictions and are not. Blocked + * countries are a per-instance SMS blocklist that development instances are + * created with far more of, and the user limit is a development-instance quota + * that production does not have at all — so "contact support", which both + * messages point at, is the wrong first move for most readers. + * + * @returns One note per recognized error family, empty when none apply. + */ +export function explainErrors(errors: Iterable, instanceType: InstanceType): string[] { + const all = [...errors]; + const notes: string[] = []; + + if (all.some((error) => error.includes("Phone numbers from this country"))) { + notes.push( + instanceType === "dev" + ? `Development instances block SMS to most countries by default — this is not a limit on your account. ` + + `Use Clerk's test phone numbers while developing (${TEST_NUMBERS_URL}), and contact support only if ` + + `you need real numbers in a specific country before going to production.` + : `Unblock the countries you need under SMS settings in the Dashboard (${SMS_SETTINGS_URL}). ` + + `Plans without SMS support cannot remove them; contact support if the setting is refused.`, + ); + } + + // Production has no user limit unless a plan imposes one, and the API's own + // message already names the fix ("upgrade to a paid plan") in that case. + if ( + instanceType === "dev" && + all.some((error) => /You have reached your limit of \d+ users/.test(error)) + ) { + notes.push( + `The user limit is a development-instance quota (${DEV_USER_LIMIT} by default). Import into a production ` + + `instance to bring everyone across, or contact support to raise this instance's limit.`, + ); + } + + return notes; +} + +// --- Settling the file, source and target ---------------------------------- + +/** + * Makes sure there is somewhere to import *into* before anything else happens. + * + * Without this the first complaint comes from deep inside the secret-key chain, + * which resolves the linked profile before it ever asks for a token — so a + * signed-out operator in an unlinked directory is told to `clerk link`, a + * command that will only turn around and ask them to sign in. + * + * A human gets the same sign-in-then-link flow `clerk link` already runs. An + * agent cannot answer a browser login or an application picker, so it gets the + * error naming whichever half is missing. + */ +async function ensureImportTarget(options: MigrateRunOptions): Promise { + // Each of these names the destination instance on its own, with no account + // and no linked directory involved — mirroring resolveBapiSecretKey. + if (options.secretKey || options.app || process.env.CLERK_SECRET_KEY) return; + // An unclaimed accountless application keeps its only secret key on disk. + if (await resolveKeylessTarget({ instance: options.instance })) return; + + const interactive = canPrompt(options); + + if (!(await hasAccountCredentials())) { + if (!interactive) { + throw new AuthError({ + reason: AUTH_ERROR_REASON.NOT_LOGGED_IN, + message: + "Not logged in, so there is no Clerk instance to import into. Run `clerk auth login`, then `clerk link`.", + examples: [ + { command: "clerk auth login", description: "Sign in, then re-run the import" }, + { + command: + "clerk migrate import users.json --source clerk --yes --secret-key sk_test_...", + description: "Import without signing in", + }, + ], + }); + } + log.info("Not logged in. Signing in first..."); + await login({ showNextSteps: false }); + } + + // Left to the secret-key chain when non-interactive: its `not_linked` error + // is the one every other command raises, and there is nothing to add to it. + if (interactive && !(await resolveProfile(process.cwd()))) { + log.info("This directory isn't linked to a Clerk application. Linking one first..."); + await link({ skipIfLinked: true }); + } +} + +/** + * Settles which file to read. The argument may name the export run that wrote + * the file; a human who gave none is asked for a path. + */ +async function resolveInput( + options: MigrateRunOptions, +): Promise<{ file: string; fromExport?: string }> { + const value = options.input; + if (!value) { + if (!canPrompt(options)) { + throwUsageError( + "`clerk migrate import` needs the file to import, or the export run that wrote it, and cannot prompt here.", + undefined, + undefined, + [ + { + command: "clerk migrate import 20260929-141502-a1b2 --yes", + description: "Import what an export run wrote", + }, + { + command: "clerk migrate import users.json --source clerk --yes", + description: "Import a file", + }, + ], + ); + } + return { file: await promptForFile() }; + } + if (!RUN_ID_PATTERN.test(value) || fileExists(value)) return { file: value }; + + const runsDir = await resolveRunsDir(options.runsDir); + const record = readRun(runsDir, value); + if (!record) { + throwUsageError(`No run \`${value}\` in ${runsDir}. Run \`clerk migrate runs\` to list them.`); + } + if (record.kind !== "export" || !record.file) { + throwUsageError( + `Run ${value} is an ${record.kind} run, which has no file to import. Name an export run, or a file.`, + ); + } + return { file: record.file.path, fromExport: record.id }; +} + +/** + * Resolves `--source` to a registered key, loading a custom source from its + * path so the rest of the run treats it exactly like a built-in. + * + * @throws UsageError for an unknown key. + */ +async function applySource(options: MigrateRunOptions): Promise { + if (!options.source) return options; + const resolved = await resolveSource(options.source); + if (resolved.path && !options.json) { + log.info(`Loaded the \`${resolved.key}\` source from ${options.source}.`); + } + return { + ...options, + source: resolved.key, + sourceArg: options.source, + ...(resolved.hash ? { sourceHash: resolved.hash } : {}), + }; +} + +/** + * The source an export file names for itself, checked against the one the + * flags name. + * + * @throws UsageError when the two disagree: importing an Auth0 export through + * the Supabase mapping would create users with the wrong fields. + */ +function applyEnvelope( + options: MigrateRunOptions, + envelope: ExportEnvelope | undefined, +): MigrateRunOptions { + if (!envelope) return options; + if (options.source && options.source !== envelope.source) { + throwUsageError( + `The file was exported from ${envelope.source}, but --source names ${options.source}. ` + + "Drop --source: the file already says where it came from.", + ); + } + return { ...options, source: envelope.source }; +} + +// --- Continuing an earlier run --------------------------------------------- + +/** How this import relates to earlier runs of the same file, source and instance. */ +export type ResumeCase = + | { kind: "new" } + | { kind: "continue"; record: RunRecord; because: "interrupted" | "partial" } + | { kind: "complete"; record: RunRecord }; + +/** + * 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: + * + * - none, or undone: a new run + * - interrupted: continue it, skipping the users it created + * - partial: continue it, retrying the users that failed or were skipped + * - complete: nothing; the file is already in + * + * @throws UsageError when that run is still running in another process, or + * has an undo that never finished: some of its users are gone and some are + * not, so neither continuing it nor starting over is safe. + */ +export function findResume( + runsDir: string, + match: { + sha256: string; + source: string; + sourceHash?: string; + instanceId: string; + /** The key's stand-in ID, for a run recorded while Clerk could not name the instance. */ + keyInstanceId?: string; + }, +): ResumeCase { + const latest = listRuns(runsDir).find( + (record) => + record.kind === "import" && + record.file?.sha256 === match.sha256 && + record.source === match.source && + record.sourceHash === match.sourceHash && + (record.target.instanceId === match.instanceId || + record.target.instanceId === match.keyInstanceId), + ); + if (!latest) return { kind: "new" }; + + const state = runState(runsDir, latest); + if (state === "running") { + throwUsageError( + `Run ${latest.id} is importing this file right now in another process (PID ${liveLockPid(runsDir, latest.id)}). ` + + `Wait for it to finish. If that process is not a migrate run, delete ${lockFile(runsDir, latest.id)}.`, + ); + } + if (state === "undone") return { kind: "new" }; + + const undoRun = listRuns(runsDir).find( + (record) => record.kind === "undo" && record.undoes === latest.id, + ); + if (undoRun && runState(runsDir, undoRun) !== "complete") { + throwUsageError( + `Run ${latest.id} has an undo that did not finish (run ${undoRun.id}). ` + + `Finish it with \`clerk migrate undo ${latest.id}\`, or pass --new-run to import into a new run.`, + ); + } + // Undone in full, though the import run was never marked so. + if (undoRun) return { kind: "new" }; + + if (state === "complete") return { kind: "complete", record: latest }; + return { + kind: "continue", + record: latest, + because: state === "interrupted" ? "interrupted" : "partial", + }; +} + +// --- Reporting ------------------------------------------------------------- + +/** How many rejected source IDs to name per reason before summing the rest. */ +const REJECT_SAMPLE = 5; + +function printChecks(checks: ImportChecks): void { + log.blank(); + log.info(bold("Checks")); + log.info(` ${plural(checks.total, "user")} checked`); + + if (checks.rejects.length > 0) { + log.info(` ${red("✗")} ${red(`${plural(checks.rejects.length, "user")} rejected`)}`); + for (const { reason, count } of checks.rejectReasons) { + const ids = checks.rejects + .filter((reject) => reject.reason === reason) + .map((reject) => + reject.keptSourceId + ? `${reject.sourceId} (kept: ${reject.keptSourceId})` + : reject.sourceId, + ); + const sample = ids.slice(0, REJECT_SAMPLE).join(", "); + const more = ids.length > REJECT_SAMPLE ? `, and ${ids.length - REJECT_SAMPLE} more` : ""; + log.info(` ${count}: ${reason}`); + log.info(` ${dim(sample + more)}`); + } + } + + if (checks.warnings.length > 0) { + log.info(` ${yellow("⚠")} ${yellow("Imported, but not everything comes across")}`); + for (const warning of checks.warnings) log.info(` ${dim(warning)}`); + } + + log.info(` ${green("✓")} ${green(`${plural(checks.importable.length, "user")} to import`)}`); + + if (checks.settingsUnavailable) { + log.info( + ` ${yellow("!")} ${dim("Could not read this instance's settings, so required fields were not checked.")}`, + ); + } + + if (checks.fixes.length > 0) { + log.blank(); + log.info(bold("Or change the instance instead")); + for (const fix of checks.fixes) { + log.info(` ${fix.label}`); + log.info(dim(` ${fix.command ?? fix.url}`)); + } + } +} + +function checksJson(checks: ImportChecks) { + return { + total: checks.total, + importable: checks.importable.length, + rejects: checks.rejects, + rejectReasons: checks.rejectReasons, + warnings: checks.warnings, + fixes: checks.fixes, + ...(checks.quota ? { quota: checks.quota } : {}), + settingsUnavailable: checks.settingsUnavailable, + }; +} + +function formatSummary( + summary: ImportSummary, + skipped: number, + record: RunRecord, + runFolder: string, + instanceType: InstanceType, +): string[] { + const lines = [ + `${green("Imported:")} ${summary.successful}`, + `${red("Failed:")} ${summary.failed}`, + ]; + if (skipped > 0) lines.push(`${yellow("Skipped:")} ${skipped}`); + + if (summary.errorBreakdown.size > 0) { + lines.push("", bold("Error breakdown:")); + for (const [error, count] of summary.errorBreakdown) { + lines.push(` ${plural(count, "user")}: ${error}`); + } + for (const note of explainErrors(summary.errorBreakdown.keys(), instanceType)) { + lines.push("", note); + } + } + lines.push("", dim(`Run ${record.id}: ${runFolder}`)); + return lines; +} + +/** + * Once every user is in, the files that hold them are only a liability: the + * export carries user data and password hashes, and the run is only needed + * for `undo`. + */ +function cleanupLines(runsDir: string, record: RunRecord): string[] { + const lines: string[] = []; + if (record.fromExport) { + lines.push( + `The export in run ${record.fromExport} holds your users' data. Once you have checked the import, delete it:`, + dim(` rm -rf ${quoteArg(runDir(runsDir, record.fromExport))}`), + ); + } + lines.push( + `Keep run ${record.id} while you might still undo it. After that:`, + dim(` rm -rf ${quoteArg(runDir(runsDir, record.id))}`), + ); + return lines; +} + +// --- The import ------------------------------------------------------------ + +/** + * The exact command that would carry on from here, for the consent and + * refusal messages. Every value is shell-quoted; secrets are placeholders. + */ +function commandFor(options: MigrateRunOptions, fromExport: string | undefined, extra: string[]) { + const input = fromExport ?? options.input ?? options.file ?? ""; + const parts = ["clerk migrate import", quoteArg(input)]; + const source = options.sourceArg ?? options.source; + if (!fromExport && source) parts.push("--source", quoteArg(source)); + if (options.allowPartial) parts.push("--allow-partial"); + if (options.newRun) parts.push("--new-run"); + if (options.requirePassword) parts.push("--require-password"); + if (options.skipLegalChecks) parts.push("--skip-legal-checks"); + if (options.firebaseSignerKey) parts.push("--firebase-signer-key", ""); + if (options.firebaseSaltSeparator) parts.push("--firebase-salt-separator", ""); + if (options.firebaseRounds) parts.push("--firebase-rounds", ""); + if (options.firebaseMemCost) parts.push("--firebase-mem-cost", ""); + if (options.secretKey) parts.push("--secret-key", ""); + if (options.app) parts.push("--app", quoteArg(options.app)); + if (options.instance) parts.push("--instance", quoteArg(options.instance)); + if (options.runsDir) parts.push("--runs-dir", quoteArg(options.runsDir)); + if (options.json) parts.push("--json"); + return [...parts, ...extra].join(" "); +} + +/** + * Records the checks' rejects as skipped users, so the run says who they were. + * + * An adopted user keeps its `creating` line: it exists in Clerk, and `undo` + * finds it only through that line. + */ +function recordRejects(run: Run, checks: ImportChecks, adopted: Map): void { + for (const { sourceId, reason, keptSourceId } of checks.rejects) { + if (adopted.has(sourceId)) continue; + run.append({ + sourceId, + status: "skipped", + reason: keptSourceId ? `${reason} (kept: ${keptSourceId})` : reason, + }); + } +} + +export async function run(rawOptions: MigrateRunOptions): Promise { + await ensureImportTarget(rawOptions); + let options = await applySource(rawOptions); + + const input = await resolveInput(options); + options = { ...options, file: input.file }; + const envelope = fileExists(input.file) + ? readEnvelope(resolveImportFilePath(input.file)) + : undefined; + options = applyEnvelope(options, envelope); + + // Asked only when the file does not say where it came from. + if (!options.source && canPrompt(options) && fileExists(input.file)) { + options = { ...options, source: await promptForSource() }; + } + + const { source, file } = validateRunOptions(options); + // The flags win, so a rotated key can be passed without re-exporting. + let firebaseHashConfig = + resolveFirebaseHashConfig(options, source) ?? + (source === "firebase" ? envelope?.firebase : undefined); + if (source === "firebase" && !firebaseHashConfig && canPrompt(options)) { + firebaseHashConfig = await promptForFirebaseHashConfig(); + } + + await withGutter( + "Migrating users to Clerk", + async ({ setNextSteps }) => { + const { secretKey, target } = await resolveClerkTarget(options); + if (!options.json) printTarget(target); + const limits = resolveLimits(secretKey); + + const filePath = resolveImportFilePath(file); + const sha256 = sha256File(filePath); + const runsDir = await resolveRunsDir(options.runsDir); + + const resume: ResumeCase = options.newRun + ? { kind: "new" } + : findResume(runsDir, { + sha256, + source, + ...(options.sourceHash ? { sourceHash: options.sourceHash } : {}), + instanceId: target.instanceId, + keyInstanceId: keyInstanceId(secretKey), + }); + + if (resume.kind === "complete") { + if (options.json) { + log.data(JSON.stringify({ target, run: resume.record, alreadyImported: true }, null, 2)); + } else { + log.success( + `Already imported in run ${resume.record.id}. Pass --new-run to import it again.`, + ); + } + return; + } + + // Users the run being continued already created are done: they are not + // checked or sent again. Those whose extra identifiers never attached + // get just the attaches. + const continued = resume.kind === "continue" ? resume.record : undefined; + const done = new Map(); + const attachOnly: UserLine[] = []; + const inFlight: string[] = []; + if (continued) { + for (const line of latestUserLines(runsDir, continued.id).values()) { + if (line.status === "creating") inFlight.push(line.sourceId); + if (line.status !== "created" || !line.clerkId) continue; + done.set(line.sourceId, line.clerkId); + if (line.pending?.length) attachOnly.push(line); + } + } + + // Creates the run stopped with no answer to. Those Clerk holds are + // adopted: checked as usual, but never created again. + const schedule = createApiScheduler(limits.concurrencyLimit, limits.rateLimit); + const adopted = new Map(); + if (continued) { + const found = await findInFlight({ + runsDir, + runId: continued.id, + sourceIds: inFlight, + secretKey, + schedule, + }); + for (const user of found) adopted.set(user.sourceId, user.clerkId); + } + + if (!options.json) { + log.info( + resume.kind === "continue" + ? `Continuing run ${resume.record.id}, which ${resume.because === "interrupted" ? "was interrupted" : "finished partial"}: ` + + `${plural(done.size, "user")} already imported ${done.size === 1 ? "is" : "are"} left alone.` + : "Starting a new run.", + ); + if (adopted.size > 0) { + log.info( + `${plural(adopted.size, "user")} whose create was cut off ${adopted.size === 1 ? "is" : "are"} already in the instance, and won't be created again.`, + ); + } + } + + const loaded = await withSpinner(`Loading users from ${file}...`, async () => + loadUsersFromFile(file, source, { context: { firebaseHashConfig } }), + ); + let users = loaded.users.filter((user) => !done.has(user.userId)); + const failures = loaded.failures.filter((failure) => !done.has(failure.userId)); + + // An instruction about this import, not a prediction: users without a + // password are left out of the job rather than recorded as skipped. + let withoutPassword: User[] = []; + if (options.requirePassword) { + withoutPassword = users.filter((user) => !user.password && !adopted.has(user.userId)); + if (withoutPassword.length > 0 && !options.json) { + log.info( + `--require-password: leaving out ${plural(withoutPassword.length, "user")} without a password.`, + ); + } + users = users.filter((user) => !withoutPassword.includes(user)); + } + + let supabaseRows: Record[] | undefined; + if (source === "supabase") { + try { + supabaseRows = await readSupabaseRows(file); + } catch (error) { + log.debug(`migrate: could not read Supabase providers: ${String(error)}`); + } + } + + const [settings, existingUsers] = await withSpinner("Checking the instance...", async () => + Promise.all([ + fetchInstanceSettings(secretKey), + limits.instanceType === "dev" ? fetchUserCount(secretKey) : Promise.resolve(null), + ]), + ); + + // Creating a user without legal acceptance needs consent of its own: + // the flag, or a yes at a prompt. Otherwise the checks reject them. + let skipLegalChecks = options.skipLegalChecks ?? false; + const withoutLegal = settings?.sign_up?.legal_consent_enabled + ? users.filter((user) => !user.legalAcceptedAt && !user.skipLegalChecks).length + : 0; + if (!skipLegalChecks && withoutLegal > 0 && !options.dryRun && canPrompt(options)) { + skipLegalChecks = await confirm({ + message: `${plural(withoutLegal, "user")} ${withoutLegal === 1 ? "has" : "have"} no legal acceptance on record, which this instance requires. Import them without it?`, + default: false, + }); + } + + const checks = await withSpinner("Checking users against the instance...", async (spinner) => + checkImport({ + users, + skipLegalChecks, + failures, + unknownFields: loaded.unknownFields, + ...(supabaseRows ? { supabaseRows } : {}), + settings, + existingUsers, + instanceType: limits.instanceType, + target, + secretKey, + schedule, + adoptedClerkIds: new Set(adopted.values()), + spinner, + }), + ); + + const refused = checks.rejects.length > 0 && !options.allowPartial; + const preview = (extra: Record) => + log.data( + JSON.stringify( + { + target, + run: continued ?? null, + resume: resume.kind, + checks: checksJson(checks), + ...extra, + }, + null, + 2, + ), + ); + + if (!options.json) printChecks(checks); + + if (options.dryRun) { + if (options.json) preview({ dryRun: true }); + else { + log.blank(); + log.info(dim("Dry run: nothing was written.")); + } + // The exit code says what the real run would do. + if (refused) process.exitCode = 2; + return; + } + + if (refused) { + if (options.json) preview({ refused: true }); + throwUsageError( + `${plural(checks.rejects.length, "user")} would be rejected, so nothing was imported. ` + + "Fix them, or pass --allow-partial to import the rest and record them as skipped.", + undefined, + undefined, + [ + { + command: commandFor(options, input.fromExport, ["--allow-partial", "--yes"]), + description: "Import the users that pass", + }, + ], + ); + } + + if ( + checks.importable.length === 0 && + checks.rejects.length === 0 && + withoutPassword.length === 0 && + attachOnly.length === 0 + ) { + // Settled, so a continued run is finished rather than left interrupted. + if (continued) continueRun(runsDir, continued).finish(); + if (options.json) preview({ nothingToImport: true }); + else log.warn("No users left to import."); + return; + } + + // Rule 1: nothing is written without consent — `--yes`, or a yes at a + // prompt. An agent, a non-TTY run and `--json` never prompt. + if (!options.yes) { + if (!canPrompt(options)) { + if (options.json) preview({ consent: "required" }); + throwUsageError( + `\`clerk migrate import\` will create ${plural(checks.importable.length, "user")} and needs consent. Pass --yes to confirm.`, + undefined, + undefined, + [ + { + command: commandFor(options, input.fromExport, ["--yes"]), + description: "Run the import", + }, + ], + ); + } + log.blank(); + const proceed = await confirm({ + message: `Import ${plural(checks.importable.length, "user")}?`, + default: false, + }); + if (!proceed) throwUserAbort(); + } + + // Gitignored only now, once there is consent to write a run. + await resolveRunsDir(options.runsDir, { write: true }); + const run = continued + ? continueRun(runsDir, continued) + : startRun(runsDir, { + kind: "import", + target, + source, + ...(options.sourceHash ? { sourceHash: options.sourceHash } : {}), + file: { path: filePath, sha256 }, + ...(input.fromExport ? { fromExport: input.fromExport } : {}), + }); + recordRejects(run, checks, adopted); + for (const user of withoutPassword) { + run.append({ + sourceId: user.userId, + status: "skipped", + reason: "no password (--require-password)", + }); + } + + const summary = + checks.importable.length > 0 || attachOnly.length > 0 + ? await withProgress( + { total: checks.importable.length, verb: "created" }, + async (progress) => + importUsers({ + users: checks.importable, + secretKey, + limits, + record: run.append, + attachOnly, + adopted, + skipPasswordRequirement: !options.requirePassword, + progress, + }), + ) + : { + totalProcessed: 0, + successful: 0, + failed: 0, + validationFailed: 0, + errorBreakdown: new Map(), + }; + const record = run.finish(); + if (summary.failed > 0) process.exitCode = 1; + + if (options.json) { + log.data( + JSON.stringify( + { + target, + run: record, + resume: resume.kind, + checks: checksJson(checks), + result: { + created: summary.successful, + failed: summary.failed, + skipped: checks.rejects.length + withoutPassword.length, + errors: [...summary.errorBreakdown].map(([error, count]) => ({ error, count })), + }, + }, + null, + 2, + ), + ); + return; + } + + log.blank(); + for (const line of formatSummary( + summary, + checks.rejects.length + withoutPassword.length, + record, + run.dir, + limits.instanceType, + )) { + log.info(line); + } + if (record.status === "complete") { + log.blank(); + for (const line of cleanupLines(runsDir, record)) log.info(line); + } + + const steps = + summary.failed > 0 + ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS(record.id) + : NEXT_STEPS.MIGRATE_DONE(record.id); + setNextSteps(steps); + printAgentNextSteps(steps); + }, + { skip: Boolean(options.json) }, + ); +} diff --git a/packages/cli-core/src/commands/migrate/wizard.test.ts b/packages/cli-core/src/commands/migrate/wizard.test.ts new file mode 100644 index 000000000..824e0ebb7 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/wizard.test.ts @@ -0,0 +1,131 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { listageStubs, useCaptureLog } from "../../test/lib/stubs.ts"; + +useCaptureLog(); + +type Prompt = { message: string; default?: string; validate?: (v?: string) => string | undefined }; +type SelectPrompt = Prompt & { choices: { name: string; value: string }[] }; + +// Registered at file top, before the wizard (or anything it imports) loads. +// This file is the only consumer of the mocked prompt modules. +const mockSelect = mock(async (_config: SelectPrompt) => undefined as unknown); +const mockText = mock(async (_config: Prompt) => "" as unknown); + +mock.module("../../lib/listage.ts", () => ({ + ...listageStubs, + select: (config: SelectPrompt) => mockSelect(config), +})); + +mock.module("../../lib/prompts.ts", () => ({ + confirm: async () => true, + text: (config: Prompt) => mockText(config), + password: async () => "", + editor: async () => "{}", +})); + +const { promptForFile, promptForFirebaseHashConfig, promptForSource } = await import("./wizard.ts"); + +let workDir: string; +let originalCwd: string; + +beforeAll(() => { + originalCwd = process.cwd(); + workDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-wizard-"))); + process.chdir(workDir); + fs.writeFileSync(path.join(workDir, "users.json"), "[]"); + fs.writeFileSync(path.join(workDir, "other.csv"), ""); + fs.writeFileSync(path.join(workDir, "notes.txt"), ""); +}); + +afterAll(() => { + process.chdir(originalCwd); + fs.rmSync(workDir, { recursive: true, force: true }); +}); + +beforeEach(() => { + mockSelect.mockReset(); + mockText.mockReset(); +}); + +/** The config object passed to the Nth `text`/`select` prompt. */ +const textCall = (index: number): Prompt | undefined => mockText.mock.calls[index]?.[0]; +const selectCall = (index: number): SelectPrompt | undefined => mockSelect.mock.calls[index]?.[0]; + +describe("promptForSource", () => { + test("is built from the registry, so every platform appears", async () => { + mockSelect.mockResolvedValue("auth0"); + + expect(await promptForSource()).toBe("auth0"); + expect(selectCall(0)?.choices.map((choice) => choice.value)).toEqual([ + "clerk", + "auth0", + "authjs", + "betterauth", + "firebase", + "supabase", + "workos", + ]); + }); + + test("labels each choice with the source's display name", async () => { + mockSelect.mockResolvedValue("clerk"); + await promptForSource(); + expect(selectCall(0)?.choices.map((choice) => choice.name)).toContain("Better Auth"); + }); +}); + +describe("promptForFile", () => { + const validate = async () => { + mockText.mockResolvedValue("users.json"); + await promptForFile(); + return textCall(0)?.validate as (value?: string) => string | undefined; + }; + + test("returns the trimmed path", async () => { + mockText.mockResolvedValue(" users.json "); + expect(await promptForFile()).toBe("users.json"); + }); + + test("accepts an existing JSON or CSV file", async () => { + const check = await validate(); + expect(check("users.json")).toBeUndefined(); + expect(check("other.csv")).toBeUndefined(); + }); + + test.each([ + ["", /required/], + ["nope.json", /File not found/], + ["notes.txt", /\.json or \.csv/], + ])("rejects %p", async (value, message) => { + const check = await validate(); + expect(check(value)).toMatch(message); + }); +}); + +describe("promptForFirebaseHashConfig", () => { + test("collects all four parameters as a set", async () => { + mockText + .mockResolvedValueOnce("SIGNER") + .mockResolvedValueOnce("Bw==") + .mockResolvedValueOnce("8") + .mockResolvedValueOnce("14"); + + expect(await promptForFirebaseHashConfig()).toEqual({ + base64_signer_key: "SIGNER", + base64_salt_separator: "Bw==", + rounds: 8, + mem_cost: 14, + }); + }); + + // An export with no password hashes needs none of them. + test("stops when the signer key is left blank", async () => { + mockText.mockResolvedValueOnce(""); + + expect(await promptForFirebaseHashConfig()).toBeUndefined(); + expect(mockText).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/wizard.ts b/packages/cli-core/src/commands/migrate/wizard.ts new file mode 100644 index 000000000..39c9fae7a --- /dev/null +++ b/packages/cli-core/src/commands/migrate/wizard.ts @@ -0,0 +1,96 @@ +/** + * The prompts behind an interactive `clerk migrate import`. + * + * Each one fills in exactly one thing the command was not given: the file, + * 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 + * usage error naming what to pass instead. + */ + +import { select } from "../../lib/listage.ts"; +import { log } from "../../lib/log.ts"; +import { text } from "../../lib/prompts.ts"; +import { fileExists, getFileType } from "./lib/transform.ts"; +import { sources } from "./sources/registry.ts"; +import type { FirebaseHashConfig } from "./types.ts"; + +/** Trims a description down to a single readable hint line. */ +function hint(description: string): string { + const firstSentence = description.split(". ")[0] ?? description; + return firstSentence.length > 96 ? `${firstSentence.slice(0, 93)}...` : firstSentence; +} + +/** Asks which platform the file came from. Built from the registry. */ +export async function promptForSource(): Promise { + return select({ + message: "Which platform did this file come from?", + choices: sources.map((entry) => ({ + name: entry.label, + value: entry.key, + description: hint(entry.description), + })), + }); +} + +/** Asks for the file to import. */ +export async function promptForFile(): Promise { + const answer = await text({ + message: "Path to the exported user file (JSON or CSV)", + validate: (value) => { + const file = value?.trim(); + if (!file) return "A file path is required"; + if (!fileExists(file)) return `File not found: ${file}`; + if (!getFileType(file)) return "Provide a .json or .csv file"; + return undefined; + }, + }); + return answer.trim(); +} + +async function askNumber(label: string): Promise { + const answer = await text({ + message: label, + validate: (value) => { + const parsed = Number(value?.trim()); + return Number.isInteger(parsed) && parsed > 0 ? undefined : "Enter a positive whole number"; + }, + }); + return Number(answer.trim()); +} + +/** + * Collects Firebase's four hash parameters. + * + * Asked as a set because a partial set produces a digest that verifies against + * nothing. Pressing enter at the first leaves the config unset, which is + * correct for an export with no password hashes. + */ +export async function promptForFirebaseHashConfig(): Promise { + log.info( + "Firebase password hashes need the project's hash parameters. Find them in the Firebase console under Authentication → Users → (⋮) → Password hash parameters.", + ); + log.info("Pass the four --firebase-* flags to skip these prompts on the next run."); + + const signerKey = ( + await text({ + message: "base64 signer key (leave blank if this export has no passwords)", + }) + ).trim(); + if (!signerKey) return undefined; + + const saltSeparator = ( + await text({ + message: "base64 salt separator", + validate: (value) => (value?.trim() ? undefined : "Required alongside the signer key"), + }) + ).trim(); + + return { + base64_signer_key: signerKey, + base64_salt_separator: saltSeparator, + rounds: await askNumber("rounds"), + mem_cost: await askNumber("mem cost"), + }; +} diff --git a/packages/cli-core/src/lib/json-body.ts b/packages/cli-core/src/lib/json-body.ts index f5ff5a7d2..31b18cc5f 100644 --- a/packages/cli-core/src/lib/json-body.ts +++ b/packages/cli-core/src/lib/json-body.ts @@ -85,7 +85,7 @@ function preview(raw: string): string { * literal `"` inside doubled; everywhere else single quotes pass the value * through untouched. */ -function quoteArg(value: string): string { +export function quoteArg(value: string): string { if (/^[A-Za-z0-9_\-./:@]+$/.test(value)) return value; if (process.platform === "win32") return `"${value.replace(/"/g, '""')}"`; return `'${value.replace(/'/g, `'\\''`)}'`; diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index f0967af8f..487b26ed1 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -75,6 +75,14 @@ export const NEXT_STEPS = { "Run `clerk apps list` to see your other applications", "Run `clerk config pull` to inspect the live configuration of this instance", ], + MIGRATE_DONE: (runId: string) => [ + `Run \`clerk migrate runs ${runId}\` to see what happened to each user`, + `Run \`clerk migrate undo ${runId}\` to delete the users it created`, + ], + MIGRATE_DONE_WITH_ERRORS: (runId: string) => [ + `Run \`clerk migrate runs ${runId}\` to see every user that failed and why`, + `Run \`clerk migrate undo ${runId}\` to delete the users it created`, + ], } as const; /** @@ -82,7 +90,30 @@ export const NEXT_STEPS = { * Only shown in human/interactive mode — agents get AGENT_PROMPT instead. */ export function printNextSteps(steps: readonly string[]): void { - if (!isHuman() || steps.length === 0) return; + if (!isHuman()) return; + renderNextSteps(steps); +} + +/** + * The same suggestions, on the paths a human never takes: agent mode and a + * non-TTY, where `printNextSteps` and `withGutter`'s outro both print nothing. + * + * A no-op for a human, who gets them from the outro — so a caller pairs this + * with `setNextSteps` rather than choosing between the two. + * + * Opt-in rather than folded into `printNextSteps`, because most steps are a + * nudge towards a command someone might like to run next. The migrate ones are + * not: they name the log file holding the per-user record of what landed, and + * the command that undoes the run. An agent that imported 10,000 users and lost + * 300 of them needs both, and has no gutter to read them from. + */ +export function printAgentNextSteps(steps: readonly string[]): void { + if (isHuman()) return; + renderNextSteps(steps); +} + +function renderNextSteps(steps: readonly string[]): void { + if (steps.length === 0) return; for (const step of steps) { log.info(` ${cyan("\u2192")} ${step}`); } diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index 2222cb838..886248b33 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -107,7 +107,9 @@ export async function withGutter( let nextSteps: readonly string[] | undefined; const controls: GutterControls = { setNextSteps(steps) { - nextSteps = steps; + // Empty is ignored rather than stored: `outro([])` would render the + // "Next steps" header with no bullets under it. Matches printNextSteps. + if (steps.length > 0) nextSteps = steps; }, }; diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index e1e10d2e1..35a1152ba 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -220,6 +220,12 @@ describe("generateCompletions", () => { expect(names).toContain("DELETE"); }); + test("completes --source with the built-in migrate sources", () => { + const names = completionNames("migrate", "import", "--source", ""); + expect(names).toContain("clerk"); + expect(names).toContain("betterauth"); + }); + test("returns empty for options with unknown values (file paths)", () => { const result = complete("config", "pull", "--output", ""); expect(result.completions).toEqual([]); @@ -272,6 +278,12 @@ describe("generateCompletions", () => { expect(names).toContain("settings"); }); + test("migrate sources: suggests the built-in sources", () => { + const names = completionNames("migrate", "sources", ""); + expect(names).toContain("supabase"); + expect(names).toContain("workos"); + }); + test("open dashboard: filters subpaths by prefix", () => { const names = completionNames("open", "dashboard", "u"); expect(names).toContain("users"); diff --git a/test/e2e/migrate.test.ts b/test/e2e/migrate.test.ts new file mode 100644 index 000000000..226a85e0e --- /dev/null +++ b/test/e2e/migrate.test.ts @@ -0,0 +1,161 @@ +/** + * Live-BAPI tests for `clerk migrate import`, covering what only a real + * instance can answer: + * + * - A Better Auth scrypt hash, sent as `scrypt_werkzeug`, verifies against the + * password it was made from. A unit test can only check the string shape. + * - A user whose only email is unverified, imported into an instance that + * requires an email, is refused by Clerk. The import's checks reject that + * user up front on the strength of this test, so it checks both halves. + * + * Requires `CLERK_PLATFORM_API_KEY` and `CLERK_CLI_TEST_APP_ID`. Locally, run + * via `bun run test:e2e:op` so 1Password resolves both in-memory. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { randomBytes, scryptSync } from "node:crypto"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const CLI_PATH = join(import.meta.dir, "../../packages/cli-core/src/cli.ts"); + +let APP_ID: string; +let workDir: string; +let emailRequired = false; +const importRuns: string[] = []; + +async function cli(args: string[]) { + return Bun.$`bun ${CLI_PATH} ${args} --app ${APP_ID}` + .env({ + ...process.env, + CLERK_CONFIG_DIR: join(workDir, "config"), + CLERK_MIGRATE_DIR: join(workDir, "runs"), + CLERK_TELEMETRY_DISABLED: "1", + }) + .cwd(workDir) + .quiet() + .nothrow(); +} + +beforeAll(async () => { + const appId = process.env.CLERK_CLI_TEST_APP_ID; + if (!appId || !process.env.CLERK_PLATFORM_API_KEY) { + throw new Error( + "CLERK_CLI_TEST_APP_ID and CLERK_PLATFORM_API_KEY are required. " + + "Run via `bun run test:e2e:op` for local 1Password injection.", + ); + } + APP_ID = appId; + workDir = mkdtempSync(join(tmpdir(), "clerk-cli-e2e-migrate-")); + + const config = await cli(["config", "pull", "--keys", "auth_email"]); + const authEmail = ( + JSON.parse(config.stdout.toString()) as { + auth_email?: { required_for_sign_up?: boolean }; + } + ).auth_email; + emailRequired = Boolean(authEmail?.required_for_sign_up); +}); + +// Undo every import, so the test app does not fill up with migrated users. +afterAll(async () => { + await Promise.all(importRuns.map(async (runId) => cli(["migrate", "undo", runId, "--yes"]))); + rmSync(workDir, { recursive: true, force: true }); +}, 60_000); + +/** + * Imports `users` as a Better Auth export and returns each user's latest run + * line. A user has several (`creating`, then `created`); the last one wins, + * as it does for `runs` and `undo`. + */ +async function importBetterAuth(users: Record[], extra: string[] = []) { + const file = join(workDir, `betterauth-${randomBytes(4).toString("hex")}.json`); + writeFileSync(file, JSON.stringify(users)); + + await cli(["migrate", "import", file, "--source", "betterauth", "--yes", ...extra]); + + const runsDir = join(workDir, "runs"); + const [runId] = readdirSync(runsDir) + .filter( + (id) => JSON.parse(readFileSync(join(runsDir, id, "run.json"), "utf-8")).kind === "import", + ) + .filter((id) => !importRuns.includes(id)); + if (!runId) throw new Error("The import recorded no run."); + importRuns.push(runId); + + const latest = new Map>(); + for (const line of readFileSync(join(runsDir, runId, "users.ndjson"), "utf-8") + .trim() + .split("\n")) { + const parsed = JSON.parse(line) as Record; + latest.set(parsed.sourceId, parsed); + } + return [...latest.values()]; +} + +/** A password hashed exactly the way Better Auth's default hasher does it. */ +function betterAuthHash(password: string): string { + const salt = randomBytes(16).toString("hex"); + const key = scryptSync(password.normalize("NFKC"), salt, 64, { + N: 16384, + r: 16, + p: 1, + maxmem: 128 * 16384 * 16 * 2, + }); + return `${salt}:${key.toString("hex")}`; +} + +test("a Better Auth scrypt hash imports and verifies against its password", async () => { + const hex = randomBytes(6).toString("hex"); + const password = `Migrate${hex}!1`; + + const [line] = await importBetterAuth([ + { + user_id: `ba_${hex}`, + email: `${hex}+clerk_test@clerkcookie.com`, + email_verified: true, + password_hash: betterAuthHash(password), + }, + ]); + expect(line).toMatchObject({ status: "created" }); + + const verify = await cli([ + "api", + `/users/${line?.clerkId as string}/verify_password`, + "-X", + "POST", + "-d", + JSON.stringify({ password }), + ]); + expect(JSON.parse(verify.stdout.toString())).toMatchObject({ verified: true }); +}, 60_000); + +test("a user whose only email is unverified is refused where email is required", async () => { + if (!emailRequired) { + // The test app decides this, not the test; say so rather than pass silently. + console.warn("Skipped: the test app's development instance does not require an email."); + return; + } + const hex = randomBytes(6).toString("hex"); + + // The premise: Clerk itself refuses a user created with no email, which is + // what an unverified-only user is at `POST /v1/users`. + const direct = await cli([ + "api", + "/users", + "-d", + JSON.stringify({ external_id: `direct_${hex}`, skip_password_requirement: true }), + ]); + expect(direct.exitCode).not.toBe(0); + + // And the import's checks reject them before asking Clerk. + const [line] = await importBetterAuth( + [{ user_id: `ba_${hex}`, email: `${hex}+clerk_test@clerkcookie.com`, email_verified: false }], + ["--allow-partial"], + ); + expect(line).toMatchObject({ + status: "skipped", + reason: "only has an unverified email, and this instance requires an email", + }); +}, 60_000); From b5d6d16a0a547a477f795da824d09c93a57ea250 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 12:59:23 -0400 Subject: [PATCH 02/39] feat(migrate): trim clerk migrate import, its README and E2E to slice 1 Cuts the files ported from #479 down to slice 1's scope, as slice-prs.md sets it, and adds the files slice 1 needs that #479 does not have. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/completion/__complete.ts | 6 +- .../cli-core/src/commands/migrate/README.md | 886 +++--------------- .../src/commands/migrate/index.test.ts | 225 +---- .../cli-core/src/commands/migrate/index.ts | 154 +-- .../src/commands/migrate/readme.test.ts | 20 +- .../commands/migrate/run-interactive.test.ts | 30 +- .../cli-core/src/commands/migrate/run.test.ts | 626 +------------ packages/cli-core/src/commands/migrate/run.ts | 400 ++------ .../src/commands/migrate/wizard.test.ts | 45 +- .../cli-core/src/commands/migrate/wizard.ts | 53 +- packages/cli-core/src/lib/next-steps.ts | 16 +- .../src/test/integration/completion.test.ts | 23 +- test/e2e/migrate.test.ts | 163 ++-- 13 files changed, 452 insertions(+), 2195 deletions(-) diff --git a/packages/cli-core/src/commands/completion/__complete.ts b/packages/cli-core/src/commands/completion/__complete.ts index 642281f9e..06f9d87f1 100644 --- a/packages/cli-core/src/commands/completion/__complete.ts +++ b/packages/cli-core/src/commands/completion/__complete.ts @@ -34,10 +34,7 @@ const HTTP_METHOD_COMPLETIONS: Completion[] = [ { name: "DELETE", description: "Delete resource" }, ]; -/** - * The built-in migrate sources. `--source` also takes a path to a source you - * wrote, so it cannot use `.choices()`. - */ +/** The built-in migrate sources. Kept off `.choices()` so an unknown key gets the source error. */ const SOURCE_COMPLETIONS: Completion[] = sources.map((entry) => ({ name: entry.key, description: entry.label, @@ -77,7 +74,6 @@ const KNOWN_OPTION_VALUES: Record = { * to root, e.g. "open dashboard". */ const KNOWN_POSITIONAL_COMPLETIONS: Record = { - "migrate sources": SOURCE_COMPLETIONS, "open dashboard": KNOWN_DASHBOARD_PATHS.map((path) => ({ name: path, description: "Dashboard subpath", diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 25bf72494..b22248c4e 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -1,55 +1,56 @@ # `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 Clerk instance. ``` -clerk migrate export [-o ] [--json] -clerk migrate import [--source ] [--dry-run] [--allow-partial] [--new-run] [--yes] [--json] -clerk migrate runs [run-id] [--json] -clerk migrate undo [--dry-run] [--yes] [--json] -clerk migrate sources [source] [--json] +clerk migrate import [--source ] [--dry-run] [--allow-partial] [--yes] [--json] clerk migrate help ``` -A migration is usually three steps: +An import is usually two steps: ```sh -clerk migrate export supabase # 1. a run, holding export.json -clerk migrate import 20260929-141502-a1b2 --dry-run # 2. check it against the instance -clerk migrate import 20260929-141502-a1b2 --yes # 3. import it +clerk migrate import users.json --source supabase --dry-run # 1. check it against the instance +clerk migrate import users.json --source supabase --yes # 2. 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. `clerk migrate` on its own -is a group name, not a command: it prints its help. +`--runs-dir ` (or `CLERK_MIGRATE_DIR`) keeps runs somewhere else. +`clerk migrate` on its own is a group name, not a command: it prints its help. ## The rules Every command follows these: 1. **Nothing writes without consent.** Consent is a yes at a terminal prompt, or - `--yes`. Without either, `import` and `undo` print what they would do and - exit 2 with the command to run. `--json` means non-interactive: it never - prompts. + `--yes`. Without either, `import` prints what it would do and exits 2 with + the command to run. `--json` means non-interactive: it never prompts. 2. **`--dry-run` checks against the real instance, and writes nothing.** An import's [checks](#checks) run before anything is written. Predicted rejects stop the import unless `--allow-partial` is passed; fields that would 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 - exports all read or write it. + including the error for each user who failed. 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 - Ctrl-C stops an import or an undo partway. The UI goes to stderr and data to - stdout. + Ctrl-C stops an import partway. The UI goes to stderr and data to stdout. - While users are created or deleted, a terminal shows a bar and the counts - under it, not a spinner: + While users are created, a terminal shows a bar and the counts under it, not + a spinner: ``` │ ██████████████████████████████████████████████████████░░░░░░░░░░░░░░░░░░ 75% @@ -76,34 +77,31 @@ Resolution order: `--secret-key` → `--app` + Platform API lookup → from `clerk link`. With a key from `--secret-key` or `CLERK_SECRET_KEY`, the key alone picks the -instance, so `import` and `undo` refuse (exit 2) an `--instance` that names a -different one. `--instance dev` next to an exported `sk_live_…` key would -otherwise write to production. +instance, so `import` refuses (exit 2) an `--instance` that names a different +one. `--instance dev` next to an exported `sk_live_…` key would otherwise write +to production. The **instance type is read from the key**: `sk_live_…` is treated as production, anything else as development. That choice drives the throughput defaults and the development-instance user limit below. -**Every command prints its target first.** `import` and `undo` name the -instance — its environment, its app when the key came from one, and its ID from +**Every command prints its target first.** `import` names the 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 -instance it reads. `runs` names the runs folder. `--json` carries the same -facts as `target`. +profile. `--json` carries the same facts as `target`. ``` Target: My App (app_2x9k…), production instance ins_2x9k… Key from: linked profile ``` -The instance ID is what a run records, so `undo` and re-runs can tell whether -the key now in use still addresses the same instance. +The instance ID is what a run records. ## The run store -Every import, export and undo is a **run**, and the run store is the one place -`clerk migrate` keeps state. +Every import is a **run**, and the run store is the one place `clerk migrate` +keeps state. ### Where runs are kept @@ -114,8 +112,10 @@ The first of these that is set: 3. `/.clerk/migrate/` The project root is the linked profile's directory, then the git toplevel, then -the current directory. Writing to the default location adds `.clerk/` to the -project's `.gitignore` first, because run files carry user data. +the current directory. The first run written to the default location adds +`.clerk/` to the project's `.gitignore`, because run files carry user data. That +happens only once there is consent to write: a dry run or a refused import +leaves `.gitignore` alone. ### What a run holds @@ -127,10 +127,9 @@ Each run is a folder named for its ID, `YYYYMMDD-HHmmss-xxxx`: | `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 | -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, and a validation failure all land in -`error`. +A user's status is `creating`, `created`, `failed` or `skipped`. The last line +for each `sourceId` wins. A `429` retry, an extra email or phone that did not +attach, and a validation failure all land in `error`. `creating` is written as a user's `POST /v1/users` goes out. It stays the latest line when no answer says whether the create landed: an abort, a @@ -138,19 +137,14 @@ network error, or a 5xx. A `created` line with `pending` lists the extra emails and phones not yet attached. A run is `partial` when any user failed, was skipped or is still `creating`, -and `complete` otherwise. A run whose process died, or that never recorded a finish time, -lists as `interrupted`. A lock held by another live process refuses a second -writer with exit 2, and names the lock file to delete if that process is not a -migrate run. A lock holding this process's own PID is stale: in a container the -CLI often gets the same PID every run. +and `complete` otherwise. A lock holding this process's own PID is stale: in a +container the CLI often gets the same PID every run. -Run folders are created owner-only (`0700`), and export files `0600`: they hold -password hashes and user data. +Run folders are created owner-only (`0700`), because they hold user data. `users.ndjson` writes are synchronous appends, so a run interrupted with Ctrl-C still leaves a complete record of everything already processed. A line that -cannot be written stops that user's create from going out. An export's -file lands in its run folder as `export.json` unless `--output` says otherwise. +cannot be written stops that user's create from going out. ### Why `users.ndjson` is NDJSON @@ -173,373 +167,6 @@ 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 -implied by the group. - -### `clerk migrate export` - -Gets users **out** of a source platform, so there is something to feed -`clerk migrate import`. - -```sh -clerk migrate export # pick a platform -clerk migrate export clerk --output users.json -clerk migrate export auth0 --domain my-tenant.us.auth0.com \ - --client-id … --client-secret … -clerk migrate export workos --api-key sk_… -``` - -The platform is an optional positional. Omitted, you get a picker built from -the registry; given, it runs directly. Each platform resolves its own flags — -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 -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 other question the command asked. So that step — -and only that step, never a fetch already under way or a file already written — -runs inside a retry: the failure is explained, the prompt comes back, and the -rest of the export continues against whichever credential worked. Agent mode -and a non-TTY fail outright instead, having nobody to ask, and `-y` fails too, -having been told not to. - -| Platform | Source | Feeds | -| ------------ | -------------------------------- | --------------------- | -| `clerk` | Clerk Backend API | `--source clerk` | -| `auth0` | Auth0 Management API | `--source auth0` | -| `supabase` | Supabase Postgres (`auth.users`) | `--source supabase` | -| `authjs` | Auth.js database | `--source authjs` | -| `betterauth` | Better Auth database | `--source betterauth` | -| `firebase` | Firebase Identity Toolkit | `--source firebase` | -| `workos` | WorkOS User Management API | `--source workos` | - -Every export is a [run](#the-run-store), and the file lands in the run -folder as `export.json`. `--output` writes it somewhere else instead, -resolved against the **current directory** like every other path flag here; -the run still records where. Nothing is asked about where the file goes. - -The file is an envelope around the users: - -```json -{ - "clerkMigrate": 1, - "source": "clerk", - "exportedAt": "2026-09-29T14:15:02.000Z", - "runId": "20260929-141502-a1b2", - "users": [ … ] -} -``` - -`source` is what lets `clerk migrate import ` run with no -`--source`. A Firebase export adds `firebase`, the project's hash -parameters, so the import needs no `--firebase-*` flags. - -`--json` prints the result on stdout instead — `{ target, run, output, users, -coverage, next }` — and never prompts, so a missing credential exits 2 naming -the flag to pass. - -| 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 | -| `--json` | all | Print the result as JSON; never prompts | -| `--db-url ` | `supabase`, `authjs`, `betterauth` | Postgres, MySQL, libsql/Turso or SQLite connection string | -| `--service-account ` | `firebase` | Path to a service account key JSON file | -| `--domain ` | `auth0` | Tenant domain, e.g. `my-tenant.us.auth0.com` | -| `--client-id ` | `auth0` | Machine-to-machine application client ID | -| `--client-secret ` | `auth0` | Machine-to-machine application client secret | -| `--api-key ` | `workos` | WorkOS secret API key, the one starting `sk_` | -| `--with-identities` | `workos` | Also record each user's OAuth providers | -| `--no-with-identities` | `workos` | Skip the OAuth provider fan-out without being asked | - -`export clerk` also takes the targeting flags — it reads from a Clerk instance, -so it resolves a key the same way `clerk migrate import` does, with one extra -step. The linked project is usually the migration's _destination_, so taking it -as the source without asking is how a run exports an instance and imports it -back into itself. Instead: - -- **Naming the instance runs unquestioned.** `--secret-key `, `--app`, - `--instance`, or an exported `CLERK_SECRET_KEY` — any of them is a sentence - you typed for this run, so none of them opens a picker. That is what makes - the export scriptable outside agent mode, and it keeps an exported key - outranking the linked profile here the way it does everywhere else in the - CLI. -- Anything resolved on your behalf — the linked project, a keyless app — is - never taken silently. A picker of every **instance** on your account opens - instead — one flat row each, `my-app - Production instance (ins_…)`, not an - application picker followed by an instance picker — with the resolved - application's instances listed **first** so taking one is still a single - Enter. Only when there are no instances to offer does it stop and list - `--secret-key`, `--app`/`--instance` and `clerk link` instead. -- With nothing to resolve at all (no link, no key, no flags), you get the - application picker `clerk users` uses — `Select a Clerk application to use:`, - followed by an instance picker when the application has more than one — - rather than an error about an unlinked directory. That is `clerk link`'s - picker, so it does offer `+ Create a new application`; a brand-new - application has no users to export, so it is never the answer here. - -The instance picker (the second tier) has no "create a new application" choice. -Its rows are searchable by what they show, so typing an application name, -`production`, or an instance id all narrow it. - -In agent mode the resolved instance is used without a prompt; pass -`--secret-key` or `--app`/`--instance` to be explicit. - -After each export you get a field-coverage table — which Clerk-relevant fields -were present on how many users — so you know the data is thin _before_ you -import it, not after: - -``` -Field coverage - ✓ 3/3 have an email address - ✗ 0/3 have a phone number - ! 1/3 have a username - ! 2/3 have a password (not exportable — see below) - -Exported 3 users to /project/.clerk/migrate/20260929-141502-a1b2/export.json -Run 20260929-141502-a1b2. See each user with `clerk migrate runs 20260929-141502-a1b2`. - -Import them with: - clerk migrate import 20260929-141502-a1b2 - - Imports into whichever instance the resolved secret key belongs to. - For production, add `--instance prod` or use a production secret key. -``` - -The import command prints through the same channel as the coverage table -rather than the gutter's **Next steps** outro, which is human-only — an agent -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 -profile in that order. - -The export run has one line per exported user, so `clerk migrate runs` lists it -alongside imports. Every export takes `--runs-dir ` to keep that run -somewhere else. - -#### Three platforms export no passwords - -- **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 - only from a support request. Add a `passwordHash` field to each user before - importing, or migrate without passwords. -- **WorkOS** returns neither password hashes nor TOTP secrets, and has no - support-request escape hatch: hashes go in on import and never come back, and - `totp.secret` is returned on enrol only. There is nothing to add to the file. - -All three say so on every run. The coverage row counts users who _have_ a -password, so the size of the gap is visible up front — `workos` prints that row -at zero unconditionally, because zero is the only value it can take. - -#### Database-backed exports (`supabase`, `authjs`, `betterauth`) - -These three read the database directly, over **`--db-url`**: - -```sh -clerk migrate export supabase --db-url "postgres://postgres:...@db.xxx.supabase.co:5432/postgres" -clerk migrate export authjs --db-url "mysql://user:...@127.0.0.1:3306/authjs" -clerk migrate export betterauth --db-url "./db.sqlite" -clerk migrate export betterauth --db-url "libsql://app-org.turso.io?authToken=..." # or set TURSO_AUTH_TOKEN -``` - -Postgres and MySQL go through `Bun.sql`; SQLite through `bun:sqlite`; -`libsql://` (Turso) over the server's HTTP pipeline endpoint, since `bun:sqlite` -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 libsql token comes from `?authToken=` on the URL, or from -`TURSO_AUTH_TOKEN` / `LIBSQL_AUTH_TOKEN`, and is redacted like a password. - -**Connection strings are redacted everywhere.** Errors show -`postgres://***@host/db`, including when the password itself contains an -unencoded `@` — the most common mistake, and exactly when the string ends up in -an error message. - -Connection failures get a hint rather than a driver error. Bun reports both an -unreachable host and a closed port as "Connection closed", so: - -| Situation | What you are told | -| ------------------------ | ------------------------------------------------------------------------------------------------- | -| Host or port unreachable | Check the host and port. On Supabase: use the pooler connection string, or enable the IPv4 add-on | -| Credentials rejected | Check the user and password | -| Table missing | Check the database name and SELECT permission. On Supabase: enable Auth, connect as `postgres` | -| SQLite file missing | Check the path and that the file is readable | - -**`supabase` reads the database rather than the Admin API** because -`encrypted_password` exists only there. An API-based export would force every -user to reset their password; this one carries the bcrypt digests across. It -also keeps `raw_app_meta_data`, which is what the import's -[checks](#checks) read for each user's providers. - -**`authjs` tries `User`, then `user`, then `users`.** Auth.js has no single -schema — Prisma capitalizes the table, Drizzle does not, and Postgres treats -the difference as significant once quoted. The run reports which one it found. -The verified column is read as `emailVerified`, or `email_verified` on a legacy -NextAuth table. Auth.js core stores no passwords, so its users arrive without -credentials. - -**`betterauth` reads its schema before it queries.** It finds the tables -(`user` and `account`, or `users` and `accounts` under `usePlural: true`), how -the columns are spelled (camelCase, or the snake_case Better Auth's Drizzle -generator writes by default), and which plugin columns exist. The username -plugin adds `username`, admin adds `banned` (carried only while `banExpires` is -unset or in the future), phone-number adds `phoneNumber`, and so on; selecting a column that is not there fails the whole query, and the -database answers the question better than the user can. Passwords come from a -`LEFT JOIN` onto the credential `account` row — left, not inner, so a user who -only ever signed in with OAuth is still exported. - -#### `firebase` - -```sh -clerk migrate export firebase --service-account ./service-account.json -``` - -Create a key at **Project settings → Service accounts → Generate new private -key**. The account needs to read users and the project's password hash -parameters (`signIn.hashConfig`). Either of these works, both verified live: - -- **Firebase Authentication Admin** (`roles/firebaseauth.admin`). The simplest - option, but it can also modify users and auth settings. -- **Read-only:** Firebase Authentication Viewer (`roles/firebaseauth.viewer`) - plus a custom role that adds `firebaseauth.configs.getHashConfig`: - - ```sh - gcloud iam roles create clerkMigrateHashExport --project=PROJECT_ID \ - --title="Clerk migrate hash export" \ - --permissions=firebaseauth.configs.get,firebaseauth.configs.getHashConfig,firebaseauth.users.get - gcloud projects add-iam-policy-binding PROJECT_ID \ - --member=serviceAccount:SA_EMAIL --role=roles/firebaseauth.viewer - gcloud projects add-iam-policy-binding PROJECT_ID \ - --member=serviceAccount:SA_EMAIL --role=projects/PROJECT_ID/roles/clerkMigrateHashExport - ``` - -Without `firebaseauth.configs.getHashConfig`, users still export, but the -export can't read the hash parameters. It says which permission is missing, and -the import then needs `--firebase-signer-key`, `--firebase-salt-separator`, -`--firebase-rounds` and `--firebase-mem-cost` (from **Authentication → Users → -⋮ → Password hash parameters** in the Firebase console). - -Without `--service-account` you are prompted for it, the way `export supabase` -prompts for its connection string. The answer can be a path to the downloaded -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 -appears in output. - -Firebase's scrypt is a modified variant, so a digest is worthless without the -project's four hash parameters. The export **reads them from the project** and -saves them in the export file's envelope, so the import needs nothing more: - -``` -Password hash parameters -Read from the project and saved in the export file, so the import needs nothing more. -``` - -Reading the config needs a broader role than listing users, so if it is denied -the export still succeeds and points at **Authentication → Users → (⋮) → -Password hash parameters** instead. An export with no password hashes says so -and asks for nothing. - -A user whose hash is present but whose salt is not (or the reverse) has both -dropped: half a credential produces a user nobody can sign in as. - -`FIREBASE_AUTH_EMULATOR_HOST` is honoured, so this works against the local -Firebase emulator as well as production. The target line names the emulator -when it is set. - -**No `firebase-admin`.** The spike the plan called for was run and _passed_ — a -compiled binary can import the SDK and complete `listUsers`, so the known -Firestore-under-compile bug does not reach the Auth Admin surface. It was still -not adopted: the SDK is 74 MB across 158 packages, including Firestore and -Cloud Storage, which would roughly double the ~62 MB binary every user -downloads, to serve one subcommand. What it does here is two REST calls and an -RS256 JWT, and Bun's Web Crypto signs RS256 with no dependency at all. - -#### Auth0 credentials - -Needs a machine-to-machine application with the `read:users` scope -(Applications → APIs → Auth0 Management API → Machine to Machine -Applications). Resolved from flags, then `AUTH0_DOMAIN` / `AUTH0_CLIENT_ID` / -`AUTH0_CLIENT_SECRET`, then a prompt. In agent mode a prompt is impossible, so -it exits naming **every** missing credential at once rather than one per run. - -Auth0 pages this endpoint only through the first **1000** users. Past that the -export stops and says so, pointing at Auth0's bulk export job — silently -returning the first thousand would read as "that is everyone". It still exits -0, and `--json` carries `truncated: true`. A tenant of exactly 1000 is -complete, and gets no warning. The bulk job's NDJSON file imports as it is. - -A credential the platform rejects (400, 401 or 403) is asked for again at a -terminal. An outage, a `429` or a refused connection is not: another -credential would not fix it, and it exits 1. - -#### WorkOS credentials - -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; agent mode exits naming both instead. - -There is no `--db-url` sibling because there is no database to point it at. -WorkOS is API-only: apps commonly mirror users into their own store through -webhooks, but that mirror is a derived copy holding no credentials, so the -User Management API is the only source. Pagination is cursor-based, so unlike -Auth0 there is no record ceiling — `after` runs to the end of the tenant. - -**`--with-identities` is off by default, and it is not free.** WorkOS has no -bulk endpoint for OAuth identities, so it is one request per user: ten requests -becomes 1,010 for a thousand users. Nothing it returns can be imported — -`POST /v1/users` has no external-accounts field — so it buys a provider -breakdown in the coverage report, and an `identities` array kept in the export -file for whoever runs the migration. The interactive path asks once, after the -user count is known, defaulting to no; agent mode takes the flag's answer and -asks nothing. `-y` answers the question `yes`, so pass -`--no-with-identities` to skip the fan-out without being asked. - -The breakdown prints as its own **OAuth providers** block under the coverage -table, not as extra coverage rows: - -``` -Field coverage - ✓ 6/6 have an email address - ✗ 0/6 have a password (WorkOS returns none) - -OAuth providers - GoogleOAuth 2 users - MicrosoftOAuth 1 user - no OAuth provider 2 users - not readable 1 user - Those users have no `identities` field in the export, rather than an empty one. -``` - -Separate because the two kinds of row do not mean the same thing. A coverage -row is "N of the M users have this field"; a provider row has no such -denominator — one user holding two providers is counted under both, so the -counts can sum past the user count, and `not readable` is not a property of the -user at all. - -A lookup that fails is counted on its own row rather than folded into -`no OAuth provider`. "Lookup failed" and "has no providers" are different -facts, and flattening the first into the second would understate social -sign-in. - -Non-interactive runs get progress on stderr every 500 users during the fan-out, -and every 10 pages during the user fetch. `withSpinner` hands a no-op to -anything that is not a TTY, so without this an agent exporting a large tenant -would see nothing at all until the run finished. - ### `clerk migrate import` Reads an exported user file, maps it onto Clerk's user schema, checks every @@ -547,52 +174,50 @@ user against the destination instance, and creates them through the Backend API. ```sh -clerk migrate import 20260929-141502-a1b2 --dry-run # check, write nothing -clerk migrate import 20260929-141502-a1b2 --yes # an export run -clerk migrate import users.json --source clerk --yes # any other file -clerk migrate import # a human is asked +clerk migrate import users.json --source supabase --dry-run # check, write nothing +clerk migrate import users.json --source supabase --yes # import +clerk migrate import users.json --source clerk --allow-partial --yes +clerk migrate import users.json --source clerk --json --yes +clerk migrate import users.json --source clerk --require-password --yes +clerk migrate import users.json --source clerk --skip-legal-checks --yes +clerk migrate import users.json --source clerk --runs-dir ./runs --yes +clerk migrate import users.json --source clerk --app app_123 --instance prod --yes +clerk migrate import users.json --source clerk --secret-key sk_test_... -y +clerk migrate import # a human is asked ``` -| Flag | Description | -| --------------------------------------- | ------------------------------------------------------------------- | -| `[file\|export-run-id]` | The export file, or the ID of the export run that wrote it | -| `--source ` | Where the file came from: a [source](#sources), or one you wrote | -| `--dry-run` | Run the [checks](#checks) against the instance, and write nothing | -| `--allow-partial` | Import the users that pass, and record the rest as skipped | -| `--new-run` | Start a new run instead of [continuing](#re-running) an earlier one | -| `--require-password` | Import only users that carry a password digest | -| `--skip-legal-checks` | Import users with no legal acceptance into an instance requiring it | -| `--firebase-signer-key ` | Firebase base64 signer key (overrides the export file) | -| `--firebase-salt-separator ` | Firebase base64 salt separator | -| `--firebase-rounds ` | Firebase scrypt rounds | -| `--firebase-mem-cost ` | Firebase scrypt memory cost | -| `-y, --yes` | Import without prompting | -| `--json` | Output as JSON. Never prompts, so importing needs `--yes` | -| `--runs-dir ` | Where runs are kept (see [the run store](#the-run-store)) | +| Flag | Description | +| --------------------- | ------------------------------------------------------------------- | +| `[file]` | A JSON or CSV export | +| `--source ` | Where the file came from: one of the [sources](#sources) | +| `--dry-run` | Run the [checks](#checks) against the instance, and write nothing | +| `--allow-partial` | Import the users that pass, and record the rest as skipped | +| `--require-password` | Import only users that carry a password digest | +| `--skip-legal-checks` | Import users with no legal acceptance into an instance requiring it | +| `-y, --yes` | Import without prompting | +| `--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 `--instance`. -An export run ID stands for the file that run wrote, and the import records it -as `fromExport`. A file `clerk migrate export` wrote carries its source, so it -needs no `--source`, and a `--source` that contradicts it exits 2. Any other -file — a bare JSON array, a CSV, Firebase's own `{ "users": [...] }` — needs -`--source`. NDJSON, one user per line (what Auth0's bulk export job writes), is -read too: always for `.ndjson` and `.jsonl`, and for a `.json` file that -doesn't parse whole. A leading BOM is ignored in JSON and CSV. +A file is a JSON array, a CSV, or NDJSON, one user per line (what Auth0's bulk +export job writes). NDJSON is read always for `.ndjson` and `.jsonl`, and for a +`.json` file that doesn't parse whole. A leading BOM is ignored in JSON and +CSV. A file that isn't valid JSON is named in the error. **What a human is asked, and what an agent is told.** A human at a terminal who -leaves out the file is asked for its path, and is asked for a source only when -the file does not name one. An agent, a non-TTY run, or `--json` without the -file exits 2 naming what to pass. +leaves out the file is asked for its path, and then for its source. An agent, a +non-TTY run, or `--json` without the file or `--source` exits 2 naming what to +pass. **Nothing is written without consent.** After the checks, a human is asked `Import N users?`, and declining writes nothing. `--yes` skips the question. Without either — an agent, a non-TTY run, `--json` — the run prints the checks -and exits 2 with the exact command to run. +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. -**Every run prints its target first**, then which [case](#re-running) applies, -then the checks. +**Every run prints its target first**, then the checks. Failures do not stop the run: each user's outcome is written to the [run](#the-run-store) and the import continues. A `429` backs off — @@ -603,38 +228,21 @@ failed. An **unrecognized password hasher** aborts the whole run before anything is sent, because it would import credentials nobody can sign in with. -`--json` returns `{ target, run, resume, checks, result }`. - -#### Re-running - -Running the same import again continues where it left off. The match is the -file's sha256, the source (and a custom source's content hash), and the -instance ID; the latest matching import run decides what happens: - -| Latest match | Re-running does | -| ----------------------------------------- | ---------------------------------------------------------------------- | -| none | a new run | -| interrupted (dead lock or no finish time) | continues the same run, skipping the users it created | -| `partial` | continues the same run, retrying the users that failed or were skipped | -| `complete` | nothing: prints "Already imported in run …" and exits 0 | -| `undone` | a new run | -| has an undo that did not finish | exits 2, naming the `clerk migrate undo` that finishes it | - -`--new-run` skips the lookup. A run another live process holds exits 2. - -A continued run also finishes what the last one left open: - -- A user still `creating` is looked up by `external_id`. One Clerk holds is - adopted as `created`, and not created again; one it doesn't is created. -- A user whose `created` line has `pending` identifiers gets just those - attaches. - `--require-password` records each user it leaves out as `skipped`, so the run ends `partial`. -When an import completes, it names the folders it no longer needs: the export it -read, which holds your users' data, and its own run, which only `undo` needs. -Each comes with the `rm -rf` to remove it. +`--json` returns `{ target, run, 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`, with +`run: null`. + +#### Re-running + +Running the same import again starts a new run. The users the first run +created are rejected as [already in the instance](#checks), so +`--allow-partial --yes` imports the rest. A user an interrupted run left at +`creating` is rejected the same way when Clerk holds it, and imported when it +doesn't. #### Checks @@ -644,16 +252,11 @@ after them. They sort the users three ways: - **Rejected** — users Clerk would refuse. Each gets the first reason that applies: - it failed schema validation - - its source requested a skip (Better Auth: an anonymous guest; Supabase: a - soft-deleted user) + - its source requested a skip (Supabase: a soft-deleted user) - 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 any other user, with a warning - - its source ID, email, phone or username repeats an earlier user in the file - that passes the other checks. 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: …`) - it lacks an identifier the instance requires. An email or phone counts only when it is verified, because an unverified one is attached after the user exists @@ -675,10 +278,13 @@ after them. They sort the users three ways: - Supabase: its only providers are ones Clerk has off, or doesn't offer at all (Figma, Kakao, Keycloak, WorkOS, Zoom, Fly). The checks offer to turn on the first kind; nothing can turn on the second + - its source ID, email, phone or username repeats an earlier user in the file + that passes the checks above. 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 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 + the scheduler) - a development instance: it is past the 100-user headroom (`CLERK_MIGRATE_DEV_USER_LIMIT` when Clerk raised it), counted in file order @@ -686,7 +292,7 @@ after them. They sort the users three ways: set up to store, fields Clerk has no place for (`Clerk won't 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, URL or - HTML: Better Auth's phone sign-up stores the number as the name). + HTML). - **Imported** — everyone else. Any reject stops the import, and it exits 2 with the command that adds @@ -721,7 +327,8 @@ The fixes are offers, not corrections: an instance that requires an email is configured as its owner intended, and fixing the export may be the answer. When the instance settings cannot be read (BAPI `/v1/domains` → the instance's Frontend API `/v1/environment`), required fields are not checked and the run -says so. +says so. When a development instance's user count cannot be read, the run says +so too, rather than checking the headroom against zero. #### Sign-up restrictions @@ -736,7 +343,7 @@ additional verified identifier, and every unverified one, is attached afterwards with its own request, ahead of any create still queued, and backs off on a `429` like the create. A refusal there is logged and the user still counts as imported — a duplicate secondary email should not undo an otherwise -successful user. An attach with no answer stays `pending` for a re-run. +successful user. An attach with no answer stays `pending` on the user's line. The first phone gets the same treatment when Clerk refuses it — a country the instance does not support, or a number that is not E.164 — and the user has an @@ -767,100 +374,10 @@ Users that do exceed the limit come back in the error breakdown as `You have reached your limit of N users`, annotated with what a development instance can do about it. -### `clerk migrate runs` - -`runs` reads the [run store](#the-run-store). - -```sh -clerk migrate runs # every run, newest first -clerk migrate runs 20260929-141502-a1b2 # one run in full -clerk migrate runs --json -``` - -| Flag | Description | -| ------------------- | ----------------------------- | -| `[run-id]` | Show one run instead of all | -| `--json` | The same data, on stdout | -| `--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 -users that failed or were skipped, with the path to the full record. An unknown -ID exits 2. - -### `clerk migrate undo` - -Deletes the users an import run created. The import run is the whole record of -what to delete: every source ID whose latest line is `created`, by the Clerk ID -recorded beside it. - -The one search is for a source ID whose latest line is `creating`: the run -stopped with that user's `POST /v1/users` sent and unanswered, so Clerk may -hold the user without its ID on record. Those are looked up by `external_id`. -The import's checks refused any source ID the instance already held, but a -later import of the same source IDs could have created one since. So a user -that another import run in the runs folder records as created is left out. - -```sh -clerk migrate undo 20260929-141502-a1b2 --dry-run # preview, delete nothing -clerk migrate undo 20260929-141502-a1b2 # confirms first -clerk migrate undo 20260929-141502-a1b2 --yes # no prompt -``` - -| Flag | Description | -| ------------------- | -------------------------------------------------------- | -| `` | The import run to undo | -| `--dry-run` | Show the preview and delete nothing | -| `-y, --yes` | Delete without prompting | -| `--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`. - -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`). 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. - -It refuses with exit 2, and deletes nothing, when: - -- the resolved key addresses a different instance than the run imported into - (the error names both) -- the run is an export or undo run -- the run has already been undone - -Deletes go through the same scheduler and `429` backoff as the import. A user -already gone from the instance counts as deleted. The undo is a run of its own, -`kind: "undo"` with `undoes: `. The import is marked `undone` only when -every user is deleted. A partial undo exits 1, and running `undo` again retries -the users that failed, in the same undo run. - -### `clerk migrate sources` - -```sh -clerk migrate sources # every source, with what it carries -clerk migrate sources betterauth # one source in full -clerk migrate sources ./my-source.ts # a source you wrote -clerk migrate sources --json -``` - -| Flag | Description | -| ---------- | ---------------------------------------------------------------- | -| `[source]` | A built-in key, or the path to a source you wrote, to show fully | -| `--json` | The same data, on stdout | - -`sources` alone prints the table above. `sources ` shows one source in -full: its export command, what it carries with a note for each, where each -field lands (`encrypted_password → password`), its fixed defaults, and any -caveats. An unknown key exits 2 and lists the valid ones. There is no -intro/outro gutter: this reads a static registry rather than running anything. - ### `clerk migrate help` -`clerk migrate help` and `clerk migrate --help` print the help for the -group or one command, with examples. `clerk migrate help ` does the -same. +`clerk migrate help` and `clerk migrate import --help` print the help for the +group or the command, with examples. ## Sources @@ -868,125 +385,24 @@ A source maps one platform's export onto Clerk's user schema, and says what it brings across. Adding a platform is one file in `sources/` plus one line in `sources/registry.ts`; `--source`'s tab-completion reads from that array. -| Key | Reads | Passwords | MFA | Metadata | -| ------------ | ----------------------------- | --------- | ------- | -------- | -| `clerk` | Clerk Dashboard export | partial | partial | partial | -| `auth0` | Auth0 Export Users API | partial | no | yes | -| `authjs` | Auth.js / NextAuth user table | no | no | no | -| `betterauth` | Better Auth export | yes | no | no | -| `firebase` | `firebase auth:export` | yes | no | no | -| `supabase` | Supabase `auth.users` export | yes | no | partial | -| `workos` | WorkOS User Management API | no | no | yes | +| Key | Reads | Passwords | MFA | Metadata | +| ---------- | ---------------------------- | --------- | ------- | -------- | +| `clerk` | Clerk Dashboard export | partial | partial | partial | +| `supabase` | Supabase `auth.users` export | yes | no | partial | + +An unknown `--source` exits 2 with the list of valid keys. There is no column for social sign-ins, because no source copies them and none -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 +needs to. 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). -### `--source` - -`clerk migrate import` takes `--source `: - -- 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. - -A file `clerk migrate export` wrote names its own source, so it needs none. - -### Custom sources - -Migrating from a platform with no built-in, without recompiling the CLI: - -```sh -clerk migrate import users.json --source ./my-platform.ts -``` - -The file lives in **your** project, not in the CLI, and is imported at runtime. -It exports the same shape the built-ins use — plain data, no imports, since -there is nothing in a compiled binary for your file to import from: - -```ts -export default { - key: "myplatform", - label: "My Platform", - description: "Exports from My Platform's admin console.", - transformer: { - account_ref: "userId", // required: becomes the Clerk user's external_id - contact_email: "email", - given: "firstName", - family: "lastName", - pw_bcrypt: "password", - }, - carries: { - passwords: { level: "yes", note: "bcrypt hashes from the pw_bcrypt column." }, - mfa: { level: "no", note: "Not exported." }, - metadata: { level: "no", note: "Not exported." }, - }, - defaults: { passwordHasher: "bcrypt" }, - postTransform: (user) => { - if (!user.firstName) delete user.firstName; - }, -}; -``` - -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. -Plain `.js` works too. - -An import run records a custom source's key and a hash of the file, so an -edited source counts as a different source. - -#### Validation - -The file is code the CLI executes, so its shape is checked before use and -rejected with the specific problem rather than crashing mid-pipeline: - -| Problem | Message | -| ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| Path does not exist | `No source file at /abs/path.ts.` | -| No default export, but a named one | ``has no default export. Found named export `myPlatform` — did you mean `export default`?`` | -| Does not parse | `Could not load ./f.ts: Expected identifier but found ","` | -| Nothing maps to `userId` | ``no source field maps to `userId`. Every user needs one — it becomes the Clerk user's external_id`` | -| No `carries` | `` `carries` must say what the source brings across: { passwords, mfa, metadata }, each { level, note } `` | -| `key` clashes with a built-in | `key is "clerk", which is already a built-in source` | -| A hook is not a function | `postTransform must be a function when present` | - -The `userId` check is the load-bearing one: without it the import would run to -completion and create every user with no `external_id`, which is what makes a -migration re-runnable. - ### Metadata -Metadata a user can edit on the source platform — Auth0's `user_metadata`, -Supabase's `raw_user_meta_data`, WorkOS's `metadata` — 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. Auth0's -`app_metadata` goes to `private_metadata`. - -### Better Auth passwords - -Better Auth hashes with its own scrypt by default and lets an app swap in bcrypt -or argon2, so one database can hold more than one kind. The hasher is detected -per user: - -| Stored value | Sent as | -| --------------------------- | ---------------------------------------------------------- | -| `<32 hex>:<128 hex>` | `scrypt:16384:16:1$$`, hasher `scrypt_werkzeug` | -| `$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 -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. - -A password Clerk cannot verify is **dropped, not rejected**: the user imports -without it and can sign in another way or reset it. Their run line carries -`passwordDropped: true`. +Supabase's `raw_user_meta_data`, which a user can edit on Supabase, 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. A Clerk Dashboard CSV carries no metadata. ### Verified vs unverified identifiers @@ -996,48 +412,24 @@ which style it uses. An identifier the source never confirmed is routed to field, because Clerk creates primary identifiers **verified** — sending an unconfirmed address there would silently promote it. -- **Boolean** (`auth0`, `betterauth`, `firebase`): `true`/`false`. A CSV export - stringifies these, so `"false"` is read as false, not as a non-empty string. -- **Timestamp** (`authjs`, `supabase`): a nullable confirmation time. Any real - value means verified; `""`, `null` and `\N` do not. - -### Firebase hash parameters - -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**. - -```sh -clerk migrate import users.json --source firebase -y \ - --firebase-signer-key --firebase-salt-separator \ - --firebase-rounds 8 --firebase-mem-cost 14 -``` - -All four are **required as a set** — supplying some but not all is a usage error -naming what is missing. A partial set produces a well-formed digest that -verifies against nothing, so users would import successfully and then be unable -to sign in. +- **Boolean**: `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** (`supabase`): a nullable confirmation time. Any real value + means verified; `""`, `null` and `\N` do not. -The flags are read first, then the export file's envelope, which carries the -parameters when `clerk migrate export firebase` could read them from the -project. The flags win, so a rotated key can be passed without re-exporting. -The envelope sits in the gitignored run folder, since the signer key is a -Firebase secret. - -An export with no password hashes needs no parameters at all. +A Clerk export keeps an unverified primary email or phone unverified. ## Schema fields What a source maps _onto_. Every user is validated against this schema before any request is made, so a field a source produces that is not listed here is silently dropped — Zod strips unknown keys — and never reaches Clerk. -Writing a custom source means targeting these names exactly. The schema lives in `validator.ts`; adding a platform means adding a source, not editing it. -**Required:** `userId` (`string`). It becomes the Clerk user's `external_id`, -which is what makes a migration re-runnable. +**Required:** `userId` (`string`). It becomes the Clerk user's `external_id`. **Identifiers.** At least one of these must be present, or the import's checks reject the user as invalid. Each accepts a single value or an array. @@ -1102,48 +494,24 @@ stamping every user with today's. ## API Endpoints -| Method | Path | Used by | -| -------- | ----------------------------------------------- | ------------------------------------------------------------------------------- | -| `POST` | `/v1/users` | `migrate import` — creates each user | -| `POST` | `/v1/email_addresses` | `migrate import` — attaches additional emails | -| `POST` | `/v1/phone_numbers` | `migrate import` — attaches additional phones | -| `GET` | `/v1/users?limit=&offset=&order_by=+created_at` | `migrate export clerk` — pages the whole instance, oldest first, 500 at a time | -| `GET` | `/v1/users/count` | `migrate import` — headroom against a development instance's user limit | -| `GET` | `/v1/users?external_id=…` | `migrate import` — checks for users already in the instance, 100 values a call | -| `GET` | `/v1/users?external_id=…` | `migrate undo` — finds users whose create was in flight when the import stopped | -| `GET` | `/v1/users?user_id=…` | `migrate undo` — reads the imported users back, 100 a call | -| `DELETE` | `/v1/users/{user_id}` | `migrate undo` — deletes one user | -| `GET` | `/v1/instance` | `migrate import`, `undo`, `export clerk` — names the instance behind the key | -| `GET` | `/v1/domains` | `migrate import` checks — resolves the Frontend API host | +| Method | Path | Used by | +| ------ | ------------------------- | ------------------------------------------------------------------------------ | +| `POST` | `/v1/users` | `migrate import` — creates each user | +| `POST` | `/v1/email_addresses` | `migrate import` — attaches additional emails | +| `POST` | `/v1/phone_numbers` | `migrate import` — attaches additional phones | +| `GET` | `/v1/users/count` | `migrate import` — headroom against a development instance's user limit | +| `GET` | `/v1/users?external_id=…` | `migrate import` — checks for users already in the instance, 100 values a call | +| `GET` | `/v1/instance` | `migrate import` — names the instance behind the key | +| `GET` | `/v1/domains` | `migrate import` checks — resolves the Frontend API host | The checks also read the instance's Frontend API `GET /v1/environment` (bootstrapping a dev browser first on development instances) for its attributes and enabled social providers. Nothing in `clerk migrate` writes instance settings: the checks print the `clerk config patch` to run instead. -Three exports talk to their own platform rather than to Clerk: - -| Method | Path | Used by | -| ------ | ---------------------------------------------- | -------------------------------------------------------- | -| `POST` | `https:///oauth/token` | `export auth0` — Management API access token | -| `GET` | `https:///api/v2/users` | `export auth0` — 100 per page, 1000 users maximum | -| `POST` | `https://oauth2.googleapis.com/token` | `export firebase` — RS256 assertion → access token | -| `GET` | `…/v1/projects/{project_id}/accounts:batchGet` | `export firebase` — pages users, 1000 at a time | -| `GET` | `…/admin/v2/projects/{project_id}/config` | `export firebase` — reads the scrypt hash parameters | -| `GET` | `…/user_management/users` | `export workos` — 100 per page, cursor-paginated | -| `GET` | `…/user_management/users/{id}/identities` | `export workos` — `--with-identities` only, one per user | - -The two Identity Toolkit paths are on `identitytoolkit.googleapis.com`, or on -`FIREBASE_AUTH_EMULATOR_HOST` when that is set. The two WorkOS paths are on -`api.workos.com`. - -The three database exports (`supabase`, `authjs`, `betterauth`) make no HTTP -calls at all — they connect over `--db-url`. - ## Notes -- `userId` in the source file becomes the Clerk user's `external_id`. That is - what makes a migration re-runnable and reversible. +- `userId` in the source file becomes the Clerk user's `external_id`. - 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. diff --git a/packages/cli-core/src/commands/migrate/index.test.ts b/packages/cli-core/src/commands/migrate/index.test.ts index 10a636f8e..5eb433a85 100644 --- a/packages/cli-core/src/commands/migrate/index.test.ts +++ b/packages/cli-core/src/commands/migrate/index.test.ts @@ -1,12 +1,21 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { getMode, setMode } from "../../mode.ts"; import { createProgram } from "../../cli-program.ts"; -import { exportPlatformKeys } from "./export/registry.ts"; import { isAssumeYes, setAssumeYes } from "./lib/assume-yes.ts"; +let saved: string | undefined; +beforeAll(() => { + 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)) { @@ -22,139 +31,22 @@ describe("registerMigrate", () => { expect(migrate?.description()).toContain("Migrate users"); }); - test("registers the import subcommand", () => { - expect(findCommand(["migrate", "import"])).toBeDefined(); + test("registers import as its only subcommand", () => { + expect(findCommand(["migrate"])?.commands.map((cmd) => cmd.name())).toEqual(["import"]); }); - // The direction is never implied: `import` and `export` are siblings, so a - // default would make one of them the meaning of the bare group name. test("leaves migrate with no default subcommand", () => { const migrate = findCommand(["migrate"]) as unknown as { _defaultCommandName?: string }; expect(migrate._defaultCommandName).toBeFalsy(); }); - test.each([ - "--source", - "--dry-run", - "--allow-partial", - "--new-run", - "--require-password", - "--json", - "--firebase-signer-key", - "--firebase-salt-separator", - "--firebase-rounds", - "--firebase-mem-cost", - "--yes", - "--secret-key", - "--app", - "--instance", - ])("migrate import accepts %s", (flag) => { - const flags = findCommand(["migrate", "import"])?.options.map((option) => option.long); - expect(flags).toContain(flag); - }); - - test("registers sources with an optional source and --json", () => { - const sources = findCommand(["migrate", "sources"]); - expect(sources?.registeredArguments[0]?.required).toBe(false); - expect(sources?.options.map((option) => option.long)).toEqual(["--json"]); - }); - - test.each([[["transformers"]], [["transformers", "list"]]])("no longer registers %p", (names) => { - expect(findCommand(["migrate", ...names])).toBeUndefined(); - }); - - test.each([ - "--transformer", - "--transformer-file", - "--file", - "--resume-after", - "--skip-unsupported-providers", - ])("migrate import drops %s", (flag) => { - expect(findCommand(["migrate", "import"])?.options.map((o) => o.long)).not.toContain(flag); - }); - - test.each([ - [["export"]], - [["export", "clerk"]], - [["export", "auth0"]], - [["export", "supabase"]], - [["export", "authjs"]], - [["export", "betterauth"]], - [["export", "firebase"]], - ])("registers migrate %p", (names) => { - expect(findCommand(["migrate", ...names])).toBeDefined(); - }); - - // Bare `migrate export` runs the picker rather than defaulting to a - // platform, so nobody exports from the wrong place by pressing enter. - test("leaves export with no default subcommand", () => { - const group = findCommand(["migrate", "export"]) as unknown as { - _defaultCommandName?: string; - }; - expect(group._defaultCommandName).toBeFalsy(); - }); - - test("registers an export subcommand per registered platform", () => { - const registered = findCommand(["migrate", "export"])?.commands.map((c) => c.name()); - for (const key of exportPlatformKeys()) expect(registered).toContain(key); - }); - - test.each(["--output", "--secret-key", "--app", "--instance"])( - "export clerk accepts %s", - (flag) => { - expect(findCommand(["migrate", "export", "clerk"])?.options.map((o) => o.long)).toContain( - flag, - ); - }, - ); - - test.each(["--domain", "--client-id", "--client-secret", "--output"])( - "export auth0 accepts %s", - (flag) => { - expect(findCommand(["migrate", "export", "auth0"])?.options.map((o) => o.long)).toContain( - flag, - ); - }, - ); - - test.each(["supabase", "authjs", "betterauth"])("export %s accepts --db-url", (platform) => { - expect(findCommand(["migrate", "export", platform])?.options.map((o) => o.long)).toContain( - "--db-url", - ); - }); - - test("export firebase accepts --service-account", () => { - expect(findCommand(["migrate", "export", "firebase"])?.options.map((o) => o.long)).toContain( - "--service-account", - ); - }); - - test.each(exportPlatformKeys())( - "migrate export %s names the run folder as the default output", - (platform) => { - const output = findCommand(["migrate", "export", platform])?.options.find( - (option) => option.long === "--output", - ); - expect(output?.description).toContain("instead of the run folder"); - }, - ); - - test.each(exportPlatformKeys())("migrate export %s accepts --json", (platform) => { - const flags = findCommand(["migrate", "export", platform])?.options.map((o) => o.long); - expect(flags).toContain("--json"); - }); - - test("registers runs with an optional run ID", () => { - const runs = findCommand(["migrate", "runs"]); - expect(runs?.registeredArguments[0]?.required).toBe(false); - expect(runs?.options.map((option) => option.long)).toEqual(["--json", "--runs-dir"]); - }); - - test("registers undo with a required run ID and its flags", () => { - const undo = findCommand(["migrate", "undo"]); - expect(undo?.registeredArguments[0]?.required).toBe(true); - expect(undo?.options.map((option) => option.long)).toEqual([ + test("migrate import takes exactly its flags", () => { + expect(findCommand(["migrate", "import"])?.options.map((option) => option.long)).toEqual([ + "--source", "--dry-run", + "--allow-partial", + "--require-password", + "--skip-legal-checks", "--yes", "--json", "--secret-key", @@ -164,58 +56,38 @@ describe("registerMigrate", () => { ]); }); - test("the logs group is gone: runs replaces it", () => { - expect(findCommand(["migrate", "logs"])).toBeUndefined(); - }); - - // Every command reads or writes the run store, so each one can be pointed - // somewhere else. - test.each([ - [["import"]], - [["runs"]], - [["undo"]], - [["export"]], - ...exportPlatformKeys().map((platform) => [["export", platform]]), - ])("migrate %p accepts --runs-dir", (names) => { - expect(findCommand(["migrate", ...names])?.options.map((option) => option.long)).toContain( - "--runs-dir", - ); - }); - - // It also takes a path, so it cannot use `.choices()`: completion offers the - // built-in keys through `KNOWN_OPTION_VALUES` instead. - test("--source accepts any value, so a path to a source you wrote gets through", () => { + // No `.choices()`: an unknown key reaches resolveSource, whose error lists + // the valid ones. Completion offers them through `KNOWN_OPTION_VALUES`. + test("--source accepts any value", () => { const option = findCommand(["migrate", "import"])?.options.find((o) => o.long === "--source"); expect(option?.argChoices).toBeUndefined(); }); - test("takes the file, or the export run that wrote it, as an optional argument", () => { + test("takes the file as an optional argument", () => { const [argument] = findCommand(["migrate", "import"])?.registeredArguments ?? []; - expect(argument?.name()).toBe("file|export-run-id"); + expect(argument?.name()).toBe("file"); expect(argument?.required).toBe(false); }); - test.each([["-y", "--yes"]])("exposes %s as the short form of %s", (short, long) => { - const option = findCommand(["migrate", "import"])?.options.find((o) => o.long === long); - expect(option?.short).toBe(short); - }); - - // Every export takes `-y`: it is what turns the credential-retry loop off, - // and the loop is on every one of them. - test.each(exportPlatformKeys())("migrate export %s accepts --yes", (platform) => { - const flags = findCommand(["migrate", "export", platform])?.options.map((o) => o.long); - expect(flags).toContain("--yes"); + test("exposes -y as the short form of --yes", () => { + const option = findCommand(["migrate", "import"])?.options.find((o) => o.long === "--yes"); + expect(option?.short).toBe("-y"); }); }); /** - * The hook is the only link between the parsed flag and the two places that - * read it, three layers down. If it stopped firing — a Commander upgrade that - * dropped hook inheritance, an action registered outside the group — both - * behaviours would silently revert and every unit test around them would still - * pass, because they set the flag directly. + * The hook is the only link between the parsed flag and the code that reads + * it below the action. If it stopped firing — a Commander upgrade that dropped + * hook inheritance, an action registered outside the group — the behaviour + * would silently revert and every unit test around it would still pass, + * because they set the flag directly. */ describe("the migrate group's -y hook", () => { + let missing: string; + beforeAll(() => { + missing = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-hook-")), "none.json"); + }); + async function parse(argv: string[]) { const program = createProgram(); // `exitOverride` so a usage error inside the action throws here instead of @@ -229,8 +101,8 @@ describe("the migrate group's -y hook", () => { return isAssumeYes(); } - test("records -y on an export", async () => { - expect(await parse(["migrate", "export", "supabase", "-y", "--db-url", "./none.sqlite"])).toBe( + test("records -y on import", async () => { + expect(await parse(["migrate", "import", missing, "-y", "--secret-key", "sk_test_x"])).toBe( true, ); }); @@ -238,25 +110,10 @@ describe("the migrate group's -y hook", () => { // `--json` means nobody reads a prompt, and agent mode is how every prompt in // this tree already knows to stand down. test("--json runs the command in agent mode", async () => { - const original = getMode(); - const runsDir = fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-json-")); - try { - setMode("human"); - await parse(["migrate", "runs", "--json", "--runs-dir", runsDir]); - expect(getMode()).toBe("agent"); - } finally { - setMode(original); - fs.rmSync(runsDir, { recursive: true, force: true }); - } - }); - - // The export group declares --json too, and takes the flag; the hook has to - // read the group's options as well as the subcommand's. - test("--json on an export subcommand runs it in agent mode", async () => { const original = getMode(); try { setMode("human"); - await parse(["migrate", "export", "supabase", "--json", "--db-url", "./none.sqlite"]); + await parse(["migrate", "import", missing, "--json", "--secret-key", "sk_test_x"]); expect(getMode()).toBe("agent"); } finally { setMode(original); @@ -265,6 +122,6 @@ describe("the migrate group's -y hook", () => { test("records its absence, so a previous run cannot leak into this one", async () => { setAssumeYes(true); - expect(await parse(["migrate", "export", "supabase", "--db-url", "./none.sqlite"])).toBe(false); + expect(await parse(["migrate", "import", missing, "--secret-key", "sk_test_x"])).toBe(false); }); }); diff --git a/packages/cli-core/src/commands/migrate/index.ts b/packages/cli-core/src/commands/migrate/index.ts index 7e29975dc..8440b281e 100644 --- a/packages/cli-core/src/commands/migrate/index.ts +++ b/packages/cli-core/src/commands/migrate/index.ts @@ -1,61 +1,59 @@ import type { Program } from "../../cli-program.ts"; -import { parseIntegerOption } from "../../lib/option-parsers.ts"; +import { isExperimentEnabled, requireExperiment } from "../../lib/experimental.ts"; import { setMode } from "../../mode.ts"; import { setAssumeYes } from "./lib/assume-yes.ts"; -import { registerMigrateExport } from "./export/index.ts"; import { RUNS_DIR_DESCRIPTION, RUNS_DIR_FLAG } from "./lib/run-store.ts"; import { run } from "./run.ts"; -import { runs } from "./runs.ts"; -import { undo } from "./undo.ts"; -import { list as sources } from "./sources/list.ts"; -const migrate = { run, runs, undo, sources }; +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") .setExamples([ { - command: "clerk migrate export supabase", - description: "Export users from Supabase into a new run", + command: "clerk migrate import users.json --source supabase --dry-run", + description: "Check a Supabase export against the instance, and write nothing", }, { - command: "clerk migrate import 20260929-141502-a1b2 --dry-run", - description: "Check an export against the instance, and write nothing", - }, - { - command: "clerk migrate import 20260929-141502-a1b2 --yes", + command: "clerk migrate import users.json --source supabase --yes", description: "Import it", }, - { command: "clerk migrate runs", description: "List every migration run" }, - { - command: "clerk migrate undo 20260929-141502-a1b2", - description: "Delete the users an import created", - }, - { command: "clerk migrate sources", description: "What each source brings across" }, ]); - // `-y` is read several layers down — by the credential-retry loop and the - // export commands — so it is resolved once here rather than threaded - // through every export handler. Hooks are inherited, so this fires for every + // `-y` is read below the action, so it is resolved once here rather than + // threaded through every call. Hooks are inherited, so this fires for every // subcommand under `migrate`; one that declares no `-y` resolves to false. // // `--json` means nobody is reading a prompt, so it runs the command in agent // mode: every prompt in this tree already stands down for an agent, with the // usage error naming what to pass instead. migrateCommand.hook("preAction", (_thisCommand, actionCommand) => { - // With globals: `export auth0 --json` lands on the export group's own - // --json, which the subcommand's opts() never sees. const opts = actionCommand.optsWithGlobals(); setAssumeYes(Boolean(opts.yes)); if (opts.json) setMode("agent"); }); - // Named, not `isDefault`. `import` and `export` are the two directions this - // group moves users in, and neither is implied by the bare group name — a - // default would make `clerk migrate --file users.json` mean "import" while - // its sibling has to be spelled out. Bare `clerk migrate` prints help. + // Named, not `isDefault`: bare `clerk migrate` prints help. // // The flags stay here rather than on `migrate`, matching how `config` keeps // its own on `pull`/`patch`/`put` — a group's help is a list of subcommands @@ -63,27 +61,15 @@ export function registerMigrate(program: Program): void { migrateCommand .command("import") .description("Import users from an exported JSON or CSV file") - .argument("[file|export-run-id]", "The export file, or the ID of the export run that wrote it") - .option( - "--source ", - "Where the file came from: a built-in source, or a source you wrote. Not needed for a file from `clerk migrate export`", - ) + .argument("[file]", "A JSON or CSV export") + .option("--source ", "Where the file came from") .option("--dry-run", "Check the file against the instance, report, and write nothing") .option("--allow-partial", "Import the users that pass the checks, and skip the rest") - .option("--new-run", "Start a new run instead of continuing an earlier one of this file") .option("--require-password", "Import only users that have a password") .option( "--skip-legal-checks", "Import users with no legal acceptance on record into an instance that requires it", ) - .option("--firebase-signer-key ", "Firebase base64 signer key (overrides the export file)") - .option("--firebase-salt-separator ", "Firebase base64 salt separator") - .option("--firebase-rounds ", "Firebase scrypt rounds", (value: string) => - parseIntegerOption(value, "--firebase-rounds", { min: 1 }), - ) - .option("--firebase-mem-cost ", "Firebase scrypt memory cost", (value: string) => - parseIntegerOption(value, "--firebase-mem-cost", { min: 1 }), - ) .option("-y, --yes", "Import without prompting") .option("--json", "Output as JSON; never prompts, so pair it with --yes to import") .option("--secret-key ", "Backend API secret key to use") @@ -92,92 +78,18 @@ export function registerMigrate(program: Program): void { .option(RUNS_DIR_FLAG, RUNS_DIR_DESCRIPTION) .setExamples([ { - command: "clerk migrate import 20260929-141502-a1b2 --dry-run", - description: "Check what an export run wrote, and write nothing", - }, - { - command: "clerk migrate import 20260929-141502-a1b2 --yes", - description: "Import it. Run it again to continue after a failure", + command: "clerk migrate import users.json --source supabase --dry-run", + description: "Check the file against the instance, and write nothing", }, { command: "clerk migrate import users.json --source clerk --allow-partial --yes", description: "Import a Clerk Dashboard export, skipping users that would be rejected", }, - { - command: "clerk migrate import users.json --source ./my-source.ts --yes", - description: "Import with a source you wrote", - }, ]) .action(async (input, _opts, cmd) => - migrate.run({ - ...(cmd.optsWithGlobals() as Parameters[0]), + run({ + ...(cmd.optsWithGlobals() as Parameters[0]), ...(input ? { input } : {}), }), ); - - registerMigrateExport(migrateCommand); - - // Flat, not under a noun group: this is the one command in the tree that - // destroys data in Clerk, and it is worth keeping short and prominent. - migrateCommand - .command("undo") - .description("Delete the users an import run created") - .argument("", "The import run to undo (see `clerk migrate runs`)") - .option("--dry-run", "Show what would be deleted, and delete nothing") - .option("-y, --yes", "Delete without prompting") - .option("--json", "Output as JSON; never prompts, so pair it with --yes to delete") - .option("--secret-key ", "Backend API secret key to use") - .option("--app ", "Application ID to target (works from any directory)") - .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") - .option(RUNS_DIR_FLAG, RUNS_DIR_DESCRIPTION) - .setExamples([ - { - command: "clerk migrate undo 20260929-141502-a1b2 --dry-run", - description: "Preview what would be deleted", - }, - { - command: "clerk migrate undo 20260929-141502-a1b2 --yes", - description: "Delete without prompting", - }, - ]) - .action(async (runId, _opts, cmd) => - migrate.undo(runId, cmd.optsWithGlobals() as Parameters[1]), - ); - - migrateCommand - .command("runs") - .description("List migration runs, or show one") - .argument("[run-id]", "A run to show in full") - .option("--json", "Output as JSON") - .option(RUNS_DIR_FLAG, RUNS_DIR_DESCRIPTION) - .setExamples([ - { 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", - }, - { command: "clerk migrate runs --json", description: "Machine-readable listing" }, - ]) - .action(async (runId, _opts, cmd) => - migrate.runs(runId, cmd.optsWithGlobals() as Parameters[1]), - ); - - // A compiled binary has no source tree to grep, so the available mappings - // need a command rather than only appearing in the interactive picker. - migrateCommand - .command("sources") - .description("List the sources an import can read, or show one in full") - .argument("[source]", "A built-in source, or the path to a source you wrote") - .option("--json", "Output as JSON") - .setExamples([ - { command: "clerk migrate sources", description: "What each source brings across" }, - { - command: "clerk migrate sources betterauth", - description: "Where each field lands, how to export, and caveats", - }, - { command: "clerk migrate sources ./my-source.ts", description: "Check a source you wrote" }, - ]) - .action(async (source, _opts, cmd) => - migrate.sources(source, cmd.optsWithGlobals() as Parameters[1]), - ); } diff --git a/packages/cli-core/src/commands/migrate/readme.test.ts b/packages/cli-core/src/commands/migrate/readme.test.ts index b64b5a1fb..05faefc31 100644 --- a/packages/cli-core/src/commands/migrate/readme.test.ts +++ b/packages/cli-core/src/commands/migrate/readme.test.ts @@ -1,19 +1,27 @@ /** * Keeps README.md and the command tree honest about each other. * - * This README documents six commands, seven export platforms, seven sources - * and the run store across ~1000 lines. Checking it by eye at review time does not - * scale, and a doc that names a flag the binary rejects is worse than no doc: - * the reader trusts it and gets a usage error. + * Checking the README by eye at review time does not scale, and a doc that + * names a flag the binary rejects is worse than no doc: the reader trusts it + * and gets a usage error. * * Both directions are checked — every example must resolve, and every flag must * be written down — so neither renaming a flag nor adding one passes silently. */ -import { describe, expect, test } from "bun:test"; +import { afterAll, 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. */ @@ -29,7 +37,7 @@ function documentedCommands(markdown: string): string[] { const found = new Set(); for (const block of fencedBlocks(markdown)) { - // Line continuations first: the Firebase example spans three lines. + // Line continuations first, so a multi-line example reads as one. for (const line of block.replace(/\\\n\s*/g, " ").split("\n")) { const start = line.indexOf("clerk migrate"); // A command never contains a backtick, a `#`, or a run of two spaces; 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 991c64e04..0f42bc22d 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -44,7 +44,6 @@ mock.module("../../lib/prompts.ts", () => ({ const { run } = await import("./run.ts"); const { UserAbortError } = await import("../../lib/errors.ts"); const { _setConfigDir } = await import("../../lib/config.ts"); -const { listRuns, startRun } = await import("./lib/run-store.ts"); const captured = useCaptureLog(); @@ -129,33 +128,6 @@ describe("prompts for what was not passed", () => { expect(mockText).not.toHaveBeenCalled(); expect(mockSelect).not.toHaveBeenCalled(); }); - - // The envelope names the source, so asking would only invite a wrong answer. - test("does not ask for a source when the file names its own", async () => { - const exportRun = startRun(runsDir(), { - kind: "export", - target: { platform: "clerk" }, - source: "clerk", - }); - const file = path.join(exportRun.dir, "export.json"); - fs.writeFileSync( - file, - JSON.stringify({ - clerkMigrate: 1, - source: "clerk", - exportedAt: "2026-09-01T00:00:00.000Z", - runId: exportRun.record.id, - users: EXPORT, - }), - ); - exportRun.update({ file: { path: file, sha256: "x" } }); - exportRun.finish(); - - await run({ input: exportRun.record.id, secretKey: "sk_test_x" }); - - expect(mockSelect).not.toHaveBeenCalled(); - expect(created()).toHaveLength(2); - }); }); describe("consent", () => { @@ -177,7 +149,7 @@ describe("consent", () => { await expect(run(importOptions)).rejects.toThrow(UserAbortError); expect(created()).toHaveLength(0); - expect(listRuns(runsDir())).toHaveLength(0); + expect(fs.existsSync(runsDir())).toBe(false); }); test("--yes does not ask", async () => { diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index c733e77f2..d8f06c046 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -9,8 +9,7 @@ import { credentialStoreStubs, useCaptureLog } from "../../test/lib/stubs.ts"; // Every test below names its own `--secret-key`, which short-circuits the // signed-in check — except the one that asserts what happens without it. mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); -import { latestUserLines, listRuns, readRun, startRun } from "./lib/run-store.ts"; -import { __resetCustomSourcesForTesting } from "./sources/registry.ts"; +import { latestUserLines, readRun, type RunRecord } from "./lib/run-store.ts"; import { explainErrors, run, validateRunOptions } from "./run.ts"; /** A real-shaped bcrypt digest: the checks reject anything that is not. */ @@ -23,6 +22,13 @@ let originalCwd: string; /** Where runs land for a project rooted at `workDir`. */ const runsDir = () => path.join(workDir, ".clerk", "migrate"); +/** The runs in `dir`, newest first. */ +const runsIn = (dir: string): RunRecord[] => + (fs.existsSync(dir) ? fs.readdirSync(dir) : []) + .map((id) => readRun(dir, id)) + .filter((record): record is RunRecord => record !== undefined) + .sort((a, b) => b.id.localeCompare(a.id)); + beforeAll(() => { originalCwd = process.cwd(); workDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-run-"))); @@ -209,7 +215,7 @@ describe("run", () => { test("records the run in the project's run store", async () => { await run(baseOptions); - const [record, ...rest] = listRuns(runsDir()); + const [record, ...rest] = runsIn(runsDir()); expect(rest).toHaveLength(0); expect(record).toMatchObject({ kind: "import", @@ -228,130 +234,6 @@ describe("run", () => { ]); }); - // A complete import leaves the export's user data behind; say how to remove it. - test("names the folders a complete import no longer needs", async () => { - await run(baseOptions); - const [record] = listRuns(runsDir()); - expect(captured.err).toContain(`rm -rf ${path.join(runsDir(), record!.id)}`); - }); - - // Pasted unquoted, `rm -rf …/app copy/…` deletes `…/app`. - test("quotes the cleanup paths", async () => { - const spaced = path.join(workDir, "app copy"); - await run({ ...baseOptions, runsDir: spaced }); - - const [record] = listRuns(spaced); - expect(captured.err).toContain(`rm -rf '${path.join(spaced, record!.id)}'`); - }); - - describe("export envelopes", () => { - /** An export run whose envelope holds `users`, as `clerk migrate export` writes it. */ - function exportRun(source: string, rows: unknown[], extra: Record = {}) { - const run = startRun(runsDir(), { kind: "export", target: { platform: source }, source }); - const file = path.join(run.dir, "export.json"); - fs.writeFileSync( - file, - JSON.stringify({ - clerkMigrate: 1, - source, - exportedAt: "2026-09-01T00:00:00.000Z", - runId: run.record.id, - users: rows, - ...extra, - }), - ); - run.update({ file: { path: file, sha256: "x" } }); - return { record: run.finish(), file }; - } - - const { source: _source, input: _input, ...noSource } = baseOptions; - - test("imports by export run ID, with the source the envelope names", async () => { - const { record } = exportRun("clerk", export2); - - await run({ ...noSource, input: record.id }); - - expect(requests.filter((r) => r.url.endsWith("/v1/users"))).toHaveLength(2); - const imported = listRuns(runsDir()).find((candidate) => candidate.kind === "import"); - expect(imported).toMatchObject({ source: "clerk", fromExport: record.id }); - }); - - test("imports an envelope file with no source named", async () => { - const { file } = exportRun("clerk", export2); - - await run({ ...noSource, input: file }); - - expect(requests.filter((r) => r.url.endsWith("/v1/users"))).toHaveLength(2); - }); - - test("refuses a source that contradicts the envelope", async () => { - const { record } = exportRun("clerk", export2); - - await expect(run({ ...noSource, source: "auth0", input: record.id })).rejects.toThrow( - /exported from clerk, but --source names auth0/, - ); - expect(requests.filter((r) => r.url.endsWith("/v1/users"))).toHaveLength(0); - }); - - test("refuses a run ID that is not an export", async () => { - await run(baseOptions); - const [imported] = listRuns(runsDir()); - - await expect(run({ ...noSource, input: imported!.id })).rejects.toThrow( - /is an import run, which has no file to import/, - ); - }); - - test("reads Firebase's hash parameters from the envelope", async () => { - const firebase = { - base64_signer_key: "SIGNER", - base64_salt_separator: "Bw==", - rounds: 8, - mem_cost: 14, - }; - const { record } = exportRun( - "firebase", - [{ localId: "f1", email: "f@x.dev", passwordHash: "HASH", salt: "SALT" }], - { firebase }, - ); - - await run({ ...noSource, input: record.id }); - - const created = requests.find((r) => r.url.endsWith("/v1/users")); - expect(created?.body).toMatchObject({ - password_hasher: "scrypt_firebase", - password_digest: "HASH$SALT$SIGNER$Bw==$8$14", - }); - }); - - test("lets the --firebase-* flags override the envelope", async () => { - const { record } = exportRun( - "firebase", - [{ localId: "f1", email: "f@x.dev", passwordHash: "HASH", salt: "SALT" }], - { - firebase: { - base64_signer_key: "OLD", - base64_salt_separator: "Bw==", - rounds: 8, - mem_cost: 14, - }, - }, - ); - - await run({ - ...noSource, - input: record.id, - firebaseSignerKey: "NEW", - firebaseSaltSeparator: "Bw==", - firebaseRounds: 8, - firebaseMemCost: 14, - }); - - const created = requests.find((r) => r.url.endsWith("/v1/users")); - expect((created!.body as { password_digest: string }).password_digest).toContain("$NEW$"); - }); - }); - test("gitignores the project's .clerk folder before writing a run", async () => { await run(baseOptions); expect(fs.readFileSync(path.join(workDir, ".gitignore"), "utf-8")).toContain(".clerk/"); @@ -367,8 +249,8 @@ describe("run", () => { test("--runs-dir puts the run somewhere else", async () => { await run({ ...baseOptions, runsDir: "elsewhere" }); - expect(listRuns(path.join(workDir, "elsewhere"))).toHaveLength(1); - expect(listRuns(runsDir())).toHaveLength(0); + expect(runsIn(path.join(workDir, "elsewhere"))).toHaveLength(1); + expect(runsIn(runsDir())).toHaveLength(0); }); test("--require-password leaves out the users without one", async () => { @@ -376,8 +258,7 @@ describe("run", () => { expect(created()).toEqual(["u1"]); expect(captured.err).toContain("leaving out 1 user without a password"); - // On record, so the run is partial rather than "Already imported". - const [record] = listRuns(runsDir()); + const [record] = runsIn(runsDir()); expect(latestUserLines(runsDir(), record!.id).get("u2")).toMatchObject({ status: "skipped", reason: "no password (--require-password)", @@ -411,14 +292,14 @@ describe("run", () => { await run(baseOptions); expect(process.exitCode).toBe(1); - expect(listRuns(runsDir())[0]).toMatchObject({ status: "partial", counts: { failed: 1 } }); + expect(runsIn(runsDir())[0]).toMatchObject({ status: "partial", counts: { failed: 1 } }); }); // Tests run non-TTY, the same signal an agent gives. describe("without a file or a source", () => { test("names what to pass rather than prompting for the file", async () => { await expect(run({ source: "clerk", yes: true, secretKey: "sk_test_x" })).rejects.toThrow( - /needs the file to import, or the export run that wrote it, and cannot prompt here/, + /needs the file to import, and cannot prompt here/, ); expect(requests).toHaveLength(0); }); @@ -430,208 +311,6 @@ describe("run", () => { }); }); - describe("continuing an earlier run", () => { - test("a complete run is not imported again", async () => { - await run(baseOptions); - requests = []; - - await run(baseOptions); - - expect(created()).toHaveLength(0); - expect(captured.err).toContain("Already imported in run"); - expect(listRuns(runsDir())).toHaveLength(1); - }); - - test("--new-run imports it again as a new run", async () => { - await run(baseOptions); - requests = []; - - await run({ ...baseOptions, newRun: true }); - - expect(created()).toEqual(["u1", "u2"]); - expect(listRuns(runsDir())).toHaveLength(2); - }); - - test("a partial run retries only the users that did not make it, in the same run", async () => { - stubClerk({ failing: new Set(["u2"]) }); - await run(baseOptions); - const [first] = listRuns(runsDir()); - - requests = []; - process.exitCode = 0; - stubClerk(); - await run(baseOptions); - - expect(created()).toEqual(["u2"]); - expect(captured.err).toContain(`Continuing run ${first!.id}, which finished partial`); - const runs = listRuns(runsDir()); - expect(runs).toHaveLength(1); - expect(runs[0]).toMatchObject({ id: first!.id, status: "complete", counts: { created: 2 } }); - }); - - test("an interrupted run skips the users it already created", async () => { - stubClerk({ failing: new Set(["u2"]) }); - await run(baseOptions); - const [first] = listRuns(runsDir()); - // A crash never writes a finish time. - const record = readRun(runsDir(), first!.id)!; - delete record.finishedAt; - fs.writeFileSync(path.join(runsDir(), first!.id, "run.json"), JSON.stringify(record)); - - requests = []; - process.exitCode = 0; - stubClerk(); - await run(baseOptions); - - expect(created()).toEqual(["u2"]); - expect(captured.err).toContain("which was interrupted"); - }); - - /** Rewrites a finished run as one a crash stopped: no finish time. */ - const interrupt = (id: string, patch: Record = {}) => { - const record = readRun(runsDir(), id)!; - delete record.finishedAt; - fs.writeFileSync( - path.join(runsDir(), id, "run.json"), - JSON.stringify({ ...record, ...patch }), - ); - }; - - // The create went out and the run stopped before the answer came back. - test("an interrupted run adopts a user Clerk created with no ID on record", async () => { - stubClerk({ failing: new Set(["u2"]) }); - await run(baseOptions); - const [first] = listRuns(runsDir()); - fs.appendFileSync( - path.join(runsDir(), first!.id, "users.ndjson"), - `${JSON.stringify({ sourceId: "u2", status: "creating" })}\n`, - ); - interrupt(first!.id); - - requests = []; - process.exitCode = 0; - stubClerk({ existing: [{ id: "user_found", external_id: "u2" }] }); - await run(baseOptions); - - expect(created()).toEqual([]); - expect(captured.err).toContain("1 user whose create was cut off is already in the instance"); - expect(latestUserLines(runsDir(), first!.id).get("u2")).toMatchObject({ - status: "created", - clerkId: "user_found", - }); - expect(readRun(runsDir(), first!.id)?.status).toBe("complete"); - }); - - test("an interrupted run creates a user whose in-flight create never landed", async () => { - stubClerk({ failing: new Set(["u2"]) }); - await run(baseOptions); - const [first] = listRuns(runsDir()); - fs.appendFileSync( - path.join(runsDir(), first!.id, "users.ndjson"), - `${JSON.stringify({ sourceId: "u2", status: "creating" })}\n`, - ); - interrupt(first!.id); - - requests = []; - process.exitCode = 0; - stubClerk(); - await run(baseOptions); - - expect(created()).toEqual(["u2"]); - }); - - test("a continued run with nothing left to do is finished", async () => { - await run(baseOptions); - const [first] = listRuns(runsDir()); - interrupt(first!.id); - requests = []; - - await run(baseOptions); - - expect(created()).toEqual([]); - expect(captured.err).toContain("No users left to import"); - expect(readRun(runsDir(), first!.id)).toMatchObject({ status: "complete" }); - expect(readRun(runsDir(), first!.id)?.finishedAt).toBeDefined(); - }); - - // "Interrupted, so undo it and start over": the undo marks the run undone - // but leaves it with no finish time. - test("an interrupted run that was then undone is imported again as a new run", async () => { - await run(baseOptions); - const [first] = listRuns(runsDir()); - interrupt(first!.id, { status: "undone" }); - requests = []; - - await run(baseOptions); - - expect(created()).toEqual(["u1", "u2"]); - expect(listRuns(runsDir()).filter((record) => record.kind === "import")).toHaveLength(2); - }); - - test("a run with an undo that did not finish refuses with exit 2", async () => { - await run(baseOptions); - const [first] = listRuns(runsDir()); - const undoRun = startRun(runsDir(), { - kind: "undo", - target: { instanceId: "ins_1" }, - undoes: first!.id, - }); - undoRun.append({ sourceId: "u1", status: "deleted", clerkId: "user_u1" }); - undoRun.append({ sourceId: "u2", status: "failed", clerkId: "user_u2" }); - undoRun.finish(); - requests = []; - - const error = (await run(baseOptions).catch((caught: unknown) => caught)) as CliError; - - expect(error.exitCode).toBe(EXIT_CODE.USAGE); - expect(error.message).toContain(`clerk migrate undo ${first!.id}`); - expect(created()).toEqual([]); - }); - - test("an undone run is imported again as a new run", async () => { - await run(baseOptions); - const [first] = listRuns(runsDir()); - const record = readRun(runsDir(), first!.id)!; - fs.writeFileSync( - path.join(runsDir(), first!.id, "run.json"), - JSON.stringify({ ...record, status: "undone" }), - ); - requests = []; - - await run(baseOptions); - - expect(created()).toEqual(["u1", "u2"]); - expect(listRuns(runsDir())).toHaveLength(2); - }); - - test("a run another live process holds refuses with exit 2", async () => { - stubClerk({ failing: new Set(["u2"]) }); - await run(baseOptions); - const [first] = listRuns(runsDir()); - const record = readRun(runsDir(), first!.id)!; - delete record.finishedAt; - fs.writeFileSync(path.join(runsDir(), first!.id, "run.json"), JSON.stringify(record)); - // PID 1 is always alive, and never this test. - fs.writeFileSync(path.join(runsDir(), first!.id, "lock"), "1"); - - expect(await exitCodeOf(run(baseOptions))).toBe(EXIT_CODE.USAGE); - }); - - // An edited file is a different job. - test("a changed file is a new run", async () => { - await run(baseOptions); - fs.writeFileSync( - path.join(workDir, "export.json"), - JSON.stringify([...export2, { id: "u3", primary_email_address: "c@x.dev" }]), - ); - requests = []; - - await run(baseOptions); - - expect(listRuns(runsDir())).toHaveLength(2); - }); - }); - describe("checks", () => { test("a reject stops the import, with the command that imports the rest", async () => { fs.writeFileSync( @@ -645,7 +324,7 @@ describe("run", () => { expect(error.message).toContain("1 user would be rejected, so nothing was imported"); expect(error.examples?.[0]?.command).toContain("--allow-partial --yes"); expect(created()).toHaveLength(0); - expect(listRuns(runsDir())).toHaveLength(0); + expect(runsIn(runsDir())).toHaveLength(0); }); test("--allow-partial imports the rest and records each reject as skipped", async () => { @@ -657,7 +336,7 @@ describe("run", () => { await run({ ...baseOptions, allowPartial: true }); expect(created()).toEqual(["u1", "u2"]); - const [record] = listRuns(runsDir()); + const [record] = runsIn(runsDir()); expect(record).toMatchObject({ status: "partial", counts: { created: 2, skipped: 1 } }); expect(latestUserLines(runsDir(), record!.id).get("u3")).toMatchObject({ status: "skipped", @@ -676,7 +355,7 @@ describe("run", () => { await run({ ...baseOptions, dryRun: true }); expect(created()).toHaveLength(0); - expect(listRuns(runsDir())).toHaveLength(0); + expect(runsIn(runsDir())).toHaveLength(0); expect(captured.err).toContain("Dry run: nothing was written."); expect(process.exitCode).toBe(2); }); @@ -752,7 +431,7 @@ describe("run", () => { await run({ ...baseOptions, allowPartial: true }); expect(created()).toEqual(["u1"]); - const [record] = listRuns(runsDir()); + const [record] = runsIn(runsDir()); expect(latestUserLines(runsDir(), record!.id).get("u2")?.reason).toContain("100-user limit"); }); @@ -827,6 +506,12 @@ describe("run", () => { }); }); + // An agent reads stderr as text. + test("--json leaves colour codes out of stderr", async () => { + await run({ ...baseOptions, json: true }); + expect(captured.err).not.toContain("\x1b["); + }); + test("--json without --yes returns the preview with consent required, and exits 2", async () => { expect(await exitCodeOf(run({ ...baseOptions, yes: false, json: true }))).toBe( EXIT_CODE.USAGE, @@ -839,260 +524,51 @@ describe("run", () => { }); }); - describe("--source ", () => { - const CUSTOM = `export default { - key: "myplatform", - label: "My Platform", - description: "Exports from My Platform.", - transformer: { account_ref: "userId", contact_email: "email", given: "firstName", pw: "password" }, - carries: { - passwords: { level: "yes", note: "bcrypt." }, - mfa: { level: "no", note: "None." }, - metadata: { level: "no", note: "None." }, - }, - defaults: { passwordHasher: "bcrypt" }, - postTransform: (user) => { if (!user.firstName) delete user.firstName; }, - };`; - - let customFile: string; - let customCounter = 0; - - beforeEach(() => { - // A fresh filename each time: dynamic import() caches by URL, so reusing - // one would silently return a previous test's module. - customFile = `./custom-run-${customCounter++}.ts`; - fs.writeFileSync(path.join(workDir, customFile), CUSTOM); - fs.writeFileSync( - path.join(workDir, "export.json"), - JSON.stringify([ - { account_ref: "mp_1", contact_email: "a@x.dev", given: "Ada", pw: BCRYPT }, - { account_ref: "mp_2", contact_email: "b@x.dev", given: "", pw: BCRYPT }, - ]), - ); - }); - - afterEach(() => { - __resetCustomSourcesForTesting(); - }); - - const created = () => requests.filter((r) => r.url.endsWith("/v1/users")); - - // The registered key isn't something --source accepts; the path is. - test("the printed command names the source's path, not its key", async () => { - const error = (await run({ - input: "export.json", - source: customFile, - secretKey: "sk_test_x", - json: true, - }).catch((caught: unknown) => caught)) as CliError; - - expect(error.examples?.[0]?.command).toBe( - `clerk migrate import export.json --source ${customFile} --secret-key --json --yes`, - ); - }); - - test("imports through a user-authored source", async () => { - await run({ - input: "export.json", - source: customFile, - yes: true, - secretKey: "sk_test_x", - }); - - expect(created().map((r) => (r.body as { external_id: string }).external_id)).toEqual([ - "mp_1", - "mp_2", - ]); - expect(captured.err).toContain("myplatform"); - expect(captured.err).toContain("source from"); - }); - - test("applies the custom source's defaults and postTransform", async () => { - await run({ - input: "export.json", - source: customFile, - yes: true, - secretKey: "sk_test_x", + describe("re-running", () => { + // Slice 1 has no continuing: the second run sees the first run's users in + // the instance, and refuses until --allow-partial. + test("a second import of the same file rejects the users the first created", async () => { + await run(baseOptions); + stubClerk({ + existing: [ + { id: "user_u1", external_id: "u1" }, + { id: "user_u2", external_id: "u2" }, + ], }); - const bodies = created().map((r) => r.body as Record); - expect(bodies[0]).toMatchObject({ first_name: "Ada", password_hasher: "bcrypt" }); - // postTransform dropped the empty given name rather than sending "". - expect("first_name" in (bodies[1] ?? {})).toBe(false); - }); - - // An edited source is a different source, so the run records which one. - test("records the custom source's content hash on the run", async () => { - await run({ input: "export.json", source: customFile, yes: true, secretKey: "sk_test_x" }); - - const [record] = listRuns(runsDir()); - expect(record?.source).toBe("myplatform"); - expect(record?.sourceHash).toMatch(/^[0-9a-f]{64}$/); - }); - - test("an unknown built-in key is a usage error listing the valid ones", async () => { - await expect( - run({ input: "export.json", source: "okta", yes: true, secretKey: "sk_test_x" }), - ).rejects.toThrow(/Unknown source "okta". Valid sources: clerk, auth0/); - expect(created()).toHaveLength(0); - }); - - test("fails before any request when the file is not there", async () => { - await expect( - run({ - input: "export.json", - source: "./nope.ts", - yes: true, - secretKey: "sk_test_x", - }), - ).rejects.toThrow(/No source file at/); - expect(requests).toHaveLength(0); - }); - - test("fails before any request when the file is malformed", async () => { - const bad = `./bad-${customCounter++}.ts`; - fs.writeFileSync( - path.join(workDir, bad), - `export default { key: "x", label: "X", transformer: {} };`, - ); - - const error = (await run({ - input: "export.json", - source: bad, - yes: true, - secretKey: "sk_test_x", - }).catch((caught: unknown) => caught)) as CliError; - expect(error.message).toMatch(/no source field maps to `userId`/); - expect(error.exitCode).toBe(EXIT_CODE.USAGE); - expect(requests).toHaveLength(0); - }); - - test("still requires a file", async () => { - await expect(run({ source: customFile, yes: true, secretKey: "sk_test_x" })).rejects.toThrow( - /needs the file to import/, - ); + expect(await exitCodeOf(run(baseOptions))).toBe(EXIT_CODE.USAGE); + expect(captured.err).toContain("already in the instance, with this source ID"); + expect(created()).toEqual(["u1", "u2"]); + expect(runsIn(runsDir())).toHaveLength(1); }); }); describe("per-platform imports", () => { - /** One realistic record per platform, in that platform's export shape. */ - const PLATFORMS: [string, unknown, string][] = [ - [ - "auth0", - [ - { - user_id: "auth0|1", - email: "a@x.dev", - email_verified: true, - given_name: "Ada", - family_name: "L", - }, - ], - "auth0|1", - ], - ["authjs", [{ id: "aj1", email: "a@x.dev", email_verified: "2024-01-01T00:00:00Z" }], "aj1"], - [ - "betterauth", - [{ user_id: "ba1", email: "a@x.dev", email_verified: true, password_hash: BCRYPT }], - "ba1", - ], - [ - "supabase", - [ + test("supabase transforms, validates and imports its export", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ { id: "sb1", email: "a@x.dev", email_confirmed_at: "2024-06-29 20:25:06+00", encrypted_password: BCRYPT, }, - ], - "sb1", - ], - ]; - - test.each(PLATFORMS)( - "%s transforms, validates and imports its export", - async (key, records, externalId) => { - fs.writeFileSync(path.join(workDir, "export.json"), JSON.stringify(records)); - - await run({ ...baseOptions, source: key }); - - const created = requests.filter((r) => r.url.endsWith("/v1/users")); - expect(created).toHaveLength(1); - expect((created[0]?.body as { external_id: string } | undefined)?.external_id).toBe( - externalId, - ); - }, - ); - - test("firebase imports its wrapped export and builds the scrypt digest", async () => { - fs.writeFileSync( - path.join(workDir, "export.json"), - JSON.stringify({ - users: [ - { - localId: "fb1", - email: "a@x.dev", - emailVerified: true, - passwordHash: "SGFzaA==", - salt: "U2FsdA==", - }, - ], - }), - ); - - await run({ - ...baseOptions, - source: "firebase", - firebaseSignerKey: "SIGNER", - firebaseSaltSeparator: "Bw==", - firebaseRounds: 8, - firebaseMemCost: 14, - }); - - const body = requests.find((r) => r.url.endsWith("/v1/users"))?.body as Record< - string, - unknown - >; - expect(body).toMatchObject({ - external_id: "fb1", - password_digest: "SGFzaA==$U2FsdA==$SIGNER$Bw==$8$14", - password_hasher: "scrypt_firebase", - }); - }); - - test("the printed command keeps the --firebase-* flags, as placeholders", async () => { - fs.writeFileSync( - path.join(workDir, "export.json"), - JSON.stringify({ users: [{ localId: "fb1", email: "a@x.dev", emailVerified: true }] }), + ]), ); - const error = (await run({ - ...baseOptions, - yes: false, - source: "firebase", - firebaseSignerKey: "SIGNER", - firebaseSaltSeparator: "Bw==", - firebaseRounds: 8, - firebaseMemCost: 14, - }).catch((caught: unknown) => caught)) as CliError; - - expect(error.examples?.[0]?.command).toContain( - "--firebase-signer-key --firebase-salt-separator --firebase-rounds --firebase-mem-cost ", - ); - }); + await run({ ...baseOptions, source: "supabase" }); - test("a partial firebase flag set fails before anything is read", async () => { - await expect( - run({ ...baseOptions, source: "firebase", firebaseSignerKey: "SIGNER" }), - ).rejects.toThrow(/--firebase-salt-separator/); - expect(requests).toHaveLength(0); + expect(created()).toEqual(["sb1"]); }); test("an unknown source fails listing the valid keys", async () => { - await expect(run({ ...baseOptions, source: "okta" })).rejects.toThrow( - /Unknown source "okta".*clerk.*supabase/s, - ); + const error = (await run({ ...baseOptions, source: "nope" }).catch( + (caught: unknown) => caught, + )) as CliError; + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain('Unknown source "nope". Valid sources: clerk, supabase.'); + expect(requests).toHaveLength(0); }); }); }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 2d7a32ec0..ddab3bec3 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -1,16 +1,14 @@ /** - * `clerk migrate import ` — the user import itself. + * `clerk migrate import ` — the user import itself. * * Registered as the `import` subcommand. The exported handler keeps the name * `run` because `import` is a reserved word. * * 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 - * target instance, and print the target. - * 2. Decide whether this continues an earlier run of the same file, source and - * instance, from the run store. + * 1. Settle the file, the source and the target instance, and print the target. + * 2. Load the users: read the file, map it through the source, then normalize + * and validate it. * 3. Run `checkImport()` against the real instance. `--dry-run` stops here. * 4. Refuse on any reject unless `--allow-partial`, then ask for consent. * Nothing is written without `--yes` or a yes at the prompt. @@ -38,31 +36,12 @@ import { isAgent, isHuman } from "../../mode.ts"; import { importUsers } from "./import-users.ts"; import { checkImport, type ImportChecks } from "./lib/checks.ts"; import { fetchInstanceSettings, fetchUserCount } from "./lib/clerk-config.ts"; -import { readEnvelope, type ExportEnvelope } from "./lib/export-file.ts"; -import { resolveFirebaseHashConfig, type FirebaseHashFlags } from "./lib/firebase-hash.ts"; import { DEV_USER_LIMIT, resolveLimits, type InstanceType } from "./lib/instance.ts"; -import { - continueRun, - latestUserLines, - listRuns, - liveLockPid, - lockFile, - readRun, - resolveRunsDir, - RUN_ID_PATTERN, - runDir, - runState, - sha256File, - startRun, - type Run, - type RunRecord, - type UserLine, -} from "./lib/run-store.ts"; +import { resolveRunsDir, sha256File, startRun, type Run, type RunRecord } from "./lib/run-store.ts"; import { withProgress } from "./lib/progress.ts"; import { createApiScheduler } from "./lib/scheduler.ts"; import { readSupabaseRows } from "./lib/supabase-providers.ts"; -import { keyInstanceId, printTarget, resolveClerkTarget } from "./lib/target.ts"; -import { findInFlight } from "./lib/user-lookup.ts"; +import { printTarget, resolveClerkTarget } from "./lib/target.ts"; import { fileExists, getFileType, @@ -71,19 +50,15 @@ import { } from "./lib/transform.ts"; import { resolveSource, sourceKeys } from "./sources/registry.ts"; import type { ImportSummary, User } from "./types.ts"; -import { promptForFile, promptForFirebaseHashConfig, promptForSource } from "./wizard.ts"; +import { promptForFile, promptForSource } from "./wizard.ts"; import { login } from "../auth/login.ts"; import { link } from "../link/index.ts"; export type MigrateRunOptions = { - /** An export file, or the ID of the export run that wrote one. */ + /** The export file. */ input?: string; - /** A built-in source key, or the path to a source you wrote. */ + /** A built-in source key. */ source?: string; - /** Content hash of a custom `--source`, set once it is loaded. */ - sourceHash?: string; - /** `--source` as typed, for printed commands: a custom source's path. Not a flag. */ - sourceArg?: string; /** The file to read, once `input` is resolved. Not a flag. */ file?: string; requirePassword?: boolean; @@ -96,8 +71,6 @@ export type MigrateRunOptions = { dryRun?: boolean; /** Import the users that pass, and record the rest as skipped. */ allowPartial?: boolean; - /** Start a fresh run even when an earlier one matches. */ - newRun?: boolean; yes?: boolean; json?: boolean; secretKey?: string; @@ -105,7 +78,7 @@ export type MigrateRunOptions = { instance?: string; /** Where runs are kept; overrides `CLERK_MIGRATE_DIR`. */ runsDir?: string; -} & FirebaseHashFlags; +}; const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; @@ -118,7 +91,7 @@ function canPrompt(options: MigrateRunOptions): boolean { * Validates what a run needs before anything is read or sent. * * `--source` has already been resolved to a registered key by the time this - * runs, custom sources included. + * runs. * * @returns The source key and file path, both guaranteed present. */ @@ -128,7 +101,7 @@ export function validateRunOptions(options: MigrateRunOptions): { } { if (!options.source) { throwUsageError( - `Missing --source. Valid values: ${sourceKeys().join(", ")}, or the path to a source you wrote.`, + `Missing --source. Valid values: ${sourceKeys().join(", ")}.`, undefined, ERROR_CODE.USAGE_ERROR, [ @@ -141,7 +114,7 @@ export function validateRunOptions(options: MigrateRunOptions): { } if (!options.file) { throwUsageError( - "Missing the file to import (a JSON or CSV export, or an export run ID).", + "Missing the file to import (a JSON or CSV export).", undefined, ERROR_CODE.USAGE_ERROR, [ @@ -262,159 +235,35 @@ async function ensureImportTarget(options: MigrateRunOptions): Promise { } /** - * Settles which file to read. The argument may name the export run that wrote - * the file; a human who gave none is asked for a path. + * Settles which file to read. A human who gave none is asked for a path. */ -async function resolveInput( - options: MigrateRunOptions, -): Promise<{ file: string; fromExport?: string }> { - const value = options.input; - if (!value) { - if (!canPrompt(options)) { - throwUsageError( - "`clerk migrate import` needs the file to import, or the export run that wrote it, and cannot prompt here.", - undefined, - undefined, - [ - { - command: "clerk migrate import 20260929-141502-a1b2 --yes", - description: "Import what an export run wrote", - }, - { - command: "clerk migrate import users.json --source clerk --yes", - description: "Import a file", - }, - ], - ); - } - return { file: await promptForFile() }; - } - if (!RUN_ID_PATTERN.test(value) || fileExists(value)) return { file: value }; - - const runsDir = await resolveRunsDir(options.runsDir); - const record = readRun(runsDir, value); - if (!record) { - throwUsageError(`No run \`${value}\` in ${runsDir}. Run \`clerk migrate runs\` to list them.`); - } - if (record.kind !== "export" || !record.file) { +async function resolveInput(options: MigrateRunOptions): Promise { + if (options.input) return options.input; + if (!canPrompt(options)) { throwUsageError( - `Run ${value} is an ${record.kind} run, which has no file to import. Name an export run, or a file.`, + "`clerk migrate import` needs the file to import, and cannot prompt here.", + undefined, + undefined, + [ + { + command: "clerk migrate import users.json --source clerk --yes", + description: "Import a file", + }, + ], ); } - return { file: record.file.path, fromExport: record.id }; + return promptForFile(); } /** - * Resolves `--source` to a registered key, loading a custom source from its - * path so the rest of the run treats it exactly like a built-in. + * Resolves `--source` to a registered key. * * @throws UsageError for an unknown key. */ async function applySource(options: MigrateRunOptions): Promise { if (!options.source) return options; const resolved = await resolveSource(options.source); - if (resolved.path && !options.json) { - log.info(`Loaded the \`${resolved.key}\` source from ${options.source}.`); - } - return { - ...options, - source: resolved.key, - sourceArg: options.source, - ...(resolved.hash ? { sourceHash: resolved.hash } : {}), - }; -} - -/** - * The source an export file names for itself, checked against the one the - * flags name. - * - * @throws UsageError when the two disagree: importing an Auth0 export through - * the Supabase mapping would create users with the wrong fields. - */ -function applyEnvelope( - options: MigrateRunOptions, - envelope: ExportEnvelope | undefined, -): MigrateRunOptions { - if (!envelope) return options; - if (options.source && options.source !== envelope.source) { - throwUsageError( - `The file was exported from ${envelope.source}, but --source names ${options.source}. ` + - "Drop --source: the file already says where it came from.", - ); - } - return { ...options, source: envelope.source }; -} - -// --- Continuing an earlier run --------------------------------------------- - -/** How this import relates to earlier runs of the same file, source and instance. */ -export type ResumeCase = - | { kind: "new" } - | { kind: "continue"; record: RunRecord; because: "interrupted" | "partial" } - | { kind: "complete"; record: RunRecord }; - -/** - * 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: - * - * - none, or undone: a new run - * - interrupted: continue it, skipping the users it created - * - partial: continue it, retrying the users that failed or were skipped - * - complete: nothing; the file is already in - * - * @throws UsageError when that run is still running in another process, or - * has an undo that never finished: some of its users are gone and some are - * not, so neither continuing it nor starting over is safe. - */ -export function findResume( - runsDir: string, - match: { - sha256: string; - source: string; - sourceHash?: string; - instanceId: string; - /** The key's stand-in ID, for a run recorded while Clerk could not name the instance. */ - keyInstanceId?: string; - }, -): ResumeCase { - const latest = listRuns(runsDir).find( - (record) => - record.kind === "import" && - record.file?.sha256 === match.sha256 && - record.source === match.source && - record.sourceHash === match.sourceHash && - (record.target.instanceId === match.instanceId || - record.target.instanceId === match.keyInstanceId), - ); - if (!latest) return { kind: "new" }; - - const state = runState(runsDir, latest); - if (state === "running") { - throwUsageError( - `Run ${latest.id} is importing this file right now in another process (PID ${liveLockPid(runsDir, latest.id)}). ` + - `Wait for it to finish. If that process is not a migrate run, delete ${lockFile(runsDir, latest.id)}.`, - ); - } - if (state === "undone") return { kind: "new" }; - - const undoRun = listRuns(runsDir).find( - (record) => record.kind === "undo" && record.undoes === latest.id, - ); - if (undoRun && runState(runsDir, undoRun) !== "complete") { - throwUsageError( - `Run ${latest.id} has an undo that did not finish (run ${undoRun.id}). ` + - `Finish it with \`clerk migrate undo ${latest.id}\`, or pass --new-run to import into a new run.`, - ); - } - // Undone in full, though the import run was never marked so. - if (undoRun) return { kind: "new" }; - - if (state === "complete") return { kind: "complete", record: latest }; - return { - kind: "continue", - record: latest, - because: state === "interrupted" ? "interrupted" : "partial", - }; + return { ...options, source: resolved.key }; } // --- Reporting ------------------------------------------------------------- @@ -506,45 +355,19 @@ function formatSummary( return lines; } -/** - * Once every user is in, the files that hold them are only a liability: the - * export carries user data and password hashes, and the run is only needed - * for `undo`. - */ -function cleanupLines(runsDir: string, record: RunRecord): string[] { - const lines: string[] = []; - if (record.fromExport) { - lines.push( - `The export in run ${record.fromExport} holds your users' data. Once you have checked the import, delete it:`, - dim(` rm -rf ${quoteArg(runDir(runsDir, record.fromExport))}`), - ); - } - lines.push( - `Keep run ${record.id} while you might still undo it. After that:`, - dim(` rm -rf ${quoteArg(runDir(runsDir, record.id))}`), - ); - return lines; -} - // --- The import ------------------------------------------------------------ /** * The exact command that would carry on from here, for the consent and * refusal messages. Every value is shell-quoted; secrets are placeholders. */ -function commandFor(options: MigrateRunOptions, fromExport: string | undefined, extra: string[]) { - const input = fromExport ?? options.input ?? options.file ?? ""; +function commandFor(options: MigrateRunOptions, extra: string[]) { + const input = options.input ?? options.file ?? ""; const parts = ["clerk migrate import", quoteArg(input)]; - const source = options.sourceArg ?? options.source; - if (!fromExport && source) parts.push("--source", quoteArg(source)); + if (options.source) parts.push("--source", quoteArg(options.source)); if (options.allowPartial) parts.push("--allow-partial"); - if (options.newRun) parts.push("--new-run"); if (options.requirePassword) parts.push("--require-password"); if (options.skipLegalChecks) parts.push("--skip-legal-checks"); - if (options.firebaseSignerKey) parts.push("--firebase-signer-key", ""); - if (options.firebaseSaltSeparator) parts.push("--firebase-salt-separator", ""); - if (options.firebaseRounds) parts.push("--firebase-rounds", ""); - if (options.firebaseMemCost) parts.push("--firebase-mem-cost", ""); if (options.secretKey) parts.push("--secret-key", ""); if (options.app) parts.push("--app", quoteArg(options.app)); if (options.instance) parts.push("--instance", quoteArg(options.instance)); @@ -553,15 +376,9 @@ function commandFor(options: MigrateRunOptions, fromExport: string | undefined, return [...parts, ...extra].join(" "); } -/** - * Records the checks' rejects as skipped users, so the run says who they were. - * - * An adopted user keeps its `creating` line: it exists in Clerk, and `undo` - * finds it only through that line. - */ -function recordRejects(run: Run, checks: ImportChecks, adopted: Map): void { +/** Records the checks' rejects as skipped users, so the run says who they were. */ +function recordRejects(run: Run, checks: ImportChecks): void { for (const { sourceId, reason, keptSourceId } of checks.rejects) { - if (adopted.has(sourceId)) continue; run.append({ sourceId, status: "skipped", @@ -575,25 +392,13 @@ export async function run(rawOptions: MigrateRunOptions): Promise { let options = await applySource(rawOptions); const input = await resolveInput(options); - options = { ...options, file: input.file }; - const envelope = fileExists(input.file) - ? readEnvelope(resolveImportFilePath(input.file)) - : undefined; - options = applyEnvelope(options, envelope); - - // Asked only when the file does not say where it came from. - if (!options.source && canPrompt(options) && fileExists(input.file)) { + options = { ...options, file: input }; + + if (!options.source && canPrompt(options) && fileExists(input)) { options = { ...options, source: await promptForSource() }; } const { source, file } = validateRunOptions(options); - // The flags win, so a rotated key can be passed without re-exporting. - let firebaseHashConfig = - resolveFirebaseHashConfig(options, source) ?? - (source === "firebase" ? envelope?.firebase : undefined); - if (source === "firebase" && !firebaseHashConfig && canPrompt(options)) { - firebaseHashConfig = await promptForFirebaseHashConfig(); - } await withGutter( "Migrating users to Clerk", @@ -605,84 +410,18 @@ export async function run(rawOptions: MigrateRunOptions): Promise { const filePath = resolveImportFilePath(file); const sha256 = sha256File(filePath); const runsDir = await resolveRunsDir(options.runsDir); - - const resume: ResumeCase = options.newRun - ? { kind: "new" } - : findResume(runsDir, { - sha256, - source, - ...(options.sourceHash ? { sourceHash: options.sourceHash } : {}), - instanceId: target.instanceId, - keyInstanceId: keyInstanceId(secretKey), - }); - - if (resume.kind === "complete") { - if (options.json) { - log.data(JSON.stringify({ target, run: resume.record, alreadyImported: true }, null, 2)); - } else { - log.success( - `Already imported in run ${resume.record.id}. Pass --new-run to import it again.`, - ); - } - return; - } - - // Users the run being continued already created are done: they are not - // checked or sent again. Those whose extra identifiers never attached - // get just the attaches. - const continued = resume.kind === "continue" ? resume.record : undefined; - const done = new Map(); - const attachOnly: UserLine[] = []; - const inFlight: string[] = []; - if (continued) { - for (const line of latestUserLines(runsDir, continued.id).values()) { - if (line.status === "creating") inFlight.push(line.sourceId); - if (line.status !== "created" || !line.clerkId) continue; - done.set(line.sourceId, line.clerkId); - if (line.pending?.length) attachOnly.push(line); - } - } - - // Creates the run stopped with no answer to. Those Clerk holds are - // adopted: checked as usual, but never created again. const schedule = createApiScheduler(limits.concurrencyLimit, limits.rateLimit); - const adopted = new Map(); - if (continued) { - const found = await findInFlight({ - runsDir, - runId: continued.id, - sourceIds: inFlight, - secretKey, - schedule, - }); - for (const user of found) adopted.set(user.sourceId, user.clerkId); - } - - if (!options.json) { - log.info( - resume.kind === "continue" - ? `Continuing run ${resume.record.id}, which ${resume.because === "interrupted" ? "was interrupted" : "finished partial"}: ` + - `${plural(done.size, "user")} already imported ${done.size === 1 ? "is" : "are"} left alone.` - : "Starting a new run.", - ); - if (adopted.size > 0) { - log.info( - `${plural(adopted.size, "user")} whose create was cut off ${adopted.size === 1 ? "is" : "are"} already in the instance, and won't be created again.`, - ); - } - } const loaded = await withSpinner(`Loading users from ${file}...`, async () => - loadUsersFromFile(file, source, { context: { firebaseHashConfig } }), + loadUsersFromFile(file, source), ); - let users = loaded.users.filter((user) => !done.has(user.userId)); - const failures = loaded.failures.filter((failure) => !done.has(failure.userId)); + let users = loaded.users; // An instruction about this import, not a prediction: users without a - // password are left out of the job rather than recorded as skipped. + // password are left out of the job rather than recorded as rejects. let withoutPassword: User[] = []; if (options.requirePassword) { - withoutPassword = users.filter((user) => !user.password && !adopted.has(user.userId)); + withoutPassword = users.filter((user) => !user.password); if (withoutPassword.length > 0 && !options.json) { log.info( `--require-password: leaving out ${plural(withoutPassword.length, "user")} without a password.`, @@ -724,7 +463,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { checkImport({ users, skipLegalChecks, - failures, + failures: loaded.failures, unknownFields: loaded.unknownFields, ...(supabaseRows ? { supabaseRows } : {}), settings, @@ -733,7 +472,6 @@ export async function run(rawOptions: MigrateRunOptions): Promise { target, secretKey, schedule, - adoptedClerkIds: new Set(adopted.values()), spinner, }), ); @@ -741,17 +479,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { const refused = checks.rejects.length > 0 && !options.allowPartial; const preview = (extra: Record) => log.data( - JSON.stringify( - { - target, - run: continued ?? null, - resume: resume.kind, - checks: checksJson(checks), - ...extra, - }, - null, - 2, - ), + JSON.stringify({ target, run: null, checks: checksJson(checks), ...extra }, null, 2), ); if (!options.json) printChecks(checks); @@ -776,7 +504,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { undefined, [ { - command: commandFor(options, input.fromExport, ["--allow-partial", "--yes"]), + command: commandFor(options, ["--allow-partial", "--yes"]), description: "Import the users that pass", }, ], @@ -786,11 +514,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { if ( checks.importable.length === 0 && checks.rejects.length === 0 && - withoutPassword.length === 0 && - attachOnly.length === 0 + withoutPassword.length === 0 ) { - // Settled, so a continued run is finished rather than left interrupted. - if (continued) continueRun(runsDir, continued).finish(); if (options.json) preview({ nothingToImport: true }); else log.warn("No users left to import."); return; @@ -807,7 +532,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { undefined, [ { - command: commandFor(options, input.fromExport, ["--yes"]), + command: commandFor(options, ["--yes"]), description: "Run the import", }, ], @@ -823,17 +548,13 @@ export async function run(rawOptions: MigrateRunOptions): Promise { // Gitignored only now, once there is consent to write a run. await resolveRunsDir(options.runsDir, { write: true }); - const run = continued - ? continueRun(runsDir, continued) - : startRun(runsDir, { - kind: "import", - target, - source, - ...(options.sourceHash ? { sourceHash: options.sourceHash } : {}), - file: { path: filePath, sha256 }, - ...(input.fromExport ? { fromExport: input.fromExport } : {}), - }); - recordRejects(run, checks, adopted); + const run = startRun(runsDir, { + kind: "import", + target, + source, + file: { path: filePath, sha256 }, + }); + recordRejects(run, checks); for (const user of withoutPassword) { run.append({ sourceId: user.userId, @@ -842,8 +563,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { }); } - const summary = - checks.importable.length > 0 || attachOnly.length > 0 + const summary: ImportSummary = + checks.importable.length > 0 ? await withProgress( { total: checks.importable.length, verb: "created" }, async (progress) => @@ -852,8 +573,6 @@ export async function run(rawOptions: MigrateRunOptions): Promise { secretKey, limits, record: run.append, - attachOnly, - adopted, skipPasswordRequirement: !options.requirePassword, progress, }), @@ -874,7 +593,6 @@ export async function run(rawOptions: MigrateRunOptions): Promise { { target, run: record, - resume: resume.kind, checks: checksJson(checks), result: { created: summary.successful, @@ -900,15 +618,11 @@ export async function run(rawOptions: MigrateRunOptions): Promise { )) { log.info(line); } - if (record.status === "complete") { - log.blank(); - for (const line of cleanupLines(runsDir, record)) log.info(line); - } const steps = summary.failed > 0 - ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS(record.id) - : NEXT_STEPS.MIGRATE_DONE(record.id); + ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS(run.dir) + : NEXT_STEPS.MIGRATE_DONE(run.dir); setNextSteps(steps); printAgentNextSteps(steps); }, diff --git a/packages/cli-core/src/commands/migrate/wizard.test.ts b/packages/cli-core/src/commands/migrate/wizard.test.ts index 824e0ebb7..387d42d79 100644 --- a/packages/cli-core/src/commands/migrate/wizard.test.ts +++ b/packages/cli-core/src/commands/migrate/wizard.test.ts @@ -26,7 +26,7 @@ mock.module("../../lib/prompts.ts", () => ({ editor: async () => "{}", })); -const { promptForFile, promptForFirebaseHashConfig, promptForSource } = await import("./wizard.ts"); +const { promptForFile, promptForSource } = await import("./wizard.ts"); let workDir: string; let originalCwd: string; @@ -56,24 +56,16 @@ const selectCall = (index: number): SelectPrompt | undefined => mockSelect.mock. describe("promptForSource", () => { test("is built from the registry, so every platform appears", async () => { - mockSelect.mockResolvedValue("auth0"); - - expect(await promptForSource()).toBe("auth0"); - expect(selectCall(0)?.choices.map((choice) => choice.value)).toEqual([ - "clerk", - "auth0", - "authjs", - "betterauth", - "firebase", - "supabase", - "workos", - ]); + mockSelect.mockResolvedValue("supabase"); + + expect(await promptForSource()).toBe("supabase"); + expect(selectCall(0)?.choices.map((choice) => choice.value)).toEqual(["clerk", "supabase"]); }); test("labels each choice with the source's display name", async () => { mockSelect.mockResolvedValue("clerk"); await promptForSource(); - expect(selectCall(0)?.choices.map((choice) => choice.name)).toContain("Better Auth"); + expect(selectCall(0)?.choices.map((choice) => choice.name)).toContain("Supabase"); }); }); @@ -104,28 +96,3 @@ describe("promptForFile", () => { expect(check(value)).toMatch(message); }); }); - -describe("promptForFirebaseHashConfig", () => { - test("collects all four parameters as a set", async () => { - mockText - .mockResolvedValueOnce("SIGNER") - .mockResolvedValueOnce("Bw==") - .mockResolvedValueOnce("8") - .mockResolvedValueOnce("14"); - - expect(await promptForFirebaseHashConfig()).toEqual({ - base64_signer_key: "SIGNER", - base64_salt_separator: "Bw==", - rounds: 8, - mem_cost: 14, - }); - }); - - // An export with no password hashes needs none of them. - test("stops when the signer key is left blank", async () => { - mockText.mockResolvedValueOnce(""); - - expect(await promptForFirebaseHashConfig()).toBeUndefined(); - expect(mockText).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/cli-core/src/commands/migrate/wizard.ts b/packages/cli-core/src/commands/migrate/wizard.ts index 39c9fae7a..bb0fbd70a 100644 --- a/packages/cli-core/src/commands/migrate/wizard.ts +++ b/packages/cli-core/src/commands/migrate/wizard.ts @@ -1,20 +1,17 @@ /** * The prompts behind an interactive `clerk migrate import`. * - * Each one fills in exactly one thing the command was not given: the file, - * the source when the file does not name its own, and Firebase's hash - * parameters when neither the flags nor the export carry them. + * Each one fills in exactly one thing the command was not given: the file, or + * the source. * * Nothing here runs for an agent, a non-TTY run or `--json`: `run` raises a * usage error naming what to pass instead. */ import { select } from "../../lib/listage.ts"; -import { log } from "../../lib/log.ts"; import { text } from "../../lib/prompts.ts"; import { fileExists, getFileType } from "./lib/transform.ts"; import { sources } from "./sources/registry.ts"; -import type { FirebaseHashConfig } from "./types.ts"; /** Trims a description down to a single readable hint line. */ function hint(description: string): string { @@ -48,49 +45,3 @@ export async function promptForFile(): Promise { }); return answer.trim(); } - -async function askNumber(label: string): Promise { - const answer = await text({ - message: label, - validate: (value) => { - const parsed = Number(value?.trim()); - return Number.isInteger(parsed) && parsed > 0 ? undefined : "Enter a positive whole number"; - }, - }); - return Number(answer.trim()); -} - -/** - * Collects Firebase's four hash parameters. - * - * Asked as a set because a partial set produces a digest that verifies against - * nothing. Pressing enter at the first leaves the config unset, which is - * correct for an export with no password hashes. - */ -export async function promptForFirebaseHashConfig(): Promise { - log.info( - "Firebase password hashes need the project's hash parameters. Find them in the Firebase console under Authentication → Users → (⋮) → Password hash parameters.", - ); - log.info("Pass the four --firebase-* flags to skip these prompts on the next run."); - - const signerKey = ( - await text({ - message: "base64 signer key (leave blank if this export has no passwords)", - }) - ).trim(); - if (!signerKey) return undefined; - - const saltSeparator = ( - await text({ - message: "base64 salt separator", - validate: (value) => (value?.trim() ? undefined : "Required alongside the signer key"), - }) - ).trim(); - - return { - base64_signer_key: signerKey, - base64_salt_separator: saltSeparator, - rounds: await askNumber("rounds"), - mem_cost: await askNumber("mem cost"), - }; -} diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index 487b26ed1..e11e94a50 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -75,13 +75,9 @@ export const NEXT_STEPS = { "Run `clerk apps list` to see your other applications", "Run `clerk config pull` to inspect the live configuration of this instance", ], - MIGRATE_DONE: (runId: string) => [ - `Run \`clerk migrate runs ${runId}\` to see what happened to each user`, - `Run \`clerk migrate undo ${runId}\` to delete the users it created`, - ], - MIGRATE_DONE_WITH_ERRORS: (runId: string) => [ - `Run \`clerk migrate runs ${runId}\` to see every user that failed and why`, - `Run \`clerk migrate undo ${runId}\` to delete the users it created`, + MIGRATE_DONE: (runFolder: string) => [`See each user's outcome in ${runFolder}/users.ndjson`], + MIGRATE_DONE_WITH_ERRORS: (runFolder: string) => [ + `See every user that failed, and why, in ${runFolder}/users.ndjson`, ], } as const; @@ -103,9 +99,9 @@ export function printNextSteps(steps: readonly string[]): void { * * Opt-in rather than folded into `printNextSteps`, because most steps are a * nudge towards a command someone might like to run next. The migrate ones are - * not: they name the log file holding the per-user record of what landed, and - * the command that undoes the run. An agent that imported 10,000 users and lost - * 300 of them needs both, and has no gutter to read them from. + * not: they name the file holding the per-user record of what landed. An agent + * that imported 10,000 users and lost 300 of them needs it, and has no gutter + * to read it from. */ export function printAgentNextSteps(steps: readonly string[]): void { if (isHuman()) return; diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index 35a1152ba..3c148aee4 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -221,9 +221,20 @@ describe("generateCompletions", () => { }); test("completes --source with the built-in migrate sources", () => { - const names = completionNames("migrate", "import", "--source", ""); - expect(names).toContain("clerk"); - expect(names).toContain("betterauth"); + const saved = process.env.CLERK_EXPERIMENTAL; + process.env.CLERK_EXPERIMENTAL = "migrate"; + try { + const names = generateCompletions(createProgram(), [ + "migrate", + "import", + "--source", + "", + ]).completions.map((c) => c.name); + expect(names).toEqual(["clerk", "supabase"]); + } finally { + if (saved === undefined) delete process.env.CLERK_EXPERIMENTAL; + else process.env.CLERK_EXPERIMENTAL = saved; + } }); test("returns empty for options with unknown values (file paths)", () => { @@ -278,12 +289,6 @@ describe("generateCompletions", () => { expect(names).toContain("settings"); }); - test("migrate sources: suggests the built-in sources", () => { - const names = completionNames("migrate", "sources", ""); - expect(names).toContain("supabase"); - expect(names).toContain("workos"); - }); - test("open dashboard: filters subpaths by prefix", () => { const names = completionNames("open", "dashboard", "u"); expect(names).toContain("users"); diff --git a/test/e2e/migrate.test.ts b/test/e2e/migrate.test.ts index 226a85e0e..44cba7d8d 100644 --- a/test/e2e/migrate.test.ts +++ b/test/e2e/migrate.test.ts @@ -2,8 +2,9 @@ * Live-BAPI tests for `clerk migrate import`, covering what only a real * instance can answer: * - * - A Better Auth scrypt hash, sent as `scrypt_werkzeug`, verifies against the - * password it was made from. A unit test can only check the string shape. + * - A Supabase export round-trips: the dry run counts it without writing, the + * import creates every user, and each bcrypt hash verifies against the + * password it was made from. * - A user whose only email is unverified, imported into an instance that * requires an email, is refused by Clerk. The import's checks reject that * user up front on the strength of this test, so it checks both halves. @@ -13,7 +14,7 @@ */ import { afterAll, beforeAll, expect, test } from "bun:test"; -import { randomBytes, scryptSync } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -23,7 +24,8 @@ const CLI_PATH = join(import.meta.dir, "../../packages/cli-core/src/cli.ts"); let APP_ID: string; let workDir: string; let emailRequired = false; -const importRuns: string[] = []; +/** Every Clerk user an import created, deleted in `afterAll`. */ +const createdIds: string[] = []; async function cli(args: string[]) { return Bun.$`bun ${CLI_PATH} ${args} --app ${APP_ID}` @@ -31,6 +33,7 @@ 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) @@ -58,77 +61,99 @@ beforeAll(async () => { emailRequired = Boolean(authEmail?.required_for_sign_up); }); -// Undo every import, so the test app does not fill up with migrated users. +// Delete every imported user, so the test app does not fill up. afterAll(async () => { - await Promise.all(importRuns.map(async (runId) => cli(["migrate", "undo", runId, "--yes"]))); + await Promise.all(createdIds.map(async (id) => cli(["api", `/users/${id}`, "-X", "DELETE"]))); rmSync(workDir, { recursive: true, force: true }); }, 60_000); /** - * Imports `users` as a Better Auth export and returns each user's latest run - * line. A user has several (`creating`, then `created`); the last one wins, - * as it does for `runs` and `undo`. + * Each user's latest line in run `runId`. A user has several (`creating`, + * then `created`); the last one wins. */ -async function importBetterAuth(users: Record[], extra: string[] = []) { - const file = join(workDir, `betterauth-${randomBytes(4).toString("hex")}.json`); - writeFileSync(file, JSON.stringify(users)); - - await cli(["migrate", "import", file, "--source", "betterauth", "--yes", ...extra]); - - const runsDir = join(workDir, "runs"); - const [runId] = readdirSync(runsDir) - .filter( - (id) => JSON.parse(readFileSync(join(runsDir, id, "run.json"), "utf-8")).kind === "import", - ) - .filter((id) => !importRuns.includes(id)); - if (!runId) throw new Error("The import recorded no run."); - importRuns.push(runId); - +function latestLines(runId: string): Record[] { const latest = new Map>(); - for (const line of readFileSync(join(runsDir, runId, "users.ndjson"), "utf-8") + for (const line of readFileSync(join(workDir, "runs", runId, "users.ndjson"), "utf-8") .trim() .split("\n")) { const parsed = JSON.parse(line) as Record; latest.set(parsed.sourceId, parsed); } + for (const line of latest.values()) { + if (line.status === "created" && typeof line.clerkId === "string") + createdIds.push(line.clerkId); + } return [...latest.values()]; } -/** A password hashed exactly the way Better Auth's default hasher does it. */ -function betterAuthHash(password: string): string { - const salt = randomBytes(16).toString("hex"); - const key = scryptSync(password.normalize("NFKC"), salt, 64, { - N: 16384, - r: 16, - p: 1, - maxmem: 128 * 16384 * 16 * 2, - }); - return `${salt}:${key.toString("hex")}`; +function writeExport(users: Record[]): string { + const file = join(workDir, `supabase-${randomBytes(4).toString("hex")}.json`); + writeFileSync(file, JSON.stringify(users)); + return file; } -test("a Better Auth scrypt hash imports and verifies against its password", async () => { - const hex = randomBytes(6).toString("hex"); - const password = `Migrate${hex}!1`; - - const [line] = await importBetterAuth([ - { - user_id: `ba_${hex}`, - email: `${hex}+clerk_test@clerkcookie.com`, - email_verified: true, - password_hash: betterAuthHash(password), - }, +test("a Supabase export dry-runs, imports, and its passwords verify", async () => { + const users = await Promise.all( + [0, 1].map(async () => { + const hex = randomBytes(6).toString("hex"); + const password = `Migrate${hex}!1`; + return { + password, + record: { + id: `sb_${hex}`, + email: `e2e-${hex}+clerk_test@clerkcookie.com`, + email_confirmed_at: "2024-06-29 20:25:06+00", + encrypted_password: await Bun.password.hash(password, { algorithm: "bcrypt", cost: 10 }), + }, + }; + }), + ); + const file = writeExport(users.map((user) => user.record)); + + const dryRun = await cli([ + "migrate", + "import", + file, + "--source", + "supabase", + "--dry-run", + "--json", ]); - expect(line).toMatchObject({ status: "created" }); - - const verify = await cli([ - "api", - `/users/${line?.clerkId as string}/verify_password`, - "-X", - "POST", - "-d", - JSON.stringify({ password }), + expect(JSON.parse(dryRun.stdout.toString())).toMatchObject({ + dryRun: true, + checks: { importable: 2 }, + }); + expect(readdirSync(workDir)).not.toContain("runs"); + + const imported = await cli([ + "migrate", + "import", + file, + "--source", + "supabase", + "--yes", + "--json", ]); - expect(JSON.parse(verify.stdout.toString())).toMatchObject({ verified: true }); + const result = JSON.parse(imported.stdout.toString()) as { + run: { id: string }; + result: { created: number }; + }; + expect(result.result.created).toBe(2); + + const lines = latestLines(result.run.id); + for (const { password, record } of users) { + const line = lines.find((candidate) => candidate.sourceId === record.id); + expect(line).toMatchObject({ status: "created" }); + const verify = await cli([ + "api", + `/users/${line?.clerkId as string}/verify_password`, + "-X", + "POST", + "-d", + JSON.stringify({ password }), + ]); + expect(JSON.parse(verify.stdout.toString())).toMatchObject({ verified: true }); + } }, 60_000); test("a user whose only email is unverified is refused where email is required", async () => { @@ -150,12 +175,22 @@ test("a user whose only email is unverified is refused where email is required", expect(direct.exitCode).not.toBe(0); // And the import's checks reject them before asking Clerk. - const [line] = await importBetterAuth( - [{ user_id: `ba_${hex}`, email: `${hex}+clerk_test@clerkcookie.com`, email_verified: false }], - ["--allow-partial"], - ); - expect(line).toMatchObject({ - status: "skipped", - reason: "only has an unverified email, and this instance requires an email", - }); + const file = writeExport([{ id: `sb_${hex}`, email: `e2e-${hex}+clerk_test@clerkcookie.com` }]); + const imported = await cli([ + "migrate", + "import", + file, + "--source", + "supabase", + "--allow-partial", + "--yes", + "--json", + ]); + const { run } = JSON.parse(imported.stdout.toString()) as { run: { id: string } }; + expect(latestLines(run.id)).toEqual([ + expect.objectContaining({ + status: "skipped", + reason: "only has an unverified email, and this instance requires an email", + }), + ]); }, 60_000); From 4ef5760b81f5b433cdac4ace3893c6c4e59fb77d Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 12:59:23 -0400 Subject: [PATCH 03/39] docs(changeset): Add an experimental clerk migrate import for Clerk and Supabase exports Co-Authored-By: Claude Opus 5.5 --- .changeset/migrate-import.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/migrate-import.md diff --git a/.changeset/migrate-import.md b/.changeset/migrate-import.md new file mode 100644 index 000000000..daaf49a22 --- /dev/null +++ b/.changeset/migrate-import.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add an experimental `clerk migrate import` that checks a Clerk or Supabase user export against your instance and imports it. It is hidden and refuses to run unless `CLERK_EXPERIMENTAL=migrate` is set. From 234b375dcae96b85ba40ae9240c8972cb204529b Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 16:56:14 -0400 Subject: [PATCH 04/39] test(migrate): expect an unknown hasher to be rejected, not abort the import slice-1a now fails the row instead of throwing, so the import is refused like any other reject and `--allow-partial` can skip it. Update the tests and README to match. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 7 ++----- .../cli-core/src/commands/migrate/run-interactive.test.ts | 4 ++-- packages/cli-core/src/commands/migrate/run.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index b22248c4e..20e22ef1e 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -225,9 +225,6 @@ honouring `Retry-After` when the response carries it — and retries up to 5 times before the user is recorded as failed. The command exits 1 if any user failed. -An **unrecognized password hasher** aborts the whole run before anything is -sent, because it would import credentials nobody can sign in with. - `--require-password` records each user it leaves out as `skipped`, so the run ends `partial`. @@ -465,8 +462,8 @@ source actually used: `pbkdf2_sha256_django`, `pbkdf2_sha512`, `pbkdf2_sha512_hex`, `scrypt_firebase`, `scrypt_werkzeug`, `sha256`, `sha256_salted`, `sha512_symfony` -An unrecognized hasher aborts the run rather than importing credentials nobody -can sign in with. +A user with an unrecognized hasher is rejected by the checks, naming the +hasher, like any other invalid user. **Metadata.** 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 0f42bc22d..9db4f2536 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -167,7 +167,7 @@ describe("consent", () => { expect(created()).toHaveLength(0); }); - test("an unrecognized password hasher aborts before any request", async () => { + test("an unrecognized password hasher is rejected before any request", async () => { fs.writeFileSync( path.join(workDir, "export.json"), JSON.stringify([ @@ -180,7 +180,7 @@ describe("consent", () => { ]), ); - await expect(run(importOptions)).rejects.toThrow(/Invalid password hasher/); + await expect(run(importOptions)).rejects.toThrow(/1 user would be rejected/); expect(created()).toHaveLength(0); }); }); diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index d8f06c046..d7bc7e256 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -266,7 +266,7 @@ describe("run", () => { expect(record?.status).toBe("partial"); }); - test("aborts before any API call when the hasher is unrecognized", async () => { + test("rejects a user with an unrecognized hasher, naming it", async () => { fs.writeFileSync( path.join(workDir, "export.json"), JSON.stringify([ @@ -280,8 +280,8 @@ describe("run", () => { ); const error = (await run(baseOptions).catch((caught: unknown) => caught)) as CliError; - expect(error.message).toContain("Invalid password hasher"); - // A usage error, not "some users failed" (exit 1). + expect(error.message).toContain("1 user would be rejected, so nothing was imported"); + expect(captured.err).toContain('Unknown password hasher "rot13"'); expect(error.exitCode).toBe(EXIT_CODE.USAGE); expect(created()).toHaveLength(0); }); From dfe6ccb0c08f13ea1ae44c21fa6af95744d3405f Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 19:20:16 -0400 Subject: [PATCH 05/39] test(migrate): restore the interactive tests' mode instead of forcing it `setMode(getMode())` pinned whatever mode had been inferred, since mode.ts cannot clear a forced mode. Drive `CLERK_MODE` and restore it instead, as cli-program.test.ts does, and correct the header: `--parallel` isolates each file, so the prompts mock does not leak. Co-Authored-By: Claude Opus 5.5 --- .../commands/migrate/run-interactive.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) 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 9db4f2536..ac53a2472 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -2,18 +2,16 @@ * The human-mode half of `migrate import`: the prompts fill in what was not * passed, and nothing is written until the operator says yes. * - * Kept in its own file because `mock.module` registrations are process-lifetime, - * and `bun test --parallel` puts several files in each worker — so a mocked - * `prompts.ts` would leak into any file that later lands in the same worker and - * imports the real one. Human mode itself needs no mock: `setMode` is the - * supported override. + * Kept in its own file because `mock.module` replaces `prompts.ts` for the whole + * file; `bun test --parallel` isolates each file, so the mock ends with it. + * Human mode is set through `CLERK_MODE` rather than `setMode`, because + * `mode.ts` has no way to clear a forced mode once the file is done. */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { getMode, setMode, type Mode } from "../../mode.ts"; import { listageStubs, useCaptureLog } from "../../test/lib/stubs.ts"; const mockSelect = mock(async () => "clerk" as unknown); @@ -21,7 +19,7 @@ const mockText = mock(async () => "export.json" as unknown); let confirmAnswer = true; /** Every confirmation the run put up, in order — the wording is the assertion. */ let confirmMessages: string[] = []; -let originalMode: Mode; +let originalMode: string | undefined; mock.module("../../lib/listage.ts", () => ({ ...listageStubs, @@ -61,8 +59,8 @@ const EXPORT = [ const runsDir = () => path.join(workDir, ".clerk", "migrate"); beforeAll(() => { - originalMode = getMode(); - setMode("human"); + originalMode = process.env.CLERK_MODE; + process.env.CLERK_MODE = "human"; originalCwd = process.cwd(); originalFetch = globalThis.fetch; workDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-interactive-"))); @@ -72,7 +70,8 @@ beforeAll(() => { }); afterAll(() => { - setMode(originalMode); + if (originalMode === undefined) delete process.env.CLERK_MODE; + else process.env.CLERK_MODE = originalMode; globalThis.fetch = originalFetch; _setConfigDir(undefined); process.chdir(originalCwd); From 5bf0176bb4d6cb780b2324703eb5a4265673ded9 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 14:56:56 -0400 Subject: [PATCH 06/39] docs(migrate): match the import's README and tests to the provider and --app fixes A Supabase user on a disabled provider is now rejected only without a verified email or phone the instance signs in with by code or link, and a fix printed for a --secret-key import carries --app . Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 9 ++++++--- packages/cli-core/src/commands/migrate/run.test.ts | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 20e22ef1e..6a7730d09 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -273,8 +273,9 @@ after them. They sort the users three ways: 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). The checks offer to turn on - the first kind; nothing can turn on the second + (Figma, Kakao, Keycloak, WorkOS, Zoom, Fly), and it has no verified 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, email, phone or username repeats an earlier user in the file that passes the checks above. Usernames compare case-insensitively and phones ignore punctuation. The first record in the file is kept, whatever @@ -316,7 +317,9 @@ Or change the instance instead ``` Each fix names its instance with `--instance`, so it changes the instance the -import targets, whatever the key's source. When Clerk could not name the +import targets, whatever the key's source. A key from `--secret-key` or +`CLERK_SECRET_KEY` names no app, so its fix reads `--app `: fill in the +app that owns the instance. When Clerk could not name the instance (a `key_…` fallback ID), the fix points at the Dashboard instead: in `--json` it carries `url` in place of `command`. diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index d7bc7e256..83ab7de5b 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -385,7 +385,7 @@ describe("run", () => { "only has an unverified email, and this instance requires an email", ); expect(captured.err).toContain( - `clerk config patch --instance ins_1 --json '{"auth_email":{"required_for_sign_up":false}}'`, + `clerk config patch --app --instance ins_1 --json '{"auth_email":{"required_for_sign_up":false}}'`, ); }); From d97aca50ea7b65e3af8fa2f21c5a671670bdb5f7 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 15:29:49 -0400 Subject: [PATCH 07/39] fix(migrate): filter --require-password's users with a Set, not a scan Each user was looked up in the list of users without a password, so the filter was quadratic: on a 500k-user export with half of them OAuth-only, the run stalled before any check. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/run.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index ddab3bec3..69e1ab561 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -427,7 +427,10 @@ export async function run(rawOptions: MigrateRunOptions): Promise { `--require-password: leaving out ${plural(withoutPassword.length, "user")} without a password.`, ); } - users = users.filter((user) => !withoutPassword.includes(user)); + // A Set, not `includes`: half a large Supabase export can lack a + // password, and a scan per user would stall before any check runs. + const leftOut = new Set(withoutPassword); + users = users.filter((user) => !leftOut.has(user)); } let supabaseRows: Record[] | undefined; From d94780f1eb1c641abc5f5923be57f6ef56366f16 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 15:30:57 -0400 Subject: [PATCH 08/39] fix(migrate): do not ask about legal acceptance under --yes -y is "import without prompting", but the legal-acceptance question still came up at a terminal, so a script relying on -y stopped there. Under -y the checks now reject those users, and --skip-legal-checks is the way to import them. The human-mode prompt is tested for the first time. Co-Authored-By: Claude Opus 5.5 --- .../commands/migrate/run-interactive.test.ts | 37 +++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 10 ++++- 2 files changed, 46 insertions(+), 1 deletion(-) 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 ac53a2472..00136576a 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -17,6 +17,8 @@ import { listageStubs, useCaptureLog } from "../../test/lib/stubs.ts"; const mockSelect = mock(async () => "clerk" as unknown); const mockText = mock(async () => "export.json" as unknown); let confirmAnswer = true; +/** User settings the instance serves, or none (the settings read fails). */ +let instanceSettings: Record | undefined; /** Every confirmation the run put up, in order — the wording is the assertion. */ let confirmMessages: string[] = []; let originalMode: string | undefined; @@ -82,6 +84,7 @@ afterAll(() => { beforeEach(() => { requests = []; confirmAnswer = true; + instanceSettings = undefined; confirmMessages = []; mockSelect.mockReset(); mockText.mockReset(); @@ -99,6 +102,16 @@ beforeEach(() => { } if (url.pathname === "/v1/users" && method === "GET") return Response.json([]); if (url.pathname === "/v1/users/count") return Response.json({ total_count: 0 }); + if (url.pathname === "/v1/domains") { + if (!instanceSettings) return new Response("nope", { status: 500 }); + return Response.json({ + data: [{ is_satellite: false, frontend_api_url: "https://fapi.example.com" }], + }); + } + if (url.pathname.includes("/v1/dev_browser")) return Response.json({ token: "jwt" }); + if (url.pathname.includes("/v1/environment")) { + return Response.json({ user_settings: instanceSettings }); + } return Response.json({ id: "user_created" }); }) as unknown as typeof fetch; }); @@ -183,3 +196,27 @@ describe("consent", () => { expect(created()).toHaveLength(0); }); }); + +describe("legal consent", () => { + beforeEach(() => { + instanceSettings = { + attributes: { email_address: { enabled: true } }, + sign_up: { legal_consent_enabled: true }, + }; + }); + + test("asks, and a yes imports the users without it", async () => { + await run(importOptions); + + expect(confirmMessages[0]).toContain("no legal acceptance on record"); + expect(created()).toHaveLength(2); + }); + + // `-y` is "import without prompting": the checks reject these users instead. + test("--yes does not ask, and the checks reject them", async () => { + await expect(run({ ...importOptions, yes: true })).rejects.toThrow(/2 users would be rejected/); + + expect(confirmMessages).toEqual([]); + expect(created()).toHaveLength(0); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 69e1ab561..29d5c63c2 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -455,7 +455,15 @@ export async function run(rawOptions: MigrateRunOptions): Promise { const withoutLegal = settings?.sign_up?.legal_consent_enabled ? users.filter((user) => !user.legalAcceptedAt && !user.skipLegalChecks).length : 0; - if (!skipLegalChecks && withoutLegal > 0 && !options.dryRun && canPrompt(options)) { + // `-y` imports without prompting, so it does not stop here either: the + // checks reject these users, and --skip-legal-checks is the way through. + if ( + !skipLegalChecks && + withoutLegal > 0 && + !options.dryRun && + !options.yes && + canPrompt(options) + ) { skipLegalChecks = await confirm({ message: `${plural(withoutLegal, "user")} ${withoutLegal === 1 ? "has" : "have"} no legal acceptance on record, which this instance requires. Import them without it?`, default: false, From 660f1a3df2b4a293d5575cb893b1cd69571bfef7 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 15:31:33 -0400 Subject: [PATCH 09/39] fix(migrate): count --require-password's left-out users in the --json output They are removed before the checks, and the only notice was a log line --json skips, so a preview's checks.total came up short with nothing to explain it. The JSON now carries withoutPassword. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 3 ++- packages/cli-core/src/commands/migrate/run.test.ts | 11 +++++++++++ packages/cli-core/src/commands/migrate/run.ts | 10 +++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 6a7730d09..2eb8d6342 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -231,7 +231,8 @@ ends `partial`. `--json` returns `{ target, run, 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`, with -`run: null`. +`run: null`. With `--require-password`, `withoutPassword` counts the users it +left out before the checks, so `checks.total` plus it is the file's size. #### Re-running diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 83ab7de5b..9a65d9c3f 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -266,6 +266,17 @@ describe("run", () => { expect(record?.status).toBe("partial"); }); + // They never reach the checks, so the preview has to count them itself. + test("--require-password counts the users it left out in the --json preview", async () => { + await run({ ...baseOptions, requirePassword: true, dryRun: true, json: true }); + + expect(JSON.parse(captured.out)).toMatchObject({ + dryRun: true, + withoutPassword: 1, + checks: { total: 1 }, + }); + }); + test("rejects a user with an unrecognized hasher, naming it", async () => { fs.writeFileSync( path.join(workDir, "export.json"), diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 29d5c63c2..d70b6f0ba 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -488,9 +488,16 @@ export async function run(rawOptions: MigrateRunOptions): Promise { ); const refused = checks.rejects.length > 0 && !options.allowPartial; + // The users --require-password left out never reach the checks, so the + // JSON says how many, or `checks.total` would not add up to the file. + const leftOut = withoutPassword.length > 0 ? { withoutPassword: withoutPassword.length } : {}; const preview = (extra: Record) => log.data( - JSON.stringify({ target, run: null, checks: checksJson(checks), ...extra }, null, 2), + JSON.stringify( + { target, run: null, checks: checksJson(checks), ...leftOut, ...extra }, + null, + 2, + ), ); if (!options.json) printChecks(checks); @@ -605,6 +612,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { target, run: record, checks: checksJson(checks), + ...leftOut, result: { created: summary.successful, failed: summary.failed, From 4428b26b580d845ea904cca16aab07229c4c78ca Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 15:31:44 -0400 Subject: [PATCH 10/39] test(migrate): register the e2e import's users for cleanup before asserting latestLines() records the created IDs that afterAll deletes, but ran after the created-count assertion, so a failed run left its users in the shared test app. Co-Authored-By: Claude Opus 5.5 --- test/e2e/migrate.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/e2e/migrate.test.ts b/test/e2e/migrate.test.ts index 44cba7d8d..3aeaffdea 100644 --- a/test/e2e/migrate.test.ts +++ b/test/e2e/migrate.test.ts @@ -138,9 +138,10 @@ test("a Supabase export dry-runs, imports, and its passwords verify", async () = run: { id: string }; result: { created: number }; }; - expect(result.result.created).toBe(2); - + // Read first: it registers the created users for cleanup, which a failed + // assertion would otherwise skip, leaving them in the shared test app. const lines = latestLines(result.run.id); + expect(result.result.created).toBe(2); for (const { password, record } of users) { const line = lines.find((candidate) => candidate.sourceId === record.id); expect(line).toMatchObject({ status: "created" }); From 1488da9712ca7a0d0df63a1f78f3dbba404469ba Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 15:32:35 -0400 Subject: [PATCH 11/39] fix(migrate): sign in for an --app import instead of failing on the key --app was let through as if it needed no account, but it resolves its key through the Platform API, so a signed-out human got an auth failure rather than the sign-in prompt. It now goes through the sign-in, though not the linking, and only a secret key skips both. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/run.test.ts | 14 ++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 10 ++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 9a65d9c3f..42bc4dd99 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -199,6 +199,20 @@ describe("run", () => { } }); + // --app needs an account to resolve its key, unlike --secret-key. + test("refuses an --app import when nobody is signed in", async () => { + const previous = process.env.CLERK_SECRET_KEY; + delete process.env.CLERK_SECRET_KEY; + try { + await expect( + run({ source: "clerk", input: "export.json", yes: true, app: "app_123" }), + ).rejects.toThrow(/Not logged in/); + expect(requests).toHaveLength(0); + } finally { + if (previous !== undefined) process.env.CLERK_SECRET_KEY = previous; + } + }); + test("imports every user in the file end to end", async () => { await run(baseOptions); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index d70b6f0ba..6788ec22e 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -198,11 +198,13 @@ export function explainErrors(errors: Iterable, instanceType: InstanceTy * error naming whichever half is missing. */ async function ensureImportTarget(options: MigrateRunOptions): Promise { - // Each of these names the destination instance on its own, with no account + // A secret key names the destination instance on its own, with no account // and no linked directory involved — mirroring resolveBapiSecretKey. - if (options.secretKey || options.app || process.env.CLERK_SECRET_KEY) return; + if (options.secretKey || process.env.CLERK_SECRET_KEY) return; + // `--app` names it too, but resolves its key through the Platform API, which + // needs an account: it goes through the sign-in below, though not the link. // An unclaimed accountless application keeps its only secret key on disk. - if (await resolveKeylessTarget({ instance: options.instance })) return; + if (!options.app && (await resolveKeylessTarget({ instance: options.instance }))) return; const interactive = canPrompt(options); @@ -228,7 +230,7 @@ async function ensureImportTarget(options: MigrateRunOptions): Promise { // Left to the secret-key chain when non-interactive: its `not_linked` error // is the one every other command raises, and there is nothing to add to it. - if (interactive && !(await resolveProfile(process.cwd()))) { + if (interactive && !options.app && !(await resolveProfile(process.cwd()))) { log.info("This directory isn't linked to a Clerk application. Linking one first..."); await link({ skipIfLinked: true }); } From cdb4add22bd12b3d94e2a9f24e153c83ebdbf244 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 15:33:11 -0400 Subject: [PATCH 12/39] docs(migrate): describe the -y holder as it is in the import slice Its comment described readers (the credential retry, the export commands) that arrive in a later slice. It now says what holds here. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/index.ts | 6 ++--- .../src/commands/migrate/lib/assume-yes.ts | 22 ++++++------------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/index.ts b/packages/cli-core/src/commands/migrate/index.ts index 8440b281e..20e221721 100644 --- a/packages/cli-core/src/commands/migrate/index.ts +++ b/packages/cli-core/src/commands/migrate/index.ts @@ -40,9 +40,9 @@ export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = proce }, ]); - // `-y` is read below the action, so it is resolved once here rather than - // threaded through every call. Hooks are inherited, so this fires for every - // subcommand under `migrate`; one that declares no `-y` resolves to false. + // `-y` is resolved once here, for code below a command's options to read + // rather than having it passed down. Hooks are inherited, so this fires for + // every subcommand under `migrate`; one that declares no `-y` resolves to false. // // `--json` means nobody is reading a prompt, so it runs the command in agent // mode: every prompt in this tree already stands down for an agent, with the diff --git a/packages/cli-core/src/commands/migrate/lib/assume-yes.ts b/packages/cli-core/src/commands/migrate/lib/assume-yes.ts index dd277dad0..c61c7ef86 100644 --- a/packages/cli-core/src/commands/migrate/lib/assume-yes.ts +++ b/packages/cli-core/src/commands/migrate/lib/assume-yes.ts @@ -1,22 +1,14 @@ /** * Whether this run was given `-y`. * - * `-y` is not the same question as {@link isAgent}. Agent mode says the CLI - * *cannot* prompt; `-y` says the operator does not want it to. Most prompts - * only care about the first — a confirm is skipped by either — but the two - * places that take a default instead of asking need to know a human chose it, - * so the two cannot be collapsed into one flag. + * Not the same question as {@link isAgent}: agent mode says the CLI *cannot* + * prompt; `-y` says the operator does not want it to. * - * Held per-run rather than threaded through, because the readers are three - * layers below the command that parses it: `withInputRetry` sits under every - * credential prompt, and the export commands read it to decide what to print. - * Passing it down would put a `yes` parameter on every export handler - * signature on the way. This mirrors `mode.ts`, which resolves - * `--mode` once in a `preAction` hook and is read the same way. - * - * Set by the `migrate` group's `preAction` hook, so every subcommand under it - * is covered whether or not it declares the flag — one that does not simply - * resolves to `false`. + * Set once by the `migrate` group's `preAction` hook, so every subcommand under + * it is covered whether or not it declares the flag — one that does not simply + * resolves to `false`. Held per run, the way `mode.ts` holds `--mode`, so code + * below a command's options can read it without the flag being passed down. + * Nothing in `import` needs that: it reads `options.yes` directly. */ let assumeYes = false; From 2c63369a1e0c7abe6e5ce9099f2fe739ee3b8491 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 18:33:23 -0400 Subject: [PATCH 13/39] test(migrate): match the import's tests and README to the stricter checks The checks now reject a user without a password when the instance has no other way in, keep the identifiers Clerk keeps, and print `APP_ID` as the fix command's placeholder. The run tests' settings get an SSO sign-in so their passwordless users import, and the README describes the new rules. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 32 ++++++++++++------- .../commands/migrate/run-interactive.test.ts | 2 ++ .../cli-core/src/commands/migrate/run.test.ts | 19 +++++++++-- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 2eb8d6342..c797d85e1 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -258,18 +258,23 @@ after them. They sort the users three ways: - it lacks an identifier the instance requires. An email or phone counts only when it is verified, because an unverified one is attached after the user exists - - it has no identifier left once those the instance has turned off are - stripped + - it has no identifier left once the emails and phones Clerk would refuse + are stripped: those of an instance that neither has them on nor signs in + or does MFA with them. A username is kept: Clerk stores it with usernames + off - it lacks a first or last name the instance requires - it has an authenticator app secret or backup codes, and the instance has 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 + 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 without it (`skip_legal_checks`), with a warning - its username breaks the instance's username rules (length, letters, - the allowed special characters) + the allowed special characters). The checks offer the setting that allows + it. With usernames off, such a username is dropped instead, with a warning - its password is not the shape its hasher says (`bcrypt`, with a cost up to 15, `scrypt_firebase`, `argon2i`/`argon2id` and `scrypt_werkzeug` are checked; other hashers are not, and Clerk refuses a bad one at create) @@ -277,8 +282,10 @@ after them. They sort the users three ways: (Figma, Kakao, Keycloak, WorkOS, Zoom, Fly), and it has no verified 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, email, phone or username repeats an earlier user in the file - that passes the checks above. Usernames compare case-insensitively and + - 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 @@ -287,11 +294,12 @@ after them. They sort the users three ways: - a development instance: it is past the 100-user headroom (`CLERK_MIGRATE_DEV_USER_LIMIT` when Clerk raised it), counted in file order -- **Imported, but not everything comes across** — fields the instance is not - set up to store, fields Clerk has no place for (`Clerk won't 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, URL or - HTML). +- **Imported, but not everything comes across** — emails or phones the + instance is not set up to store, fields Clerk has no place for (`Clerk won't +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). 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 @@ -306,7 +314,7 @@ Checks 12: only has an unverified email, and this instance requires an email 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 is not set up to store + 6 users have a username, which this instance does not use: it is stored, and works only once usernames are turned on Clerk won't store: department (120 users) ✓ 108 users to import @@ -319,7 +327,7 @@ Or change the instance instead Each fix names its instance with `--instance`, so it changes the instance the import targets, whatever the key's source. A key from `--secret-key` or -`CLERK_SECRET_KEY` names no app, so its fix reads `--app `: fill in the +`CLERK_SECRET_KEY` names no app, so its fix reads `--app APP_ID`: fill in the app that owns the instance. When Clerk could not name the instance (a `key_…` fallback ID), the fix points at the Dashboard instead: in `--json` it carries `url` in place of `command`. 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 00136576a..91c676738 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -202,6 +202,8 @@ describe("legal consent", () => { instanceSettings = { attributes: { email_address: { enabled: true } }, sign_up: { legal_consent_enabled: true }, + // A way in besides a password, so users without one import. + enterprise_sso: { enabled: true }, }; }); diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 42bc4dd99..77ad149e3 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -70,7 +70,12 @@ describe("validateRunOptions", () => { type Stub = { /** What `/v1/environment` reports; `null` makes the settings unreadable. */ - settings?: { attributes?: object; social?: object; sign_up?: object } | null; + settings?: { + attributes?: object; + social?: object; + sign_up?: object; + enterprise_sso?: object; + } | null; /** Users already in the instance, as `GET /v1/users` returns them. */ existing?: { id: string; @@ -394,7 +399,11 @@ describe("run", () => { test("a user whose only email is unverified is rejected where email is required, with a fix", async () => { stubClerk({ - settings: { attributes: { email_address: { enabled: true, required: true } } }, + settings: { + attributes: { email_address: { enabled: true, required: true } }, + // A way in besides a password, so users without one import. + enterprise_sso: { enabled: true }, + }, }); fs.writeFileSync( path.join(workDir, "export.json"), @@ -410,7 +419,7 @@ describe("run", () => { "only has an unverified email, and this instance requires an email", ); expect(captured.err).toContain( - `clerk config patch --app --instance ins_1 --json '{"auth_email":{"required_for_sign_up":false}}'`, + `clerk config patch --app APP_ID --instance ins_1 --json '{"auth_email":{"required_for_sign_up":false}}'`, ); }); @@ -419,6 +428,8 @@ describe("run", () => { settings: { attributes: { email_address: { enabled: true } }, sign_up: { legal_consent_enabled: true }, + // A way in besides a password, so users without one import. + enterprise_sso: { enabled: true }, }, }); @@ -465,6 +476,8 @@ describe("run", () => { settings: { attributes: { email_address: { enabled: true } }, social: { oauth_google: { enabled: true } }, + // A way in besides a password, so users without one import. + enterprise_sso: { enabled: true }, }, }); fs.writeFileSync( From 5491e795f5c656df7aee97a403f9dfcd97b32718 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 18:48:46 -0400 Subject: [PATCH 14/39] fix(migrate): leave an interrupted import unfinished, and count users not sent A Ctrl-C in a non-TTY or agent run returns the import normally. The users it never sent have no line, so `finish()` counted only what was there and wrote `complete` with a `finishedAt`, which hid the interruption from anything that reads the run later. After a Ctrl-C the run now releases its lock without finishing, so it reads as interrupted. When the user quota stops the run instead, `finish()` is told how many users were never sent and settles on `partial`, and the summary and `--json` say "Not sent: N". Co-Authored-By: Claude Opus 5.5 --- .../commands/migrate/lib/run-store.test.ts | 18 ++++++ .../src/commands/migrate/lib/run-store.ts | 25 ++++++-- .../cli-core/src/commands/migrate/run.test.ts | 59 +++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 14 ++++- 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/run-store.test.ts b/packages/cli-core/src/commands/migrate/lib/run-store.test.ts index 48fab60e2..6112b8ab2 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.test.ts @@ -193,6 +193,24 @@ describe("locks", () => { }); // In case a folder's mode is ever looser than the run store sets it. + // Users never sent have no line to count, so the caller says how many. + test("a finish with users never sent is partial", () => { + const sent = startRun(runsDir, init); + sent.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + expect(sent.finish().status).toBe("complete"); + + const stopped = startRun(runsDir, init); + stopped.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + expect(stopped.finish({ notSent: 2 }).status).toBe("partial"); + }); + + test("release leaves the run unfinished and unlocked", () => { + const run = startRun(runsDir, init); + run.release(); + expect(readRun(runsDir, run.record.id)?.finishedAt).toBeUndefined(); + expect(fs.existsSync(path.join(run.dir, "lock"))).toBe(false); + }); + test.skipIf(process.platform === "win32")("run files are owner-only", () => { const run = startRun(runsDir, init); run.append({ sourceId: "u1", status: "creating" }); 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 acc0017ef..1c25c89e5 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -312,10 +312,16 @@ export type Run = { /** * Counts the outcomes, settles the status and releases the lock. * - * `partial` when any user failed, was skipped, may not have been created or - * has identifiers still pending, `complete` otherwise. + * `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 + * still pending, `complete` otherwise. */ - finish(): RunRecord; + finish(options?: { notSent?: number }): RunRecord; + /** + * Releases the lock and leaves the run unfinished: with no `finishedAt` and + * no live holder, it reads as interrupted. + */ + release(): void; }; function openRun(runsDir: string, record: RunRecord): Run { @@ -336,7 +342,7 @@ function openRun(runsDir: string, record: RunRecord): Run { run.record = { ...run.record, ...patch }; writeRecord(runsDir, run.record); }, - finish() { + finish(options) { const lines = [...latestUserLines(runsDir, run.record.id).values()]; const counts = countLines(lines); // A created user whose extra identifiers never attached is left for a continue. @@ -344,15 +350,22 @@ function openRun(runsDir: string, record: RunRecord): Run { (line) => line.status === "created" && (line.pending?.length ?? 0) > 0, ).length; const unfinished = - (counts.failed ?? 0) + (counts.skipped ?? 0) + (counts.creating ?? 0) + pendingAttaches; + (counts.failed ?? 0) + + (counts.skipped ?? 0) + + (counts.creating ?? 0) + + (options?.notSent ?? 0) + + pendingAttaches; run.update({ counts, status: unfinished > 0 ? "partial" : "complete", finishedAt: new Date().toISOString(), }); - fs.rmSync(path.join(dir, LOCK_FILE), { force: true }); + run.release(); return run.record; }, + release() { + fs.rmSync(path.join(dir, LOCK_FILE), { force: true }); + }, }; return run; } diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 77ad149e3..cf99afe81 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -9,6 +9,7 @@ import { credentialStoreStubs, useCaptureLog } from "../../test/lib/stubs.ts"; // Every test below names its own `--secret-key`, which short-circuits the // signed-in check — except the one that asserts what happens without it. mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); +import { _resetInterruptState, abortInFlight, beginInterrupt } from "../../lib/signals.ts"; import { latestUserLines, readRun, type RunRecord } from "./lib/run-store.ts"; import { explainErrors, run, validateRunOptions } from "./run.ts"; @@ -562,6 +563,64 @@ describe("run", () => { }); }); + describe("stopped part-way", () => { + // `importUsers` returns normally on a Ctrl-C; the run must not read as done. + test("a Ctrl-C after the first create leaves the run unfinished, as interrupted", async () => { + process.env.CLERK_MIGRATE_CONCURRENCY_LIMIT = "1"; + const clerk = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const response = await clerk(input, init); + if (init?.method === "POST" && new URL(input.toString()).pathname === "/v1/users") { + beginInterrupt(); + abortInFlight(); + } + return response; + }) as typeof fetch; + try { + await run(baseOptions); + } finally { + _resetInterruptState(); + delete process.env.CLERK_MIGRATE_CONCURRENCY_LIMIT; + } + + const [record] = runsIn(runsDir()); + expect(record?.finishedAt).toBeUndefined(); + expect(fs.existsSync(path.join(runsDir(), record?.id ?? "", "lock"))).toBe(false); + expect(created()).toHaveLength(1); + }); + + test("the user quota leaves the rest not sent, and the run partial", async () => { + stubClerk(); + const clerk = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => + init?.method === "POST" && new URL(input.toString()).pathname === "/v1/users" + ? Response.json( + { + errors: [ + { + code: "user_quota_exceeded", + message: "user quota exceeded", + long_message: "You have reached your limit of 100 users.", + }, + ], + }, + { status: 403 }, + ) + : clerk(input, init)) as typeof fetch; + process.env.CLERK_MIGRATE_CONCURRENCY_LIMIT = "1"; + try { + await run({ ...baseOptions, json: true }); + } finally { + delete process.env.CLERK_MIGRATE_CONCURRENCY_LIMIT; + } + + expect(JSON.parse(captured.out)).toMatchObject({ + run: { status: "partial" }, + result: { created: 0, failed: 1, notSent: 1 }, + }); + }); + }); + describe("re-running", () => { // Slice 1 has no continuing: the second run sees the first run's users in // the instance, and refuses until --allow-partial. diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 6788ec22e..73a9b879c 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -31,6 +31,7 @@ import { quoteArg } from "../../lib/json-body.ts"; import { log } from "../../lib/log.ts"; import { NEXT_STEPS, printAgentNextSteps } from "../../lib/next-steps.ts"; import { confirm } from "../../lib/prompts.ts"; +import { interruptedExitCode } from "../../lib/signals.ts"; import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { isAgent, isHuman } from "../../mode.ts"; import { importUsers } from "./import-users.ts"; @@ -343,6 +344,8 @@ function formatSummary( `${red("Failed:")} ${summary.failed}`, ]; if (skipped > 0) lines.push(`${yellow("Skipped:")} ${skipped}`); + // The user quota stopped the run: these go out on a re-run. + if (summary.notSent > 0) lines.push(`${yellow("Not sent:")} ${summary.notSent}`); if (summary.errorBreakdown.size > 0) { lines.push("", bold("Error breakdown:")); @@ -601,10 +604,18 @@ export async function run(rawOptions: MigrateRunOptions): Promise { totalProcessed: 0, successful: 0, failed: 0, + notSent: 0, + droppedPhones: new Map(), validationFailed: 0, errorBreakdown: new Map(), }; - const record = run.finish(); + // A Ctrl-C returns the import early, and the users it never sent have + // no line to count. Left unfinished, the run reads as interrupted. + if (interruptedExitCode() !== null) { + run.release(); + return; + } + const record = run.finish({ notSent: summary.notSent }); if (summary.failed > 0) process.exitCode = 1; if (options.json) { @@ -618,6 +629,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { result: { created: summary.successful, failed: summary.failed, + notSent: summary.notSent, skipped: checks.rejects.length + withoutPassword.length, errors: [...summary.errorBreakdown].map(([error, count]) => ({ error, count })), }, From dbcc32c2f5c0921667347be78b10635fb7e4ee8f Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 18:49:50 -0400 Subject: [PATCH 15/39] fix(migrate): report the users imported without the phone Clerk refused When Clerk refused a phone and the create was retried without it, the summary said `Failed: 0`, `--json` said `errors: []`, and the development-instance SMS note never showed, because it read only failures. The refusal lived only on that user's line. The summary now lists them under "Imported without their phone", by Clerk's reason; `--json` carries them as `result.warnings`; and the error notes read these reasons too. The README says so. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 7 ++- .../cli-core/src/commands/migrate/run.test.ts | 53 +++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 14 ++++- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index c797d85e1..a9b127929 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -129,7 +129,8 @@ Each run is a folder named for its ID, `YYYYMMDD-HHmmss-xxxx`: A user's status is `creating`, `created`, `failed` or `skipped`. The last line for each `sourceId` wins. A `429` retry, an extra email or phone that did not -attach, and a validation failure all land in `error`. +attach, a first phone Clerk refused (which the summary also counts), and a +validation failure all land in `error`. `creating` is written as a user's `POST /v1/users` goes out. It stays the latest line when no answer says whether the create landed: an abort, a @@ -356,7 +357,9 @@ successful user. An attach with no answer stays `pending` on the user's line. The first phone gets the same treatment when Clerk refuses it — a country the instance does not support, or a number that is not E.164 — and the user has an -email: the create is retried without the phone, and the refusal is logged. +email: the create is retried without the phone. The user counts as imported, +and the summary lists them under "Imported without their phone", by Clerk's +reason (`result.warnings` in `--json`). #### Throughput diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index cf99afe81..9f82eb2c9 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -621,6 +621,59 @@ describe("run", () => { }); }); + describe("a phone Clerk refuses", () => { + const COUNTRY = + "Phone numbers from this country (France) are currently not supported. For more information, please contact support."; + + beforeEach(() => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { id: "u1", primary_email_address: "a@x.dev", primary_phone_number: "+33612345678" }, + ]), + ); + const clerk = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const body = init?.body ? (JSON.parse(init.body as string) as Record) : {}; + const isCreate = + init?.method === "POST" && new URL(input.toString()).pathname === "/v1/users"; + if (isCreate && body.phone_number) { + requests.push({ method: "POST", url: input.toString(), body }); + return Response.json( + { + errors: [ + { code: "unsupported_country_code", message: "unsupported", long_message: COUNTRY }, + ], + }, + { status: 403 }, + ); + } + return clerk(input, init); + }) as typeof fetch; + }); + + // The user imports, so it is no failure, but the phone is gone. + test("is counted in the summary, with the SMS note", async () => { + await run(baseOptions); + const err = Bun.stripANSI(captured.err); + expect(err).toContain("Failed: 0"); + expect(err).toContain(`Imported without their phone:\n 1 user: ${COUNTRY}`); + expect(err).toContain("Development instances block SMS"); + }); + + test("is a warning in --json", async () => { + await run({ ...baseOptions, json: true }); + expect(JSON.parse(captured.out)).toMatchObject({ + result: { + created: 1, + failed: 0, + errors: [], + warnings: [{ warning: `imported without their phone: ${COUNTRY}`, count: 1 }], + }, + }); + }); + }); + describe("re-running", () => { // Slice 1 has no continuing: the second run sees the first run's users in // the instance, and refuses until --allow-partial. diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 73a9b879c..e4236f5b4 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -352,10 +352,16 @@ function formatSummary( for (const [error, count] of summary.errorBreakdown) { lines.push(` ${plural(count, "user")}: ${error}`); } - for (const note of explainErrors(summary.errorBreakdown.keys(), instanceType)) { - lines.push("", note); + } + // Imported, so not failures, but the phone is gone: say so, and why. + if (summary.droppedPhones.size > 0) { + lines.push("", bold("Imported without their phone:")); + for (const [reason, count] of summary.droppedPhones) { + lines.push(` ${plural(count, "user")}: ${reason}`); } } + const explained = [...summary.errorBreakdown.keys(), ...summary.droppedPhones.keys()]; + for (const note of explainErrors(explained, instanceType)) lines.push("", note); lines.push("", dim(`Run ${record.id}: ${runFolder}`)); return lines; } @@ -632,6 +638,10 @@ export async function run(rawOptions: MigrateRunOptions): Promise { notSent: summary.notSent, skipped: checks.rejects.length + withoutPassword.length, errors: [...summary.errorBreakdown].map(([error, count]) => ({ error, count })), + warnings: [...summary.droppedPhones].map(([reason, count]) => ({ + warning: `imported without their phone: ${reason}`, + count, + })), }, }, null, From 9b7990fe5c4fc50f2f3d45095b204572833c5a0b Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 18:50:30 -0400 Subject: [PATCH 16/39] test(migrate): prove an import without --yes writes nothing to a real instance The E2E covered the dry run and the import but never the consent boundary. It now runs the import without `--yes` and expects exit 2, `consent: "required"`, no run folder and no users in Clerk. It also asserts each exit code, and reads the real import's `run.json` for its status and counts. Co-Authored-By: Claude Opus 5.5 --- test/e2e/migrate.test.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/test/e2e/migrate.test.ts b/test/e2e/migrate.test.ts index 3aeaffdea..df94ca644 100644 --- a/test/e2e/migrate.test.ts +++ b/test/e2e/migrate.test.ts @@ -2,9 +2,10 @@ * Live-BAPI tests for `clerk migrate import`, covering what only a real * instance can answer: * - * - A Supabase export round-trips: the dry run counts it without writing, the - * import creates every user, and each bcrypt hash verifies against the - * password it was made from. + * - A Supabase export round-trips: the dry run counts it without writing, an + * import without `--yes` writes nothing, the import creates every user and + * records the run, and each bcrypt hash verifies against the password it + * was made from. * - A user whose only email is unverified, imported into an instance that * requires an email, is refused by Clerk. The import's checks reject that * user up front on the strength of this test, so it checks both halves. @@ -119,12 +120,27 @@ test("a Supabase export dry-runs, imports, and its passwords verify", async () = "--dry-run", "--json", ]); + expect(dryRun.exitCode).toBe(0); expect(JSON.parse(dryRun.stdout.toString())).toMatchObject({ dryRun: true, checks: { importable: 2 }, }); expect(readdirSync(workDir)).not.toContain("runs"); + // Rule 1, against a real instance: without --yes nobody has consented, so + // nothing is written, here or in Clerk. + const unconsented = await cli(["migrate", "import", file, "--source", "supabase", "--json"]); + expect(unconsented.exitCode).toBe(2); + expect(JSON.parse(unconsented.stdout.toString())).toMatchObject({ + consent: "required", + checks: { importable: 2 }, + }); + expect(readdirSync(workDir)).not.toContain("runs"); + for (const { record } of users) { + const found = await cli(["api", `/users?external_id=${record.id}`]); + expect(JSON.parse(found.stdout.toString())).toEqual([]); + } + const imported = await cli([ "migrate", "import", @@ -141,7 +157,11 @@ test("a Supabase export dry-runs, imports, and its passwords verify", async () = // Read first: it registers the created users for cleanup, which a failed // assertion would otherwise skip, leaving them in the shared test app. const lines = latestLines(result.run.id); + expect(imported.exitCode).toBe(0); expect(result.result.created).toBe(2); + expect( + JSON.parse(readFileSync(join(workDir, "runs", result.run.id, "run.json"), "utf-8")), + ).toMatchObject({ status: "complete", counts: { total: 2, created: 2 } }); for (const { password, record } of users) { const line = lines.find((candidate) => candidate.sourceId === record.id); expect(line).toMatchObject({ status: "created" }); From 14dc3a4ba93dcbc0347720a83b2510e4e5370f73 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 18:50:49 -0400 Subject: [PATCH 17/39] docs(migrate): say production has no user limit by default, not none at all A plan can set a production user limit, as the code comment beside it and clerk_go both say. The `explainErrors` comment, its test and the README now say "none by default". Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 3 ++- packages/cli-core/src/commands/migrate/run.test.ts | 4 ++-- packages/cli-core/src/commands/migrate/run.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index a9b127929..dbfa46561 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -377,7 +377,8 @@ A non-numeric or non-positive value is ignored in favour of the default. A development instance's user limit is checked with the other [checks](#checks): new development instances are created with a 100-user limit, -production instances have none, and the run reads the live count +production instances have none by default (a plan can set one; the import +stops at the first refusal), and the run reads the live count (`GET /v1/users/count`). The limit itself is not served by any API, so for a development instance Clerk has raised, set `CLERK_MIGRATE_DEV_USER_LIMIT` to the raised limit; `--allow-partial` imports up to the headroom. diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 9f82eb2c9..2fd55039f 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -743,8 +743,8 @@ describe("explainErrors", () => { test("explains the user quota only where one applies", () => { expect(explainErrors([QUOTA], "dev").join(" ")).toContain("development-instance quota"); - // Production has no such quota, and the API's message already names the - // plan upgrade in the one case it does. + // Production has no such quota by default, and the API's message already + // names the plan upgrade when a plan sets one. expect(explainErrors([QUOTA], "prod")).toEqual([]); }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index e4236f5b4..415ffb22e 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -149,7 +149,7 @@ const TEST_NUMBERS_URL = "https://clerk.com/docs/guides/development/testing/test * Both of these read as account-level restrictions and are not. Blocked * countries are a per-instance SMS blocklist that development instances are * created with far more of, and the user limit is a development-instance quota - * that production does not have at all — so "contact support", which both + * that production does not have by default — so "contact support", which both * messages point at, is the wrong first move for most readers. * * @returns One note per recognized error family, empty when none apply. From 19057b3e1c2a8bacadb1fff5f804b6010115c8aa Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 18:51:34 -0400 Subject: [PATCH 18/39] fix(migrate): sign in for --app even when CLERK_SECRET_KEY is set With CLERK_SECRET_KEY exported, the import skipped its sign-in check even when `--app` was passed. `resolveBapiSecretKey` takes `--app` first, so a signed-out user got a Platform API failure instead of the sign-in prompt or error. The exported key now ends the check only without `--app`. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/run.test.ts | 16 ++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 5 +++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 2fd55039f..635a679b2 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -219,6 +219,22 @@ describe("run", () => { } }); + // resolveBapiSecretKey takes --app over the exported key, so the sign-in is + // still needed: without it the Platform API call fails instead. + test("refuses an --app import when nobody is signed in, even with CLERK_SECRET_KEY set", async () => { + const previous = process.env.CLERK_SECRET_KEY; + process.env.CLERK_SECRET_KEY = "sk_test_x"; + try { + await expect( + run({ source: "clerk", input: "export.json", yes: true, app: "app_123" }), + ).rejects.toThrow(/Not logged in/); + expect(requests).toHaveLength(0); + } finally { + if (previous === undefined) delete process.env.CLERK_SECRET_KEY; + else process.env.CLERK_SECRET_KEY = previous; + } + }); + test("imports every user in the file end to end", async () => { await run(baseOptions); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 415ffb22e..5a828cc4e 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -200,8 +200,9 @@ export function explainErrors(errors: Iterable, instanceType: InstanceTy */ async function ensureImportTarget(options: MigrateRunOptions): Promise { // A secret key names the destination instance on its own, with no account - // and no linked directory involved — mirroring resolveBapiSecretKey. - if (options.secretKey || process.env.CLERK_SECRET_KEY) return; + // and no linked directory involved — mirroring resolveBapiSecretKey, which + // takes `--app` over an exported CLERK_SECRET_KEY. + if (options.secretKey || (!options.app && process.env.CLERK_SECRET_KEY)) return; // `--app` names it too, but resolves its key through the Platform API, which // needs an account: it goes through the sign-in below, though not the link. // An unclaimed accountless application keeps its only secret key on disk. From b505a683d2af951f363ed6acac7a2c4aa181c292 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 18:51:44 -0400 Subject: [PATCH 19/39] docs(migrate): say unknown fields are warned about, and use sentence case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema section said a field outside the schema is silently dropped; the checks warn about each one (`Clerk won't store: …`). Two headings used title case where the other command READMEs use sentence case. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index dbfa46561..72f9ff652 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -61,7 +61,7 @@ Every command follows these: the export once the import succeeds would delete it after an interrupted one. Without a terminal, the counts are printed at each 10%. -## Targeting And Auth +## Targeting and auth `clerk migrate import` resolves its Backend API key through the CLI's standard chain: @@ -437,7 +437,8 @@ A Clerk export keeps an unverified primary email or phone unverified. What a source maps _onto_. Every user is validated against this schema before any request is made, so a field a source produces that is not listed -here is silently dropped — Zod strips unknown keys — and never reaches Clerk. +here is dropped — Zod strips unknown keys — and never reaches Clerk. The checks +warn about each one (`Clerk won't store: …`). The schema lives in `validator.ts`; adding a platform means adding a source, not editing it. @@ -505,7 +506,7 @@ stamping every user with today's. | `skipLegalChecks` | `boolean` | Skip legal acceptance checks | | `skipPasswordChecks` | `boolean` | Skip password requirements on import | -## API Endpoints +## API endpoints | Method | Path | Used by | | ------ | ------------------------- | ------------------------------------------------------------------------------ | From acb00edce6defaccb8b7347d2333d823b27ef484 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 18:52:52 -0400 Subject: [PATCH 20/39] fix(migrate): name the users an import would skip when asking to go ahead With every user rejected under `--allow-partial`, or none with a password under `--require-password`, the prompt asked "Import 0 users?" and the non-interactive message said it "will create 0 users", though the run records the rest as skipped. Both now say "Create N users and skip M" when there are skips. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/run-interactive.test.ts | 7 +++++++ packages/cli-core/src/commands/migrate/run.test.ts | 9 +++++++++ packages/cli-core/src/commands/migrate/run.ts | 10 +++++++--- 3 files changed, 23 insertions(+), 3 deletions(-) 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 91c676738..b27bb505d 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -149,6 +149,13 @@ describe("consent", () => { expect(confirmMessages).toEqual(["Import 2 users?"]); }); + test("names the users it would skip", async () => { + fs.writeFileSync(path.join(workDir, "export.json"), JSON.stringify([...EXPORT, { id: "u3" }])); + await run({ ...importOptions, allowPartial: true }); + + expect(confirmMessages).toEqual(["Create 2 users and skip 1 user?"]); + }); + test("prints the checks before the question", async () => { await run(importOptions); diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 635a679b2..394f146c0 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -374,6 +374,15 @@ describe("run", () => { expect(runsIn(runsDir())).toHaveLength(0); }); + // "will create 0 users" hides that the run records the rest as skipped. + test("--allow-partial without --yes names the users it would skip", async () => { + fs.writeFileSync(path.join(workDir, "export.json"), JSON.stringify([{ id: "u3" }])); + + await expect(run({ ...baseOptions, allowPartial: true, yes: false })).rejects.toThrow( + "will create 0 users and skip 1 user and needs consent", + ); + }); + test("--allow-partial imports the rest and records each reject as skipped", async () => { fs.writeFileSync( path.join(workDir, "export.json"), diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 5a828cc4e..fdb651069 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -552,12 +552,16 @@ export async function run(rawOptions: MigrateRunOptions): Promise { } // Rule 1: nothing is written without consent — `--yes`, or a yes at a - // prompt. An agent, a non-TTY run and `--json` never prompt. + // prompt. An agent, a non-TTY run and `--json` never prompt. With users + // skipped, the question names them: "Import 0 users?" hides the skips. + const toCreate = plural(checks.importable.length, "user"); + const skipping = checks.rejects.length + withoutPassword.length; + const skips = skipping > 0 ? ` and skip ${plural(skipping, "user")}` : ""; if (!options.yes) { if (!canPrompt(options)) { if (options.json) preview({ consent: "required" }); throwUsageError( - `\`clerk migrate import\` will create ${plural(checks.importable.length, "user")} and needs consent. Pass --yes to confirm.`, + `\`clerk migrate import\` will create ${toCreate}${skips} and needs consent. Pass --yes to confirm.`, undefined, undefined, [ @@ -570,7 +574,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { } log.blank(); const proceed = await confirm({ - message: `Import ${plural(checks.importable.length, "user")}?`, + message: skips ? `Create ${toCreate}${skips}?` : `Import ${toCreate}?`, default: false, }); if (!proceed) throwUserAbort(); From eaf8a7fe7c8bef15b302999333a5a78356d9e68e Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 13:27:02 -0400 Subject: [PATCH 21/39] docs(migrate): say retries and refusals land in a line's error field "All land in `error`" read as a status, next to the list of statuses. It is the field on the user's line. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 72f9ff652..79f2f9359 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -130,7 +130,7 @@ Each run is a folder named for its ID, `YYYYMMDD-HHmmss-xxxx`: A user's status is `creating`, `created`, `failed` or `skipped`. 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 `error`. +validation failure all land in the line's `error` field. `creating` is written as a user's `POST /v1/users` goes out. It stays the latest line when no answer says whether the create landed: an abort, a From db27ec6b5275971da2eb1f3e18372f85e4348ec5 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 14:16:04 -0400 Subject: [PATCH 22/39] test(migrate): cover the --json outcomes that stop before a run exists A refused import and a file with nobody to import each print a JSON preview with `run: null` and their own discriminator, but neither was tested. Both now are. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/run.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 394f146c0..1fb272e5f 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -588,6 +588,27 @@ describe("run", () => { }); }); + // The two `--json` outcomes that stop before a run exists: each says why, + // with `run: null`. + describe("--json, stopped before any run", () => { + test("a refused import reports refused, and exits 2", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([...export2, { id: "u3" }]), + ); + expect(await exitCodeOf(run({ ...baseOptions, json: true }))).toBe(EXIT_CODE.USAGE); + expect(JSON.parse(captured.out)).toMatchObject({ run: null, refused: true }); + expect(created()).toHaveLength(0); + }); + + test("a file with nobody to import reports nothingToImport", async () => { + fs.writeFileSync(path.join(workDir, "export.json"), "[]"); + await run({ ...baseOptions, json: true }); + expect(JSON.parse(captured.out)).toMatchObject({ run: null, nothingToImport: true }); + expect(created()).toHaveLength(0); + }); + }); + describe("stopped part-way", () => { // `importUsers` returns normally on a Ctrl-C; the run must not read as done. test("a Ctrl-C after the first create leaves the run unfinished, as interrupted", async () => { From 243cd5db5968b52b79f1daa7264bac1fa6ed7260 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 15:54:14 -0400 Subject: [PATCH 23/39] fix(migrate): say why an import stopped in its summary, after the progress bar The quota stop's reason now comes back from the import rather than being logged under the progress bar, so the summary prints it once the bar is done, with what to do next, and `--json` carries it as `result.stopReason`. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/run.test.ts | 36 ++++++++++++++++++- packages/cli-core/src/commands/migrate/run.ts | 10 ++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 1fb272e5f..1c381582d 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -662,9 +662,43 @@ describe("run", () => { expect(JSON.parse(captured.out)).toMatchObject({ run: { status: "partial" }, - result: { created: 0, failed: 1, notSent: 1 }, + result: { + created: 0, + failed: 1, + notSent: 1, + stopReason: "You have reached your limit of 100 users.", + }, }); }); + + // In the summary, after the progress bar: printed mid-run, the bar redrew + // over it. + test("the summary says why the import stopped", async () => { + stubClerk(); + const clerk = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => + init?.method === "POST" && new URL(input.toString()).pathname === "/v1/users" + ? Response.json( + { + errors: [ + { + code: "user_quota_exceeded", + message: "user quota exceeded", + long_message: "You have reached your limit of 100 users.", + }, + ], + }, + { status: 403 }, + ) + : clerk(input, init)) as typeof fetch; + await run(baseOptions); + + const err = Bun.stripANSI(captured.err); + expect(err).toContain("Not sent: 1"); + expect(err).toContain( + "You have reached your limit of 100 users. No more users were sent: running the import again picks up the rest once the limit is raised.", + ); + }); }); describe("a phone Clerk refuses", () => { diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index fdb651069..0fd8c42e0 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -347,6 +347,15 @@ function formatSummary( if (skipped > 0) lines.push(`${yellow("Skipped:")} ${skipped}`); // The user quota stopped the run: these go out on a re-run. if (summary.notSent > 0) lines.push(`${yellow("Not sent:")} ${summary.notSent}`); + // Printed here, after the progress bar, which would redraw over it mid-run. + if (summary.stopReason) { + lines.push( + "", + yellow( + `${summary.stopReason} No more users were sent: running the import again picks up the rest once the limit is raised.`, + ), + ); + } if (summary.errorBreakdown.size > 0) { lines.push("", bold("Error breakdown:")); @@ -641,6 +650,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { created: summary.successful, failed: summary.failed, notSent: summary.notSent, + ...(summary.stopReason ? { stopReason: summary.stopReason } : {}), skipped: checks.rejects.length + withoutPassword.length, errors: [...summary.errorBreakdown].map(([error, count]) => ({ error, count })), warnings: [...summary.droppedPhones].map(([reason, count]) => ({ From 1c5558fc4b6db96d83bdd04b59d9687338aa2b7e Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 16:22:26 -0400 Subject: [PATCH 24/39] fix(migrate): print the run line on a Ctrl-C and close the gutter as paused Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/run.test.ts | 5 ++++- packages/cli-core/src/commands/migrate/run.ts | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 1c381582d..41bb164f8 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -623,13 +623,16 @@ describe("run", () => { return response; }) as typeof fetch; try { - await run(baseOptions); + // Thrown, so the gutter closes as paused rather than done. + await expect(run(baseOptions)).rejects.toThrow(); } finally { _resetInterruptState(); delete process.env.CLERK_MIGRATE_CONCURRENCY_LIMIT; } const [record] = runsIn(runsDir()); + // The run folder is the only record of who was created. + expect(captured.err).toContain(`Run ${record?.id} records who was created:`); expect(record?.finishedAt).toBeUndefined(); expect(fs.existsSync(path.join(runsDir(), record?.id ?? "", "lock"))).toBe(false); expect(created()).toHaveLength(1); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 0fd8c42e0..6997be01d 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -630,10 +630,13 @@ export async function run(rawOptions: MigrateRunOptions): Promise { errorBreakdown: new Map(), }; // A Ctrl-C returns the import early, and the users it never sent have - // no line to count. Left unfinished, the run reads as interrupted. + // no line to count. Left unfinished, the run reads as interrupted. The + // signal handler prints nothing of ours, so the run line goes out here, + // and the throw closes the gutter as paused. if (interruptedExitCode() !== null) { run.release(); - return; + log.info(`Stopped. Run ${run.record.id} records who was created: ${run.dir}`); + throwUserAbort(); } const record = run.finish({ notSent: summary.notSent }); if (summary.failed > 0) process.exitCode = 1; From eb030619a7b5b892b5e155dd869679744c5464f5 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 16:22:26 -0400 Subject: [PATCH 25/39] fix(migrate): save the users a stopped import never sent in run.json's counts Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/lib/run-store.test.ts | 5 ++++- packages/cli-core/src/commands/migrate/lib/run-store.ts | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/run-store.test.ts b/packages/cli-core/src/commands/migrate/lib/run-store.test.ts index 6112b8ab2..049cffb8f 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.test.ts @@ -201,7 +201,10 @@ describe("locks", () => { const stopped = startRun(runsDir, init); stopped.append({ sourceId: "a", status: "created", clerkId: "user_a" }); - expect(stopped.finish({ notSent: 2 }).status).toBe("partial"); + const record = stopped.finish({ notSent: 2 }); + expect(record.status).toBe("partial"); + // Saved, so a later reader can tell how many were never sent. + expect(readRun(runsDir, record.id)?.counts).toEqual({ total: 1, created: 1, notSent: 2 }); }); test("release leaves the run unfinished and unlocked", () => { 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 1c25c89e5..f39b5150a 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -60,7 +60,8 @@ export type RunTarget = { export type RunFile = { path: string; sha256: string }; -export type RunCounts = Partial> & { total: number }; +/** `notSent`: users a stopped import never sent, so they have no line. */ +export type RunCounts = Partial> & { total: number; notSent?: number }; export type RunRecord = { id: string; @@ -345,6 +346,7 @@ function openRun(runsDir: string, record: RunRecord): Run { finish(options) { const lines = [...latestUserLines(runsDir, run.record.id).values()]; const counts = countLines(lines); + if (options?.notSent) counts.notSent = options.notSent; // A created user whose extra identifiers never attached is left for a continue. const pendingAttaches = lines.filter( (line) => line.status === "created" && (line.pending?.length ?? 0) > 0, @@ -353,7 +355,7 @@ function openRun(runsDir: string, record: RunRecord): Run { (counts.failed ?? 0) + (counts.skipped ?? 0) + (counts.creating ?? 0) + - (options?.notSent ?? 0) + + (counts.notSent ?? 0) + pendingAttaches; run.update({ counts, From 51a39d8efb3a5718f6958ef5a4b693494abdc1bd Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 16:22:26 -0400 Subject: [PATCH 26/39] fix(migrate): split the consent refusal into what the import would do and how to confirm it Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/run-interactive.test.ts | 2 +- packages/cli-core/src/commands/migrate/run.test.ts | 4 ++-- packages/cli-core/src/commands/migrate/run.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 b27bb505d..d89412b6e 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -180,7 +180,7 @@ describe("consent", () => { // `--json` means nobody reads a prompt, even at a terminal. test("--json never asks, and without --yes refuses", async () => { - await expect(run({ ...importOptions, json: true })).rejects.toThrow(/needs consent/); + await expect(run({ ...importOptions, json: true })).rejects.toThrow(/Pass --yes to confirm/); expect(confirmMessages).toEqual([]); expect(created()).toHaveLength(0); diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 41bb164f8..df8ad577c 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -379,7 +379,7 @@ describe("run", () => { fs.writeFileSync(path.join(workDir, "export.json"), JSON.stringify([{ id: "u3" }])); await expect(run({ ...baseOptions, allowPartial: true, yes: false })).rejects.toThrow( - "will create 0 users and skip 1 user and needs consent", + "will create 0 users and skip 1 user. Pass --yes to confirm.", ); }); @@ -551,7 +551,7 @@ describe("run", () => { )) as CliError; expect(error.exitCode).toBe(EXIT_CODE.USAGE); - expect(error.message).toContain("needs consent. Pass --yes to confirm"); + expect(error.message).toContain(". Pass --yes to confirm"); expect(error.examples?.[0]?.command).toBe( "clerk migrate import export.json --source clerk --secret-key --yes", ); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 6997be01d..7371b3c80 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -570,7 +570,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { if (!canPrompt(options)) { if (options.json) preview({ consent: "required" }); throwUsageError( - `\`clerk migrate import\` will create ${toCreate}${skips} and needs consent. Pass --yes to confirm.`, + `\`clerk migrate import\` will create ${toCreate}${skips}. Pass --yes to confirm.`, undefined, undefined, [ From a318a1a9996ee239a352c04cd13f81703d841b40 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 16:22:27 -0400 Subject: [PATCH 27/39] docs(migrate): document the Not sent count, how to send those users, and an interrupted run Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 79f2f9359..52ae9426f 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -137,8 +137,10 @@ latest line when no answer says whether the create landed: an abort, a network error, or a 5xx. A `created` line with `pending` lists the extra emails and phones not yet attached. -A run is `partial` when any user failed, was skipped or is still `creating`, -and `complete` otherwise. A lock holding this process's own PID is stale: in a +A run is `partial` when any user failed, was skipped, is still `creating` or +was never sent (`counts.notSent`), and `complete` otherwise. A run interrupted +with Ctrl-C stays `running`, with no `finishedAt`, and prints its run ID and +folder on the way out. A lock holding this process's own PID is stale: in a container the CLI often gets the same PID every run. Run folders are created owner-only (`0700`), because they hold user data. @@ -385,7 +387,10 @@ the raised limit; `--allow-partial` imports up to the headroom. Users that do exceed the limit come back in the error breakdown as `You have reached your limit of N users`, annotated with what a development -instance can do about it. +instance can do about it. The first refusal stops the import: the users it +never sent are counted under "Not sent" (`result.notSent` in `--json`, and +`counts.notSent` in `run.json`). Once the limit is raised, +[run the import again](#re-running) with `--allow-partial --yes` to send them. ### `clerk migrate help` From e8a2698738be59a9ec9c52ab1e1ea95351e8fddf Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 18:12:56 -0400 Subject: [PATCH 28/39] docs(migrate): document the phones numeric usernames make Clerk refuse Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 52ae9426f..0b05f7a62 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -258,6 +258,9 @@ after them. They sort the users three ways: receive mail (`.local`, `.invalid`, `.test`, `.example`, `.arpa`, `.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 + any other user, with a warning - it lacks an identifier the instance requires. An email or phone counts only when it is verified, because an unverified one is attached after the user exists @@ -277,7 +280,9 @@ after them. They sort the users three ways: without it (`skip_legal_checks`), with a warning - its username breaks the instance's username rules (length, letters, the allowed special characters). The checks offer the setting that allows - it. With usernames off, such a username is dropped instead, with a warning + it. With usernames off, such a username is dropped instead, with a warning. + Numeric usernames are not offered when the file has phones not in E.164 + form, which the setting would make Clerk refuse; the reject counts them - its password is not the shape its hasher says (`bcrypt`, with a cost up to 15, `scrypt_firebase`, `argon2i`/`argon2id` and `scrypt_werkzeug` are checked; other hashers are not, and Clerk refuses a bad one at create) From b5c2466ef9daada2a2f3b9c9796df822a27c5ffe Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 18:21:25 -0400 Subject: [PATCH 29/39] fix(migrate): print the run ID and folder as the import starts, so a real Ctrl-C leaves them on screen Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 7 +- .../commands/migrate/run.subprocess.test.ts | 71 +++++++++++++++++++ .../cli-core/src/commands/migrate/run.test.ts | 2 +- packages/cli-core/src/commands/migrate/run.ts | 11 +-- 4 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 packages/cli-core/src/commands/migrate/run.subprocess.test.ts diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 0b05f7a62..f42997da3 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -139,9 +139,10 @@ and phones not yet attached. A run is `partial` when any user failed, was skipped, is still `creating` or was never sent (`counts.notSent`), and `complete` otherwise. A run interrupted -with Ctrl-C stays `running`, with no `finishedAt`, and prints its run ID and -folder on the way out. A lock holding this process's own PID is stale: in a -container the CLI often gets the same PID every run. +with Ctrl-C stays `running`, with no `finishedAt`. Its run ID and folder are +printed as the run starts, so they are on screen however it ends. A lock +holding this process's own PID is stale: in a container the CLI often gets the +same PID every run. Run folders are created owner-only (`0700`), because they hold user data. diff --git a/packages/cli-core/src/commands/migrate/run.subprocess.test.ts b/packages/cli-core/src/commands/migrate/run.subprocess.test.ts new file mode 100644 index 000000000..2064094a3 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/run.subprocess.test.ts @@ -0,0 +1,71 @@ +/** + * A real Ctrl-C mid-import, in a child process. + * + * `CLI_SIGINT_HANDLER` exits once telemetry is flushed, before the import gets + * back to `run()`, so nothing `run()` prints after the import reaches the + * terminal. A test that latches the interrupt directly never runs the handler, + * so this one sends the child a real SIGINT and reads its stderr. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +let workDir: string; + +beforeAll(() => { + workDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-sigint-"))); + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([{ id: "u1", primary_email_address: "a@x.dev" }]), + ); +}); + +afterAll(() => { + fs.rmSync(workDir, { recursive: true, force: true }); +}); + +test("a real Ctrl-C mid-import leaves the run ID and folder on screen", async () => { + const source = ` + const { CLI_SIGINT_HANDLER } = await import(${JSON.stringify(`${import.meta.dir}/../../lib/signals.ts`)}); + process.on("SIGINT", CLI_SIGINT_HANDLER); + setTimeout(() => process.exit(99), 10_000); // guard against a hang + globalThis.fetch = async (input, init) => { + const url = new URL(input.toString()); + const method = init?.method ?? "GET"; + if (url.pathname === "/v1/instance") { + return Response.json({ object: "instance", id: "ins_1", environment_type: "development" }); + } + if (url.pathname === "/v1/users/count") return Response.json({ total_count: 0 }); + if (method === "GET" && url.pathname === "/v1/users") return Response.json([]); + if (method === "POST" && url.pathname === "/v1/users") { + process.kill(process.pid, "SIGINT"); + return new Promise(() => {}); + } + return new Response("unavailable", { status: 503 }); + }; + const { run } = await import(${JSON.stringify(`${import.meta.dir}/run.ts`)}); + await run({ source: "clerk", input: "export.json", yes: true, secretKey: "sk_test_x" }); + `; + + const { CLERK_CLI_NO_SIGNAL_RERAISE: _suppressed, ...cleanEnv } = process.env; + const proc = Bun.spawn(["bun", "-e", source], { + cwd: workDir, + stdout: "ignore", + stderr: "pipe", + env: { + ...cleanEnv, + CLERK_CONFIG_DIR: path.join(workDir, "config"), + CLERK_MODE: "agent", + // Never emit a real event from a test run. + CLERK_TELEMETRY_DISABLED: "1", + }, + }); + const stderr = await new Response(proc.stderr).text(); + await proc.exited; + + expect(proc.signalCode).toBe("SIGINT"); + const [runId] = fs.readdirSync(path.join(workDir, ".clerk", "migrate")); + expect(stderr).toContain(`Run ${runId}: ${path.join(workDir, ".clerk", "migrate", runId ?? "")}`); +}, 15_000); diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index df8ad577c..c3d1ba39c 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -632,7 +632,7 @@ describe("run", () => { const [record] = runsIn(runsDir()); // The run folder is the only record of who was created. - expect(captured.err).toContain(`Run ${record?.id} records who was created:`); + expect(captured.err).toContain(`Run ${record?.id}: `); expect(record?.finishedAt).toBeUndefined(); expect(fs.existsSync(path.join(runsDir(), record?.id ?? "", "lock"))).toBe(false); expect(created()).toHaveLength(1); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 7371b3c80..03d680a5b 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -597,6 +597,10 @@ export async function run(rawOptions: MigrateRunOptions): Promise { source, file: { path: filePath, sha256 }, }); + // Printed now, not on the way out: on a Ctrl-C the signal handler exits + // before the import returns, and the folder is the only record of who + // was created. + log.info(`Run ${run.record.id}: ${run.dir}`); recordRejects(run, checks); for (const user of withoutPassword) { run.append({ @@ -630,12 +634,11 @@ export async function run(rawOptions: MigrateRunOptions): Promise { errorBreakdown: new Map(), }; // A Ctrl-C returns the import early, and the users it never sent have - // no line to count. Left unfinished, the run reads as interrupted. The - // signal handler prints nothing of ours, so the run line goes out here, - // and the throw closes the gutter as paused. + // no line to count. Left unfinished, the run reads as interrupted, and + // the throw closes the gutter as paused, when the signal handler has not + // already exited. if (interruptedExitCode() !== null) { run.release(); - log.info(`Stopped. Run ${run.record.id} records who was created: ${run.dir}`); throwUserAbort(); } const record = run.finish({ notSent: summary.notSent }); From 74deef7c49dc2727997fba33e76fc28861cc0623 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 18:21:25 -0400 Subject: [PATCH 30/39] fix(migrate): say how to send the users a quota stop left, without repeating Clerk's message Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/run.test.ts | 2 +- packages/cli-core/src/commands/migrate/run.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index c3d1ba39c..4998a5d46 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -699,7 +699,7 @@ describe("run", () => { const err = Bun.stripANSI(captured.err); expect(err).toContain("Not sent: 1"); expect(err).toContain( - "You have reached your limit of 100 users. No more users were sent: running the import again picks up the rest once the limit is raised.", + "No more users were sent. Once the limit is raised, run the import again with `--allow-partial --yes` to send them.", ); }); }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 03d680a5b..5f75df219 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -352,7 +352,7 @@ function formatSummary( lines.push( "", yellow( - `${summary.stopReason} No more users were sent: running the import again picks up the rest once the limit is raised.`, + "No more users were sent. Once the limit is raised, run the import again with `--allow-partial --yes` to send them.", ), ); } From be6c7fe3883f0984466500c68ddb25a376109476 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 18:21:25 -0400 Subject: [PATCH 31/39] docs(migrate): bring the username and bcrypt cost check bullets in line with #519 Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index f42997da3..8ddba33a0 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -267,8 +267,8 @@ after them. They sort the users three ways: user exists - it has no identifier left once the emails and phones Clerk would refuse are stripped: those of an instance that neither has them on nor signs in - or does MFA with them. A username is kept: Clerk stores it with usernames - off + or does MFA with them. A username is kept, as Clerk stores it with + usernames off, unless it is one Clerk refuses - it lacks a first or last name the instance requires - it has an authenticator app secret or backup codes, and the instance has that turned off. Importing it without them would take away its second @@ -284,8 +284,8 @@ after them. They sort the users three ways: it. With usernames off, such a username is dropped instead, with a warning. Numeric usernames are not offered when the file has phones not in E.164 form, which the setting would make Clerk refuse; the reject counts them - - its password is not the shape its hasher says (`bcrypt`, with a cost up to - 15, `scrypt_firebase`, `argon2i`/`argon2id` and `scrypt_werkzeug` are + - 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 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 email or From 755dad845002d14fceebf3f9526e20f3aae125d0 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 18:36:15 -0400 Subject: [PATCH 32/39] test(migrate): keep an inherited runs-dir override out of the Ctrl-C subprocess Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/run.subprocess.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/run.subprocess.test.ts b/packages/cli-core/src/commands/migrate/run.subprocess.test.ts index 2064094a3..124c55b0c 100644 --- a/packages/cli-core/src/commands/migrate/run.subprocess.test.ts +++ b/packages/cli-core/src/commands/migrate/run.subprocess.test.ts @@ -49,7 +49,13 @@ test("a real Ctrl-C mid-import leaves the run ID and folder on screen", async () await run({ source: "clerk", input: "export.json", yes: true, secretKey: "sk_test_x" }); `; - const { CLERK_CLI_NO_SIGNAL_RERAISE: _suppressed, ...cleanEnv } = process.env; + // An inherited runs-dir override would send the run somewhere this test + // does not look. + const { + CLERK_CLI_NO_SIGNAL_RERAISE: _suppressed, + CLERK_MIGRATE_DIR: _runsDir, + ...cleanEnv + } = process.env; const proc = Bun.spawn(["bun", "-e", source], { cwd: workDir, stdout: "ignore", From 248da9b3039764e190ef67e4a61fed103e396ab6 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 18:36:15 -0400 Subject: [PATCH 33/39] docs(migrate): say only an import that gets as far as writing is a run Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 8ddba33a0..45a4b1239 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -100,8 +100,9 @@ The instance ID is what a run records. ## The run store -Every import is a **run**, and the run store is the one place `clerk migrate` -keeps state. +Every import 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 import with nobody to import write none (`run: null`). ### Where runs are kept From 0e75219b2736d76d4644dc55b227a7ca667cae63 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 18:47:46 -0400 Subject: [PATCH 34/39] docs(migrate): say only an empty file writes no run among the imports with nobody to create Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 45a4b1239..61c2d95d0 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -100,9 +100,11 @@ The instance ID is what a run records. ## The run store -Every import 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 import with nobody to import write none (`run: null`). +Every import 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. ### Where runs are kept From b4d0d407876f6895c2457e0523172088da95aad6 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 13 Oct 2026 12:58:21 -0400 Subject: [PATCH 35/39] docs(migrate): say how the E.164 checks treat trimmed phones, phones off, and a required phone they drop Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 61c2d95d0..f28593581 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -264,10 +264,13 @@ after them. They sort the users three ways: 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 - any other user, with a warning + any other user, with a warning. Spaces around a phone don't count, as Clerk + trims them. Phones the instance has off are stripped anyway, so they are + not checked - it lacks an identifier the instance requires. An email or phone counts only when it is verified, because an unverified one is attached after the - user exists + user exists. A required phone dropped for not being in E.164 form is named + as such - it has no identifier left once the emails and phones Clerk would refuse are stripped: those of an instance that neither has them on nor signs in or does MFA with them. A username is kept, as Clerk stores it with @@ -286,7 +289,8 @@ after them. They sort the users three ways: the allowed special characters). The checks offer the setting that allows it. With usernames off, such a username is dropped instead, with a warning. Numeric usernames are not offered when the file has phones not in E.164 - form, which the setting would make Clerk refuse; the reject counts them + 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 checked; other hashers are not, and Clerk refuses a bad one at create) From 3286db9e337d6af7f58d0bc3f8fa887be82e2309 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 13 Oct 2026 17:33:26 -0400 Subject: [PATCH 36/39] fix(migrate): say why users would be rejected in the refusal itself An agent reading only the error saw "1 user would be rejected" and not why. It now names the reason, or the most common one, and with --json points at `checks.rejects`. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/run.test.ts | 27 +++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 17 +++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 4998a5d46..a7e11dc03 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -369,11 +369,38 @@ describe("run", () => { expect(error.exitCode).toBe(EXIT_CODE.USAGE); expect(error.message).toContain("1 user would be rejected, so nothing was imported"); + // An agent reading only the error learns why. + expect(error.message).toMatch(/nothing was imported\. Reason: .+\. Fix them/); expect(error.examples?.[0]?.command).toContain("--allow-partial --yes"); expect(created()).toHaveLength(0); expect(runsIn(runsDir())).toHaveLength(0); }); + test("with several reasons, names the most common and points --json at the rest", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + ...export2, + { id: "u3" }, + { id: "u4" }, + { + id: "u5", + primary_email_address: "e@x.dev", + password_digest: "d", + password_hasher: "rot13", + }, + ]), + ); + + const error = (await run({ ...baseOptions, json: true }).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.message).toContain("3 users would be rejected, so nothing was imported"); + expect(error.message).toMatch(/Most common reason \(2\): .+; and 1 other reason\./); + expect(error.message).toContain("See `checks.rejects` for each user."); + }); + // "will create 0 users" hides that the run records the rest as skipped. test("--allow-partial without --yes names the users it would skip", async () => { fs.writeFileSync(path.join(workDir, "export.json"), JSON.stringify([{ id: "u3" }])); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 5f75df219..91cb19680 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -397,6 +397,20 @@ function commandFor(options: MigrateRunOptions, extra: string[]) { return [...parts, ...extra].join(" "); } +/** + * Why the checks refused, in the error itself: an agent may read only that, + * not the report above it. + */ +function rejectSummary(checks: ImportChecks, json = false): string { + const [top, ...rest] = checks.rejectReasons.toSorted((a, b) => b.count - a.count); + if (!top) return ""; + const why = + rest.length === 0 + ? `Reason: ${top.reason}.` + : `Most common reason (${top.count}): ${top.reason}; and ${plural(rest.length, "other reason")}.`; + return ` ${why}${json ? " See `checks.rejects` for each user." : ""}`; +} + /** Records the checks' rejects as skipped users, so the run says who they were. */ function recordRejects(run: Run, checks: ImportChecks): void { for (const { sourceId, reason, keptSourceId } of checks.rejects) { @@ -537,7 +551,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { if (refused) { if (options.json) preview({ refused: true }); throwUsageError( - `${plural(checks.rejects.length, "user")} would be rejected, so nothing was imported. ` + + `${plural(checks.rejects.length, "user")} would be rejected, so nothing was imported.` + + `${rejectSummary(checks, options.json)} ` + "Fix them, or pass --allow-partial to import the rest and record them as skipped.", undefined, undefined, From ba510eca5ad8abf91a730f59019545c103a4ca8e Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 13 Oct 2026 17:34:08 -0400 Subject: [PATCH 37/39] fix(migrate): carry a yes at the legal prompt into the suggested commands A refusal then suggested `--allow-partial --yes` without --skip-legal-checks, and that rerun skips the prompt, so it left out the users the yes had let in. Co-Authored-By: Claude Opus 5.5 --- .../commands/migrate/run-interactive.test.ts | 23 +++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 2 ++ 2 files changed, 25 insertions(+) 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 d89412b6e..c4c201526 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -221,6 +221,29 @@ describe("legal consent", () => { expect(created()).toHaveLength(2); }); + // A rerun with --yes skips the prompt, so the command has to carry the answer. + test("a yes carries into the command the refusal suggests", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + ...EXPORT, + { + id: "u3", + primary_email_address: "c@x.dev", + password_digest: "d", + password_hasher: "rot13", + }, + ]), + ); + + const error = (await run(importOptions).catch((caught: unknown) => caught)) as { + examples?: { command: string }[]; + }; + + expect(confirmMessages[0]).toContain("no legal acceptance on record"); + expect(error.examples?.[0]?.command).toContain("--skip-legal-checks"); + }); + // `-y` is "import without prompting": the checks reject these users instead. test("--yes does not ask, and the checks reject them", async () => { await expect(run({ ...importOptions, yes: true })).rejects.toThrow(/2 users would be rejected/); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 91cb19680..d9c3a16e4 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -503,6 +503,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { message: `${plural(withoutLegal, "user")} ${withoutLegal === 1 ? "has" : "have"} no legal acceptance on record, which this instance requires. Import them without it?`, default: false, }); + // The suggested commands carry the answer, so a rerun with --yes keeps it. + options = { ...options, skipLegalChecks }; } const checks = await withSpinner("Checking users against the instance...", async (spinner) => From c352fd961dc65df151d48dd76d435c4ca5c0ae74 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 13 Oct 2026 17:34:30 -0400 Subject: [PATCH 38/39] fix(migrate): check --source before signing in A signed-out human with a mistyped --source went through the browser login and `clerk link` before learning the source was unknown. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/run.test.ts | 8 ++++++++ packages/cli-core/src/commands/migrate/run.ts | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index a7e11dc03..54ad5a9a8 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -822,6 +822,14 @@ describe("run", () => { expect(created()).toEqual(["sb1"]); }); + test("an unknown source fails before signing in", async () => { + const { secretKey: _key, ...signedOut } = baseOptions; + const error = (await run({ ...signedOut, source: "nope" }).catch( + (caught: unknown) => caught, + )) as CliError; + expect(error.message).toContain('Unknown source "nope"'); + }); + test("an unknown source fails listing the valid keys", async () => { const error = (await run({ ...baseOptions, source: "nope" }).catch( (caught: unknown) => caught, diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index d9c3a16e4..e089cd8eb 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -423,8 +423,9 @@ function recordRejects(run: Run, checks: ImportChecks): void { } export async function run(rawOptions: MigrateRunOptions): Promise { - await ensureImportTarget(rawOptions); + // A mistyped --source fails before any sign-in or link. let options = await applySource(rawOptions); + await ensureImportTarget(options); const input = await resolveInput(options); options = { ...options, file: input }; From 3535cbfc177c0d8bef5d59eae5c34c1b41ff8ab0 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 13 Oct 2026 17:35:02 -0400 Subject: [PATCH 39/39] fix(migrate): let an explicit --mode win over --json's agent mode Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/index.test.ts | 20 +++++++++++++++++++ .../cli-core/src/commands/migrate/index.ts | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/index.test.ts b/packages/cli-core/src/commands/migrate/index.test.ts index 5eb433a85..d26c5556f 100644 --- a/packages/cli-core/src/commands/migrate/index.test.ts +++ b/packages/cli-core/src/commands/migrate/index.test.ts @@ -120,6 +120,26 @@ describe("the migrate group's -y hook", () => { } }); + test("an explicit --mode wins over --json", async () => { + const original = getMode(); + try { + setMode("agent"); + await parse([ + "--mode", + "human", + "migrate", + "import", + missing, + "--json", + "--secret-key", + "sk_test_x", + ]); + expect(getMode()).toBe("human"); + } finally { + setMode(original); + } + }); + test("records its absence, so a previous run cannot leak into this one", async () => { setAssumeYes(true); expect(await parse(["migrate", "import", missing, "--secret-key", "sk_test_x"])).toBe(false); diff --git a/packages/cli-core/src/commands/migrate/index.ts b/packages/cli-core/src/commands/migrate/index.ts index 20e221721..4323a6893 100644 --- a/packages/cli-core/src/commands/migrate/index.ts +++ b/packages/cli-core/src/commands/migrate/index.ts @@ -46,11 +46,11 @@ export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = proce // // `--json` means nobody is reading a prompt, so it runs the command in agent // mode: every prompt in this tree already stands down for an agent, with the - // usage error naming what to pass instead. + // usage error naming what to pass instead. An explicit `--mode` still wins. migrateCommand.hook("preAction", (_thisCommand, actionCommand) => { const opts = actionCommand.optsWithGlobals(); setAssumeYes(Boolean(opts.yes)); - if (opts.json) setMode("agent"); + if (opts.json && !opts.mode) setMode("agent"); }); // Named, not `isDefault`: bare `clerk migrate` prints help.