From 73553e3d65cab47217680a93a8dd9b4769307f01 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Mon, 5 Oct 2026 14:53:31 -0400 Subject: [PATCH 01/63] feat(migrate): add runs and undo, and continue a stopped import A re-run of the same file, source and instance continues the latest run: an interrupted one skips the users it created and adopts the creates it sent with no answer, a partial one retries the users that failed or were skipped, and a complete one is left alone. --new-run starts over. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 203 ++++++-- .../src/commands/migrate/import-users.test.ts | 45 +- .../src/commands/migrate/import-users.ts | 66 ++- .../src/commands/migrate/index.test.ts | 29 +- .../cli-core/src/commands/migrate/index.ts | 55 ++- .../src/commands/migrate/lib/checks.test.ts | 9 + .../src/commands/migrate/lib/checks.ts | 6 + .../commands/migrate/lib/run-store.test.ts | 92 +++- .../src/commands/migrate/lib/run-store.ts | 75 ++- .../src/commands/migrate/lib/user-lookup.ts | 29 ++ .../cli-core/src/commands/migrate/run.test.ts | 256 ++++++++-- packages/cli-core/src/commands/migrate/run.ts | 237 ++++++++- .../src/commands/migrate/runs.test.ts | 110 +++++ .../cli-core/src/commands/migrate/runs.ts | 246 ++++++++++ .../src/commands/migrate/undo.test.ts | 323 +++++++++++++ .../cli-core/src/commands/migrate/undo.ts | 455 ++++++++++++++++++ packages/cli-core/src/lib/next-steps.ts | 16 +- 17 files changed, 2105 insertions(+), 147 deletions(-) create mode 100644 packages/cli-core/src/commands/migrate/runs.test.ts create mode 100644 packages/cli-core/src/commands/migrate/runs.ts create mode 100644 packages/cli-core/src/commands/migrate/undo.test.ts create mode 100644 packages/cli-core/src/commands/migrate/undo.ts diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index f285935815..32841f0e06 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -15,7 +15,9 @@ Migrate users into a Clerk instance from another auth provider, or from another Clerk instance. ``` -clerk migrate import [--source ] [--dry-run] [--allow-partial] [--yes] [--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 help ``` @@ -26,7 +28,9 @@ clerk migrate import users.json --source supabase --dry-run # 1. check it agai clerk migrate import users.json --source supabase --yes # 2. import it ``` -`--runs-dir ` (or `CLERK_MIGRATE_DIR`) keeps runs somewhere else. +`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 @@ -34,22 +38,24 @@ clerk migrate import users.json --source supabase --yes # 2. import it Every command follows these: 1. **Nothing writes without consent.** Consent is a yes at a terminal prompt, or - `--yes`. Without either, `import` prints what it would do and exits 2 with - the command to run. `--json` means non-interactive: it never prompts. + `--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. + including the error for each user who failed. `runs`, `undo` and re-runs 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 partway. The UI goes to stderr and data to stdout. + Ctrl-C stops an import or an undo partway. The UI goes to stderr and data to + stdout. - While users are created, a terminal shows a bar and the counts under it, not + While users are created or deleted, a terminal shows a bar and the counts under it, not a spinner: ``` @@ -77,30 +83,33 @@ 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` refuses (exit 2) an `--instance` that names a different -one. `--instance dev` next to an exported `sk_live_…` key would otherwise write +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` names the instance — its +**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. `--json` carries the same facts as `target`. +profile. `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. +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 that gets as far as writing is a **run**, and the run store is +Every import and undo that gets as far as writing is a **run**, and the run store is the one place `clerk migrate` keeps state. A dry run, a refusal, a run that needs consent and an empty file write none (`run: null`). An import whose users `--require-password` all leaves out still writes one, recording them @@ -130,7 +139,7 @@ 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` or `skipped`. The last line +A user's status is `creating`, `created`, `failed`, `skipped` or `deleted`. The last line for each `sourceId` wins. A `429` retry, an extra email or phone that did not attach, a first phone Clerk refused (which the summary also counts), and a validation failure all land in the line's `error` field. @@ -141,11 +150,13 @@ 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, is still `creating` or -was never sent (`counts.notSent`), and `complete` otherwise. A run interrupted -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. +was never sent (`counts.notSent`), and `complete` otherwise. A run whose process +died, or that never recorded a finish time, lists as `interrupted`. A Ctrl-C +leaves a run that way. Its run ID and folder are printed as the run starts, so +they are on screen however it ends. 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`), because they hold user data. @@ -184,6 +195,7 @@ API. 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 --new-run --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 @@ -199,6 +211,7 @@ clerk migrate import # a human is asked | `--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 | +| `--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 | | `-y, --yes` | Import without prompting | @@ -224,7 +237,8 @@ Without either — an agent, a non-TTY run, `--json` — the run prints the chec 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 the checks. +**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 — @@ -235,19 +249,39 @@ failed. `--require-password` records each user it leaves out as `skipped`, so the run 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`. With `--require-password`, `withoutPassword` counts the users it -left out before the checks, so `checks.total` plus it is the file's size. +`--json` returns `{ target, run, resume, checks, result }`. When a run stops +before importing, it carries one of `dryRun: true`, `refused: true`, +`consent: "required"` or `nothingToImport: true` in place of `result`, and a +file already imported in full returns `alreadyImported: true`. 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 -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. +Running the same import again continues where it left off. The match is the +file's sha256, the source, 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. + +When an import completes, it names its run folder, which only `undo` needs and +which holds user data, with the `rm -rf` to remove it. #### Checks @@ -306,7 +340,8 @@ after them. They sort the users three ways: 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) + 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 @@ -368,7 +403,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` on the user's line. +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 @@ -403,12 +438,83 @@ Users that do exceed the limit come back in the error breakdown as 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. +[run the import again](#re-running) to send them. + +### `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 +clerk migrate undo 20260929-141502-a1b2 --json --yes +``` + +| 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 not an import 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 help` -`clerk migrate help` and `clerk migrate import --help` print the help for the -group or the command, with examples. +`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 @@ -461,7 +567,8 @@ warn about each one (`Clerk won't store: …`). 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`. +**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. @@ -526,15 +633,18 @@ 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/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 | +| 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/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` — 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 @@ -543,7 +653,8 @@ instance settings: the checks print the `clerk config patch` to run instead. ## Notes -- `userId` in the source file becomes the Clerk user's `external_id`. +- `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. diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index baae3b2842..8115304acd 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -380,7 +380,7 @@ describe("importUsers", () => { expect(lines.at(-1)).not.toHaveProperty("pending"); }); - test("keeps an attach with no answer pending", async () => { + test("keeps an attach with no answer pending, for a continued run", async () => { stub((url) => url.endsWith("/v1/email_addresses") ? clerkError(503, "unavailable") : ok("user_created"), ); @@ -421,6 +421,49 @@ describe("importUsers", () => { ]); }); + test("attachOnly sends just the pending attaches, and clears them", async () => { + stub(() => ok("idn_1")); + + await importUsers({ + users: [], + attachOnly: [ + { + sourceId: "u1", + clerkId: "user_1", + status: "created", + pending: [{ kind: "phone", value: "+15555550100", verified: false }], + }, + ], + secretKey: "sk_test_x", + limits: LIMITS, + record, + }); + + expect(requests.map((r) => [new URL(r.url).pathname, r.body])).toEqual([ + [ + "/v1/phone_numbers", + { user_id: "user_1", phone_number: "+15555550100", primary: false, verified: false }, + ], + ]); + expect(lines.at(-1)).toEqual({ sourceId: "u1", clerkId: "user_1", status: "created" }); + }); + + test("an adopted user is not created again; only its extras attach", async () => { + stub(() => ok("idn_1")); + + const summary = await importUsers({ + users: [user({ email: ["a@x.dev", "b@x.dev"] })], + adopted: new Map([["u1", "user_found"]]), + secretKey: "sk_test_x", + limits: LIMITS, + record, + }); + + expect(requests.map((r) => new URL(r.url).pathname)).toEqual(["/v1/email_addresses"]); + expect(summary.successful).toBe(1); + expect(lines.at(-1)).toMatchObject({ clerkId: "user_found", status: "created" }); + }); + // Shapes from clerk_go's apierror: the country error carries its own code // and no param_name; the E.164 error is a form error on phone_number. test.each([ diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index f09b838c46..e128b840eb 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -356,6 +356,17 @@ export type ImportUsersOptions = { limits: ResolvedLimits; /** Receives each user's lines as they happen. */ record: (line: UserLine) => void; + /** + * Users a continued run created whose extra identifiers never attached: their + * latest `created` line, with `pending`. Only the attaches are sent. + */ + attachOnly?: UserLine[]; + /** + * Source ID → Clerk ID for users whose create a stopped run sent with no + * answer, and which a continued run then found in the instance. They are + * not created again; only their extra identifiers are sent. + */ + adopted?: Map; /** Allow users that carry no password. */ skipPasswordRequirement?: boolean; /** Carried into the summary so the report covers the whole file. */ @@ -369,8 +380,7 @@ export type ImportUsersOptions = { * * A failed user is recorded and the run continues; a 429 backs off (honouring * `Retry-After`) and retries up to {@link MAX_RETRIES} times. A create with no - * answer keeps its `creating` line: Clerk may hold the user, and a re-run's - * checks look it up before creating it again. + * answer keeps its `creating` line, for a continued run or `undo` to resolve. */ export async function importUsers(options: ImportUsersOptions): Promise { const { @@ -378,6 +388,8 @@ export async function importUsers(options: ImportUsersOptions): Promise(), skipPasswordRequirement = true, validationFailed = 0, progress: report, @@ -439,8 +451,7 @@ export async function importUsers(options: ImportUsersOptions): Promise { const { error: _error, pending: _pending, ...base } = line; @@ -461,22 +472,25 @@ export async function importUsers(options: ImportUsersOptions): Promise - createUser(ctx, user, identifiers, skipPasswordRequirement, () => { - record({ sourceId: user.userId, status: "creating" }); - sent = true; - }), - { - signal: ctx.stop, - onRetry: ({ message, delaySeconds }) => { - retries.push(message); - ctx.schedule.pause(delaySeconds * 1000); - }, - }, - ); + created = adoptedId + ? { clerkUserId: adoptedId, notes: [] } + : await retryOn429( + async () => + createUser(ctx, user, identifiers, skipPasswordRequirement, () => { + record({ sourceId: user.userId, status: "creating" }); + sent = true; + }), + { + signal: ctx.stop, + onRetry: ({ message, delaySeconds }) => { + retries.push(message); + ctx.schedule.pause(delaySeconds * 1000); + }, + }, + ); } catch (error) { // Not an API outcome: the request never went out. if (storeFailure && error === storeFailure.error) throw error; @@ -537,13 +551,15 @@ export async function importUsers(options: ImportUsersOptions): Promise - processUser(user).catch((error: unknown) => { - if (!storeFailure) throw error; - }), - ), - ); + // A halt leaves its error to be rethrown below, once every user has settled. + const settle = async (work: Promise) => + work.catch((error: unknown) => { + if (!storeFailure) throw error; + }); + await Promise.all([ + ...users.map(async (user) => settle(processUser(user))), + ...attachOnly.map(async (line) => settle(finishUser(line, line.pending ?? [], []))), + ]); if (storeFailure) throw storeFailure.error; return { diff --git a/packages/cli-core/src/commands/migrate/index.test.ts b/packages/cli-core/src/commands/migrate/index.test.ts index d26c5556f2..2dd590f1a7 100644 --- a/packages/cli-core/src/commands/migrate/index.test.ts +++ b/packages/cli-core/src/commands/migrate/index.test.ts @@ -31,8 +31,12 @@ describe("registerMigrate", () => { expect(migrate?.description()).toContain("Migrate users"); }); - test("registers import as its only subcommand", () => { - expect(findCommand(["migrate"])?.commands.map((cmd) => cmd.name())).toEqual(["import"]); + test("registers import, undo and runs", () => { + expect(findCommand(["migrate"])?.commands.map((cmd) => cmd.name())).toEqual([ + "import", + "undo", + "runs", + ]); }); test("leaves migrate with no default subcommand", () => { @@ -45,6 +49,7 @@ describe("registerMigrate", () => { "--source", "--dry-run", "--allow-partial", + "--new-run", "--require-password", "--skip-legal-checks", "--yes", @@ -56,6 +61,26 @@ describe("registerMigrate", () => { ]); }); + test("registers runs with an optional run ID", () => { + const runsCommand = findCommand(["migrate", "runs"]); + expect(runsCommand?.registeredArguments[0]?.required).toBe(false); + expect(runsCommand?.options.map((option) => option.long)).toEqual(["--json", "--runs-dir"]); + }); + + test("registers undo with a required run ID and its flags", () => { + const undoCommand = findCommand(["migrate", "undo"]); + expect(undoCommand?.registeredArguments[0]?.required).toBe(true); + expect(undoCommand?.options.map((option) => option.long)).toEqual([ + "--dry-run", + "--yes", + "--json", + "--secret-key", + "--app", + "--instance", + "--runs-dir", + ]); + }); + // 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", () => { diff --git a/packages/cli-core/src/commands/migrate/index.ts b/packages/cli-core/src/commands/migrate/index.ts index 4323a68930..90a42ef54d 100644 --- a/packages/cli-core/src/commands/migrate/index.ts +++ b/packages/cli-core/src/commands/migrate/index.ts @@ -4,6 +4,8 @@ import { setMode } from "../../mode.ts"; import { setAssumeYes } from "./lib/assume-yes.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"; export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = process.env): void { if (!isExperimentEnabled("migrate", env)) { @@ -36,7 +38,12 @@ export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = proce }, { command: "clerk migrate import users.json --source supabase --yes", - description: "Import it", + description: "Import it. Run it again to continue after a failure", + }, + { command: "clerk migrate runs", description: "List every migration run" }, + { + command: "clerk migrate undo 20260929-141502-a1b2", + description: "Delete the users an import created", }, ]); @@ -65,6 +72,7 @@ export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = proce .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", @@ -92,4 +100,49 @@ export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = proce ...(input ? { input } : {}), }), ); + + // 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) => + 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) => + runs(runId, cmd.optsWithGlobals() as Parameters[1]), + ); } diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 8ffc06774c..4601aecf91 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -327,6 +327,15 @@ describe("rejects", () => { expect(checks.importable.map((u) => u.userId)).toEqual(["kept"]); }); + // A continued run found this user behind its own in-flight create. + test("not a user the continued run adopted", async () => { + existing = [{ id: "user_1", external_id: "mine" }]; + + expect( + await reasonsOf({ users: [user("mine")], adoptedClerkIds: new Set(["user_1"]) }), + ).toEqual({}); + }); + test("a supabase user whose only provider is disabled", async () => { const rows = [ { id: "only-discord", raw_app_meta_data: { providers: ["discord"] } }, diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 263e02a6f3..99d95e9d2e 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -103,6 +103,11 @@ export type CheckInput = { * sending `skip_legal_checks`. Without it they are rejected. */ skipLegalChecks?: boolean; + /** + * Clerk IDs a continued run found behind its own in-flight creates: finding + * them in the instance is expected. + */ + adoptedClerkIds?: Set; spinner?: SpinnerControls; }; @@ -637,6 +642,7 @@ async function findInstanceDuplicates( }; for (const existing of found) { + if (input.adoptedClerkIds?.has(existing.id)) continue; if (existing.external_id) { claim(byExternalId.get(existing.external_id), "already in the instance, with this source ID"); } 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 049cffb8f3..1af0b9f533 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 @@ -15,11 +15,13 @@ import os from "node:os"; import path from "node:path"; import { _setConfigDir } from "../../../lib/config.ts"; import { + continueRun, latestUserLines, - liveLockPid, + listRuns, newRunId, readRun, resolveRunsDir, + runState, RUNS_DIR_ENV, startRun, } from "./run-store.ts"; @@ -136,6 +138,17 @@ describe("a run's life", () => { expect([...latestUserLines(runsDir, run.record.id).keys()]).toEqual(["a"]); }); + test("a continued run starts a fresh line after one a crash cut short", () => { + const run = startRun(runsDir, init); + run.append({ sourceId: "a", status: "created" }); + fs.appendFileSync(path.join(run.dir, "users.ndjson"), '{"sourceId":"b","sta'); + fs.rmSync(path.join(run.dir, "lock")); + + continueRun(runsDir, run.record).append({ sourceId: "c", status: "created" }); + + expect([...latestUserLines(runsDir, run.record.id).keys()]).toEqual(["a", "c"]); + }); + // A user created with no line is beyond both undo and a re-run, so the // create that would follow must not go out. test("append throws when the line cannot be written", () => { @@ -146,26 +159,53 @@ describe("a run's life", () => { }); }); -describe("locks", () => { - test("a lock whose process died is stale", () => { +describe("locks and interruptions", () => { + // A PID no process can have. + const DEAD_PID = "2147483646"; + + test("a run whose process died is interrupted", () => { + const run = startRun(runsDir, init); + fs.writeFileSync(path.join(run.dir, "lock"), DEAD_PID); + expect(runState(runsDir, run.record)).toBe("interrupted"); + }); + + test("a run with no lock and no finish time is interrupted", () => { + const run = startRun(runsDir, init); + fs.rmSync(path.join(run.dir, "lock")); + expect(runState(runsDir, run.record)).toBe("interrupted"); + }); + + // Undo marks an interrupted import undone, but leaves it with no finish time. + test("an interrupted run that was undone reads as undone", () => { + const run = startRun(runsDir, init); + fs.rmSync(path.join(run.dir, "lock")); + expect(runState(runsDir, { ...run.record, status: "undone" })).toBe("undone"); + }); + + test("continuing takes the lock from a dead process and reopens the run", () => { const run = startRun(runsDir, init); - // A PID no process can have. - fs.writeFileSync(path.join(run.dir, "lock"), "2147483646"); - expect(liveLockPid(runsDir, run.record.id)).toBeUndefined(); + run.append({ sourceId: "a", status: "created" }); + run.finish(); + + const again = continueRun(runsDir, readRun(runsDir, run.record.id)!); + expect(again.record).toMatchObject({ id: run.record.id, status: "running" }); + expect(again.record.finishedAt).toBeUndefined(); + again.append({ sourceId: "b", status: "created" }); + expect(again.finish().counts.total).toBe(2); }); // In a container the CLI often gets the same PID every run, so a killed // run's lock can hold this process's own PID. test("a lock holding this process's own PID is stale", () => { const run = startRun(runsDir, init); - expect(liveLockPid(runsDir, run.record.id)).toBeUndefined(); + expect(runState(runsDir, run.record)).toBe("interrupted"); + expect(() => continueRun(runsDir, run.record)).not.toThrow(); }); - test("a lock another live process holds is live", () => { + test("reads as running while another live process holds the lock", () => { const run = startRun(runsDir, init); - // PID 1 is always alive, and never this test. fs.writeFileSync(path.join(run.dir, "lock"), "1"); - expect(liveLockPid(runsDir, run.record.id)).toBe(1); + expect(runState(runsDir, run.record)).toBe("running"); }); // Two runs in the same second share an ID one time in 65,536. @@ -221,4 +261,36 @@ describe("locks", () => { expect(fs.statSync(path.join(run.dir, file)).mode & 0o777).toBe(0o600); } }); + + test("refuses a run another live process holds, with exit 2", () => { + const run = startRun(runsDir, init); + // PID 1 is always alive, and never this test. + fs.writeFileSync(path.join(run.dir, "lock"), "1"); + + expect(() => continueRun(runsDir, run.record)).toThrow( + new RegExp( + `in use by another process \\(PID 1\\).*delete ${path.join(run.dir, "lock")}`, + "s", + ), + ); + }); +}); + +describe("listRuns", () => { + test("lists newest first and ignores folders that are not runs", () => { + const first = startRun(runsDir, init); + first.update({ startedAt: "2026-01-01T00:00:00.000Z" }); + const second = startRun(runsDir, { ...init, kind: "export" }); + second.update({ startedAt: "2026-02-01T00:00:00.000Z" }); + fs.mkdirSync(path.join(runsDir, "not-a-run")); + + expect(listRuns(runsDir).map((record) => record.id)).toEqual([ + second.record.id, + first.record.id, + ]); + }); + + test("is empty when the folder does not exist", () => { + expect(listRuns(path.join(workDir, "nowhere"))).toEqual([]); + }); }); 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 f39b5150a6..c17ca3bba2 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -11,8 +11,8 @@ * second writer; a dead one, or a run with no `finishedAt`, means the run * was interrupted. * - * Everything `clerk migrate` records lives here, so there is no second record - * to drift out of step. + * `runs`, `undo` and re-runs all read or write it, so there is no second + * record to drift out of step. * * Appends are synchronous so a run interrupted with Ctrl-C still leaves a * complete record of everything already processed. @@ -102,6 +102,9 @@ export type UserLine = { passwordDropped?: boolean; }; +/** What `runs` shows for a run: its stored status, or how it stopped. */ +export type RunState = RunStatus | "interrupted"; + const RUN_FILE = "run.json"; const USERS_FILE = "users.ndjson"; const LOCK_FILE = "lock"; @@ -281,6 +284,48 @@ export function latestUserLines(runsDir: string, id: string): Map { + const ids = new Set(); + for (const record of listRuns(runsDir)) { + if (record.kind !== "import" || record.id === exceptId) continue; + for (const line of latestUserLines(runsDir, record.id).values()) { + if (line.status === "created" && line.clerkId) ids.add(line.clerkId); + } + } + return ids; +} + +/** Every readable run, newest first. */ +export function listRuns(runsDir: string): RunRecord[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(runsDir, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => readRun(runsDir, entry.name)) + .filter((record): record is RunRecord => record !== undefined) + .sort((a, b) => b.startedAt.localeCompare(a.startedAt) || b.id.localeCompare(a.id)); +} + // --- Writing --------------------------------------------------------------- function writeRecord(runsDir: string, record: RunRecord): void { @@ -403,3 +448,29 @@ export function startRun(runsDir: string, init: StartRunInit): Run { log.debug(`migrate: started ${init.kind} run ${id} in ${runsDir}`); return openRun(runsDir, record); } + +/** + * Reopens a finished or interrupted run to keep working on it. + * + * @throws UsageError when another live process holds its lock. + */ +export function continueRun(runsDir: string, record: RunRecord): Run { + acquireLock(runsDir, record.id); + // A crash mid-write leaves a torn last line; end it so the next append + // starts a line of its own instead of fusing with it. + const usersFile = path.join(runDir(runsDir, record.id), USERS_FILE); + const written = fs.existsSync(usersFile) ? fs.readFileSync(usersFile, "utf-8") : ""; + if (written && !written.endsWith("\n")) fs.appendFileSync(usersFile, "\n"); + const run = openRun(runsDir, record); + run.record = { ...record, status: "running" }; + delete run.record.finishedAt; + writeRecord(runsDir, run.record); + log.debug(`migrate: continuing ${record.kind} run ${record.id} in ${runsDir}`); + return run; +} + +/** Merges fields into a run this process is not writing, such as `undoneBy`. */ +export function patchRun(runsDir: string, id: string, patch: Partial): void { + const record = readRun(runsDir, id); + if (record) writeRecord(runsDir, { ...record, ...patch }); +} diff --git a/packages/cli-core/src/commands/migrate/lib/user-lookup.ts b/packages/cli-core/src/commands/migrate/lib/user-lookup.ts index efca044d9d..f934d25cda 100644 --- a/packages/cli-core/src/commands/migrate/lib/user-lookup.ts +++ b/packages/cli-core/src/commands/migrate/lib/user-lookup.ts @@ -12,6 +12,7 @@ import { bapiRequest } from "../../../lib/bapi.ts"; import type { SpinnerControls } from "../../../lib/spinner.ts"; import { retryOn429 } from "./retry.ts"; +import { clerkIdsCreatedByOtherRuns } from "./run-store.ts"; import type { ApiScheduler } from "./scheduler.ts"; /** BAPI accepts at most 100 values per filter on `GET /v1/users`. */ @@ -101,3 +102,31 @@ export async function lookupUsers(options: { return pages.flat().filter((user) => typeof user.id === "string"); } + +/** + * The users behind creates that were in flight when run `runId` stopped. + * + * Found by `external_id`, which the import's checks refused to reuse, but a + * later run of the same source IDs can still have created one after this run + * stopped. So any Clerk ID another import run records as created is left out. + */ +export async function findInFlight(options: { + runsDir: string; + runId: string; + sourceIds: string[]; + secretKey: string; + schedule: ApiScheduler; +}): Promise<{ sourceId: string; clerkId: string }[]> { + if (options.sourceIds.length === 0) return []; + const found = await lookupUsers({ + filter: "external_id", + values: options.sourceIds, + secretKey: options.secretKey, + schedule: options.schedule, + }); + const otherRuns = clerkIdsCreatedByOtherRuns(options.runsDir, options.runId); + const wanted = new Set(options.sourceIds); + return found + .filter((user) => user.external_id && wanted.has(user.external_id) && !otherRuns.has(user.id)) + .map((user) => ({ sourceId: user.external_id as string, clerkId: user.id })); +} diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 54ad5a9a87..ac53eeda2e 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -9,9 +9,11 @@ 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 { latestUserLines, listRuns, readRun, startRun } from "./lib/run-store.ts"; import { explainErrors, run, validateRunOptions } from "./run.ts"; +// After run.ts: imported first, signals.ts loads version.ts ahead of its Bun +// macro here, and the file fails to load. +import { _resetInterruptState, abortInFlight, beginInterrupt } from "../../lib/signals.ts"; /** A real-shaped bcrypt digest: the checks reject anything that is not. */ const BCRYPT = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; @@ -23,13 +25,6 @@ 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-"))); @@ -251,7 +246,7 @@ describe("run", () => { test("records the run in the project's run store", async () => { await run(baseOptions); - const [record, ...rest] = runsIn(runsDir()); + const [record, ...rest] = listRuns(runsDir()); expect(rest).toHaveLength(0); expect(record).toMatchObject({ kind: "import", @@ -270,6 +265,22 @@ 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)}'`); + }); + 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/"); @@ -285,8 +296,8 @@ describe("run", () => { test("--runs-dir puts the run somewhere else", async () => { await run({ ...baseOptions, runsDir: "elsewhere" }); - expect(runsIn(path.join(workDir, "elsewhere"))).toHaveLength(1); - expect(runsIn(runsDir())).toHaveLength(0); + expect(listRuns(path.join(workDir, "elsewhere"))).toHaveLength(1); + expect(listRuns(runsDir())).toHaveLength(0); }); test("--require-password leaves out the users without one", async () => { @@ -294,7 +305,7 @@ describe("run", () => { expect(created()).toEqual(["u1"]); expect(captured.err).toContain("leaving out 1 user without a password"); - const [record] = runsIn(runsDir()); + const [record] = listRuns(runsDir()); expect(latestUserLines(runsDir(), record!.id).get("u2")).toMatchObject({ status: "skipped", reason: "no password (--require-password)", @@ -339,7 +350,7 @@ describe("run", () => { await run(baseOptions); expect(process.exitCode).toBe(1); - expect(runsIn(runsDir())[0]).toMatchObject({ status: "partial", counts: { failed: 1 } }); + expect(listRuns(runsDir())[0]).toMatchObject({ status: "partial", counts: { failed: 1 } }); }); // Tests run non-TTY, the same signal an agent gives. @@ -373,7 +384,7 @@ describe("run", () => { 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); + expect(listRuns(runsDir())).toHaveLength(0); }); test("with several reasons, names the most common and points --json at the rest", async () => { @@ -419,7 +430,7 @@ describe("run", () => { await run({ ...baseOptions, allowPartial: true }); expect(created()).toEqual(["u1", "u2"]); - const [record] = runsIn(runsDir()); + const [record] = listRuns(runsDir()); expect(record).toMatchObject({ status: "partial", counts: { created: 2, skipped: 1 } }); expect(latestUserLines(runsDir(), record!.id).get("u3")).toMatchObject({ status: "skipped", @@ -438,7 +449,7 @@ describe("run", () => { await run({ ...baseOptions, dryRun: true }); expect(created()).toHaveLength(0); - expect(runsIn(runsDir())).toHaveLength(0); + expect(listRuns(runsDir())).toHaveLength(0); expect(captured.err).toContain("Dry run: nothing was written."); expect(process.exitCode).toBe(2); }); @@ -520,7 +531,7 @@ describe("run", () => { await run({ ...baseOptions, allowPartial: true }); expect(created()).toEqual(["u1"]); - const [record] = runsIn(runsDir()); + const [record] = listRuns(runsDir()); expect(latestUserLines(runsDir(), record!.id).get("u2")?.reason).toContain("100-user limit"); }); @@ -657,7 +668,7 @@ describe("run", () => { delete process.env.CLERK_MIGRATE_CONCURRENCY_LIMIT; } - const [record] = runsIn(runsDir()); + const [record] = listRuns(runsDir()); // The run folder is the only record of who was created. expect(captured.err).toContain(`Run ${record?.id}: `); expect(record?.finishedAt).toBeUndefined(); @@ -784,22 +795,205 @@ describe("run", () => { }); }); - 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 () => { + describe("continuing an earlier run", () => { + test("a complete run is not imported again", async () => { await run(baseOptions); - stubClerk({ - existing: [ - { id: "user_u1", external_id: "u1" }, - { id: "user_u2", external_id: "u2" }, - ], + 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(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); + 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); }); }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index e089cd8ebb..9792760955 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -6,9 +6,10 @@ * * An import goes through the same steps every time: * - * 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. + * 1. Settle the file, the source 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. @@ -38,11 +39,26 @@ import { importUsers } from "./import-users.ts"; import { checkImport, type ImportChecks } from "./lib/checks.ts"; import { fetchInstanceSettings, fetchUserCount } from "./lib/clerk-config.ts"; import { DEV_USER_LIMIT, resolveLimits, type InstanceType } from "./lib/instance.ts"; -import { resolveRunsDir, sha256File, startRun, type Run, type RunRecord } from "./lib/run-store.ts"; +import { + continueRun, + latestUserLines, + listRuns, + liveLockPid, + lockFile, + resolveRunsDir, + 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 { printTarget, resolveClerkTarget } from "./lib/target.ts"; +import { keyInstanceId, printTarget, resolveClerkTarget } from "./lib/target.ts"; +import { findInFlight } from "./lib/user-lookup.ts"; import { fileExists, getFileType, @@ -72,6 +88,8 @@ 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; @@ -270,6 +288,76 @@ async function applySource(options: MigrateRunOptions): Promise + record.kind === "import" && + record.file?.sha256 === match.sha256 && + record.source === match.source && + (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. */ @@ -376,6 +464,17 @@ function formatSummary( return lines; } +/** + * Once every user is in, the run is only needed for `undo`, and it holds user + * data. + */ +function cleanupLines(runsDir: string, record: RunRecord): string[] { + return [ + `Keep run ${record.id} while you might still undo it. After that:`, + dim(` rm -rf ${quoteArg(runDir(runsDir, record.id))}`), + ]; +} + // --- The import ------------------------------------------------------------ /** @@ -387,6 +486,7 @@ function commandFor(options: MigrateRunOptions, extra: string[]) { const parts = ["clerk migrate import", quoteArg(input)]; 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.secretKey) parts.push("--secret-key", ""); @@ -411,9 +511,15 @@ function rejectSummary(checks: ImportChecks, json = false): string { 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 { +/** + * 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", @@ -446,18 +552,83 @@ 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, + 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), ); - let users = loaded.users; + 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 rejects. + // password are left out of the job rather than recorded as skipped. let withoutPassword: User[] = []; if (options.requirePassword) { - withoutPassword = users.filter((user) => !user.password); + 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.`, @@ -512,7 +683,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { checkImport({ users, skipLegalChecks, - failures: loaded.failures, + failures, unknownFields: loaded.unknownFields, ...(supabaseRows ? { supabaseRows } : {}), settings, @@ -521,6 +692,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { target, secretKey, schedule, + adoptedClerkIds: new Set(adopted.values()), spinner, }), ); @@ -532,7 +704,14 @@ export async function run(rawOptions: MigrateRunOptions): Promise { const preview = (extra: Record) => log.data( JSON.stringify( - { target, run: null, checks: checksJson(checks), ...leftOut, ...extra }, + { + target, + run: continued ?? null, + resume: resume.kind, + checks: checksJson(checks), + ...leftOut, + ...extra, + }, null, 2, ), @@ -571,8 +750,11 @@ export async function run(rawOptions: MigrateRunOptions): Promise { if ( checks.importable.length === 0 && checks.rejects.length === 0 && - withoutPassword.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; @@ -609,17 +791,19 @@ 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 = startRun(runsDir, { - kind: "import", - target, - source, - file: { path: filePath, sha256 }, - }); + const run = continued + ? continueRun(runsDir, continued) + : startRun(runsDir, { + kind: "import", + target, + 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); + recordRejects(run, checks, adopted); for (const user of withoutPassword) { run.append({ sourceId: user.userId, @@ -629,7 +813,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { } const summary: ImportSummary = - checks.importable.length > 0 + checks.importable.length > 0 || attachOnly.length > 0 ? await withProgress( { total: checks.importable.length, verb: "created" }, async (progress) => @@ -638,6 +822,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { secretKey, limits, record: run.append, + attachOnly, + adopted, skipPasswordRequirement: !options.requirePassword, progress, }), @@ -668,6 +854,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { { target, run: record, + resume: resume.kind, checks: checksJson(checks), ...leftOut, result: { @@ -700,11 +887,15 @@ 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(run.dir) - : NEXT_STEPS.MIGRATE_DONE(run.dir); + ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS(record.id) + : NEXT_STEPS.MIGRATE_DONE(record.id); setNextSteps(steps); printAgentNextSteps(steps); }, diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts new file mode 100644 index 0000000000..5a751b9707 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { startRun } from "./lib/run-store.ts"; +import { runs } from "./runs.ts"; + +const captured = useCaptureLog(); + +let runsDir: string; + +beforeEach(() => { + runsDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-runs-"))); +}); + +afterEach(() => { + fs.rmSync(runsDir, { recursive: true, force: true }); +}); + +function partialImport() { + const run = startRun(runsDir, { + kind: "import", + target: { appLabel: "My App", env: "development", instanceId: "ins_1" }, + source: "clerk", + file: { path: "/tmp/users.json", sha256: "abc" }, + }); + run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + run.append({ sourceId: "b", status: "failed", error: "That email address is taken." }); + run.append({ sourceId: "c", status: "failed", error: "That email address is taken." }); + run.append({ sourceId: "d", status: "skipped", reason: "no password (--require-password)" }); + return run.finish(); +} + +describe("runs", () => { + test("names the runs folder, then lists each run", async () => { + const record = partialImport(); + + await runs(undefined, { runsDir }); + + expect(captured.err).toContain(`Runs folder: ${runsDir}`); + expect(captured.err).toContain(record.id); + expect(captured.err).toContain("My App (development, ins_1)"); + expect(captured.err).toContain("1 created, 2 failed, 1 skipped"); + }); + + // Counts are written at the finish, which an interrupted run never reaches. + test("counts an interrupted run from its user lines", async () => { + const run = startRun(runsDir, { + kind: "import", + target: { env: "development", instanceId: "ins_1" }, + source: "clerk", + }); + run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + run.append({ sourceId: "b", status: "created", clerkId: "user_b" }); + fs.rmSync(path.join(run.dir, "lock")); + + await runs(undefined, { runsDir }); + + expect(captured.err).toContain("interrupted"); + expect(captured.err).toContain("2 created"); + }); + + test("says so when there are no runs", async () => { + await runs(undefined, { runsDir }); + expect(captured.err).toContain("No migration runs yet."); + }); + + test("--json lists the runs on stdout, with each one's state", async () => { + const record = partialImport(); + + await runs(undefined, { runsDir, json: true }); + + const parsed = JSON.parse(captured.out) as { runsDir: string; runs: { id: string }[] }; + expect(parsed).toMatchObject({ runsDir, runs: [{ id: record.id, state: "partial" }] }); + }); +}); + +describe("runs ", () => { + test("shows the counts, the error breakdown and who did not make it", async () => { + const record = partialImport(); + + await runs(record.id, { runsDir }); + + expect(captured.err).toContain("partial"); + expect(Bun.stripANSI(captured.err)).toContain("2 users: That email address is taken."); + expect(captured.err).toContain("Failed (2)"); + expect(captured.err).toContain("Skipped (1)"); + expect(captured.err).toContain("no password (--require-password)"); + expect(captured.err).toContain(path.join(runsDir, record.id, "users.ndjson")); + }); + + test("--json returns the run, its errors, and the failed and skipped users", async () => { + const record = partialImport(); + + await runs(record.id, { runsDir, json: true }); + + const parsed = JSON.parse(captured.out) as Record; + expect(parsed).toMatchObject({ + run: { id: record.id, state: "partial", counts: { created: 1, failed: 2, skipped: 1 } }, + errors: [{ error: "That email address is taken.", count: 2 }], + failed: [{ sourceId: "b" }, { sourceId: "c" }], + skipped: [{ sourceId: "d" }], + }); + }); + + test("an unknown ID is a usage error", async () => { + await expect(runs("nope", { runsDir })).rejects.toThrow(/No run `nope`/); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts new file mode 100644 index 0000000000..5c9a70d9f4 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -0,0 +1,246 @@ +/** + * `clerk migrate runs [run-id]` — what every import, export and undo did. + * + * Reads the run store and nothing else. `runs` alone lists every run, newest + * first. `runs ` shows one: its header, counts, the error breakdown, the + * users that did not make it, and the command to run next. + */ + +import path from "node:path"; +import { bold, dim, green, red, yellow } from "../../lib/color.ts"; +import { throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { normalizeErrorMessage } from "./import-users.ts"; +import { + countLines, + latestUserLines, + listRuns, + readRun, + resolveRunsDir, + runDir, + runState, + type RunRecord, + type RunState, + type UserLine, +} from "./lib/run-store.ts"; +import { describeTarget } from "./lib/target.ts"; + +export type RunsOptions = { + json?: boolean; + runsDir?: string; +}; + +/** How many failed or skipped users `runs ` prints before pointing at the file. */ +const USER_LIST_LIMIT = 20; + +function formatDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); +} + +function colorState(state: RunState): string { + if (state === "complete") return green(state); + if (state === "partial" || state === "interrupted") return yellow(state); + if (state === "undone") return dim(state); + return state; +} + +/** The counts that matter for a run's kind, in the order they are read. */ +export function formatCounts(record: RunRecord): string { + const counts = record.counts; + const order = + record.kind === "export" + ? (["exported", "skipped"] as const) + : record.kind === "undo" + ? (["deleted", "failed"] as const) + : (["created", "failed", "skipped"] as const); + const parts = order + .filter((status) => (counts[status] ?? 0) > 0 || status === order[0]) + .map((status) => `${counts[status] ?? 0} ${status}`); + return parts.join(", "); +} + +function fileLabel(record: RunRecord): string { + if (!record.file) return ""; + const relative = path.relative(process.cwd(), record.file.path); + return relative.startsWith("..") ? record.file.path : relative; +} + +function pad(rows: string[][]): string[] { + const widths = rows[0]!.map((_, column) => + Math.max(...rows.map((row) => Bun.stringWidth(row[column] ?? ""))), + ); + return rows.map((row) => + row + .map((cell, column) => + column === row.length - 1 + ? cell + : cell + " ".repeat(widths[column]! - Bun.stringWidth(cell)), + ) + .join(" ") + .trimEnd(), + ); +} + +/** Error messages grouped the way the import summary groups them. */ +function errorBreakdown(lines: UserLine[]): { error: string; count: number }[] { + const counts = new Map(); + for (const line of lines) { + if (line.status !== "failed" || !line.error) continue; + const key = normalizeErrorMessage(line.error); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts].map(([error, count]) => ({ error, count })).sort((a, b) => b.count - a.count); +} + +/** What to run after reading about this run. */ +export function nextCommands(record: RunRecord, state: RunState): string[] { + if (state === "running") return []; + if (record.kind === "import" && state !== "undone") { + return [`Run \`clerk migrate undo ${record.id}\` to delete the users it created`]; + } + if (record.kind === "undo" && state !== "complete" && record.undoes) { + return [`Run \`clerk migrate undo ${record.undoes}\` to retry the users that failed`]; + } + return []; +} + +function listJson(runsDir: string, records: RunRecord[]) { + return { + runsDir, + runs: records.map((record) => ({ ...record, state: runState(runsDir, record) })), + }; +} + +function printList(runsDir: string, records: RunRecord[]): void { + log.info(`Runs folder: ${runsDir}`); + log.blank(); + + if (records.length === 0) { + log.info("No migration runs yet."); + return; + } + + const rows = [ + ["RUN", "DATE", "KIND", "STATUS", "TARGET", "FILE", "RESULT"].map((header) => bold(header)), + ...records.map((record) => [ + record.id, + formatDate(record.startedAt), + record.kind, + colorState(runState(runsDir, record)), + describeTarget(record.target), + fileLabel(record), + formatCounts(record), + ]), + ]; + for (const line of pad(rows)) log.info(line); + log.blank(); + log.info(dim(`${records.length} run${records.length === 1 ? "" : "s"}`)); +} + +function showJson(runsDir: string, record: RunRecord) { + const lines = [...latestUserLines(runsDir, record.id).values()]; + return { + runsDir, + run: { ...record, state: runState(runsDir, record) }, + next: nextCommands(record, runState(runsDir, record)), + errors: errorBreakdown(lines), + failed: lines.filter((line) => line.status === "failed"), + skipped: lines.filter((line) => line.status === "skipped"), + }; +} + +function printUsers(title: string, lines: UserLine[], usersFile: string): void { + if (lines.length === 0) return; + log.blank(); + log.info(bold(`${title} (${lines.length})`)); + for (const line of lines.slice(0, USER_LIST_LIMIT)) { + const why = line.reason ?? line.error ?? ""; + log.info(` ${line.sourceId}${why ? dim(` ${why}`) : ""}`); + } + if (lines.length > USER_LIST_LIMIT) { + log.info(dim(` …and ${lines.length - USER_LIST_LIMIT} more in ${usersFile}`)); + } +} + +function printRun(runsDir: string, record: RunRecord): void { + const state = runState(runsDir, record); + const dir = runDir(runsDir, record.id); + const usersFile = path.join(dir, "users.ndjson"); + const lines = [...latestUserLines(runsDir, record.id).values()]; + + log.info(`Runs folder: ${runsDir}`); + log.blank(); + const header: [string, string | undefined][] = [ + ["Run", record.id], + ["Kind", record.kind], + ["Status", colorState(state)], + ["Started", formatDate(record.startedAt)], + ["Finished", record.finishedAt ? formatDate(record.finishedAt) : undefined], + ["Target", describeTarget(record.target)], + ["Key from", record.target.keySource], + ["Source", record.source], + ["File", record.file?.path], + ["From export", record.fromExport], + ["Undoes", record.undoes], + ["Undone by", record.undoneBy], + ["Result", formatCounts(record)], + ]; + const shown = header.filter((entry): entry is [string, string] => Boolean(entry[1])); + const width = Math.max(...shown.map(([label]) => label.length)) + 2; + for (const [label, value] of shown) log.info(`${bold(label.padEnd(width))}${value}`); + + const errors = errorBreakdown(lines); + if (errors.length > 0) { + log.blank(); + log.info(bold("Error breakdown")); + for (const { error, count } of errors) { + log.info(` ${red(String(count))} ${count === 1 ? "user" : "users"}: ${error}`); + } + } + + printUsers( + "Failed", + lines.filter((line) => line.status === "failed"), + usersFile, + ); + printUsers( + "Skipped", + lines.filter((line) => line.status === "skipped"), + usersFile, + ); + + log.blank(); + log.info(dim(`Every outcome: ${usersFile}`)); + for (const step of nextCommands(record, state)) log.info(` → ${step}`); +} + +/** + * Counts are written when a run finishes, so an interrupted one still holds + * its start's zeros. Those are counted from its user lines instead. + */ +function withLiveCounts(runsDir: string, record: RunRecord): RunRecord { + if (record.finishedAt) return record; + return { ...record, counts: countLines(latestUserLines(runsDir, record.id).values()) }; +} + +export async function runs(id: string | undefined, options: RunsOptions = {}): Promise { + const runsDir = await resolveRunsDir(options.runsDir); + + if (id === undefined) { + const records = listRuns(runsDir).map((record) => withLiveCounts(runsDir, record)); + if (options.json) log.data(JSON.stringify(listJson(runsDir, records), null, 2)); + else printList(runsDir, records); + return; + } + + const stored = readRun(runsDir, id); + if (!stored) { + throwUsageError(`No run \`${id}\` in ${runsDir}. Run \`clerk migrate runs\` to list them.`); + } + const record = withLiveCounts(runsDir, stored); + + if (options.json) log.data(JSON.stringify(showJson(runsDir, record), null, 2)); + else printRun(runsDir, record); +} diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts new file mode 100644 index 0000000000..46a20d0e8f --- /dev/null +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -0,0 +1,323 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { EXIT_CODE, type CliError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { latestUserLines, listRuns, readRun, startRun, type RunRecord } from "./lib/run-store.ts"; +import { keyInstanceId } from "./lib/target.ts"; +import { undo } from "./undo.ts"; + +const captured = useCaptureLog(); + +let runsDir: string; +let originalFetch: typeof globalThis.fetch; +let requests: { method: string; url: string }[]; +/** Clerk IDs the stubbed instance still holds, with each one's last sign-in. */ +let instanceUsers: Map; +/** Clerk IDs whose DELETE fails with a 500. */ +let failing: Set; +/** external_id → Clerk ID, for users whose create was in flight. */ +let inFlight: Map; + +const IMPORT_STARTED = "2026-09-01T00:00:00.000Z"; +const AFTER_IMPORT = Date.parse("2026-09-02T00:00:00.000Z"); + +beforeAll(() => { + originalFetch = globalThis.fetch; +}); + +afterAll(() => { + globalThis.fetch = originalFetch; +}); + +beforeEach(() => { + runsDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-undo-"))); + requests = []; + failing = new Set(); + inFlight = new Map(); + instanceUsers = new Map([ + ["user_a", null], + ["user_b", AFTER_IMPORT], + ]); + + 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 (method === "GET" && url.pathname === "/v1/users" && url.searchParams.has("external_id")) { + return Response.json( + url.searchParams + .getAll("external_id") + .map((externalId) => externalId.replace(/^\+/, "")) + .filter((externalId) => inFlight.has(externalId)) + .map((externalId) => ({ id: inFlight.get(externalId), external_id: externalId })), + ); + } + if (method === "GET" && url.pathname === "/v1/users") { + const ids = url.searchParams.getAll("user_id"); + return Response.json( + ids + .filter((id) => instanceUsers.has(id)) + .map((id) => ({ id, last_sign_in_at: instanceUsers.get(id) })), + ); + } + if (method === "DELETE") { + const id = url.pathname.split("/").pop() as string; + if (failing.has(id)) { + return Response.json({ errors: [{ code: "boom", message: "boom" }] }, { status: 500 }); + } + instanceUsers.delete(id); + return Response.json({ id, deleted: true }); + } + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + process.exitCode = 0; + fs.rmSync(runsDir, { recursive: true, force: true }); +}); + +/** An import into `ins_1` that created two users and failed one. */ +function importRun(overrides: Partial = {}): RunRecord { + const run = startRun(runsDir, { + kind: "import", + target: { instanceId: "ins_1", env: "development" }, + source: "clerk", + }); + run.update({ startedAt: IMPORT_STARTED }); + run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + run.append({ sourceId: "b", status: "created", clerkId: "user_b" }); + run.append({ sourceId: "c", status: "failed", error: "taken" }); + const record = run.finish(); + if (Object.keys(overrides).length > 0) run.update(overrides); + return { ...record, ...overrides }; +} + +const options = { secretKey: "sk_test_x", runsDir: "" }; +const withDir = (extra: Record = {}) => ({ ...options, runsDir, ...extra }); +const deletes = () => requests.filter((request) => request.method === "DELETE"); + +describe("refusals, all exit 2 and delete nothing", () => { + const exitCodeOf = async (promise: Promise) => { + const error = (await promise.catch((caught: unknown) => caught)) as CliError; + return error.exitCode; + }; + + test("an unknown run", async () => { + expect(await exitCodeOf(undo("nope", withDir({ yes: true })))).toBe(EXIT_CODE.USAGE); + }); + + test("a run that is not an import", async () => { + const run = startRun(runsDir, { kind: "export", target: { platform: "auth0" } }); + run.finish(); + await expect(undo(run.record.id, withDir({ yes: true }))).rejects.toThrow( + /is an export run\. Only an import run can be undone/, + ); + expect(deletes()).toHaveLength(0); + }); + + test("a run already undone", async () => { + const record = importRun({ status: "undone", undoneBy: "20260901-000000-beef" }); + await expect(undo(record.id, withDir({ yes: true }))).rejects.toThrow( + /already undone by run 20260901-000000-beef/, + ); + }); + + test("a key that addresses a different instance, naming both", async () => { + const record = importRun({ target: { instanceId: "ins_other", env: "production" } }); + await expect(undo(record.id, withDir({ yes: true }))).rejects.toThrow( + /imported into instance \(production, ins_other\).*addresses instance \(development, ins_1\)/s, + ); + expect(deletes()).toHaveLength(0); + }); + + // `key_` is the identity fallback when GET /v1/instance failed, e.g. rate + // limited straight after a large import. That is unknown, not different. + test("an unconfirmed instance is not reported as a different one", async () => { + const record = importRun({ + target: { instanceId: "key_0123456789abcdef", env: "development" }, + }); + const error = (await undo(record.id, withDir({ yes: true })).catch((e: unknown) => e)) as Error; + expect(error.message).toContain("Could not confirm"); + expect(error.message).not.toContain("addresses instance"); + expect(deletes()).toHaveLength(0); + }); + + // The import ran while Clerk could not name the instance; the same key is + // the same instance once it can. + test("accepts a run recorded under this key's stand-in ID", async () => { + const record = importRun({ + target: { instanceId: keyInstanceId("sk_test_x"), env: "development" }, + }); + + await undo(record.id, withDir({ yes: true })); + + expect(deletes()).toHaveLength(2); + }); + + test("no consent where nobody can be asked: the preview, then the command", async () => { + const record = importRun(); + + const error = (await undo(record.id, withDir()).catch((caught: unknown) => caught)) as CliError; + + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain("Pass --yes to confirm"); + expect(captured.err).toContain("Will delete 2 users"); + expect(deletes()).toHaveLength(0); + }); +}); + +describe("--dry-run", () => { + test("previews the deletes and how many users signed in since, and deletes nothing", async () => { + const record = importRun(); + + await undo(record.id, withDir({ dryRun: true })); + + expect(captured.err).toContain("Target: development instance ins_1"); + expect(captured.err).toContain("Will delete 2 users"); + expect(captured.err).toContain("1 of them has signed in since the import"); + expect(deletes()).toHaveLength(0); + expect(listRuns(runsDir)).toHaveLength(1); + }); + + test("--json returns the target, the run and the preview", async () => { + const record = importRun(); + + await undo(record.id, withDir({ dryRun: true, json: true })); + + expect(JSON.parse(captured.out)).toMatchObject({ + target: { instanceId: "ins_1", keySource: "--secret-key" }, + run: { id: record.id }, + preview: { toDelete: 2, signedInSince: 1, alreadyGone: 0, alreadyDeleted: 0 }, + dryRun: true, + }); + }); +}); + +describe("deleting", () => { + // The run stopped with this user's POST /v1/users in flight: Clerk created + // it, but its ID never reached the run record. + test("finds a user whose create was in flight by external_id, and deletes it", async () => { + const run = startRun(runsDir, { + kind: "import", + target: { instanceId: "ins_1", env: "development" }, + source: "clerk", + }); + run.update({ startedAt: IMPORT_STARTED }); + run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + run.append({ sourceId: "d", status: "creating" }); + run.append({ sourceId: "e", status: "creating" }); + const record = run.finish(); + inFlight.set("d", "user_d"); + instanceUsers.set("user_d", null); + + await undo(record.id, withDir({ yes: true })); + + expect(deletes().map((request) => request.url.split("/").pop())).toEqual( + expect.arrayContaining(["user_a", "user_d"]), + ); + expect(deletes()).toHaveLength(2); + }); + + // Run A stopped with d's create in flight; a later run B then created d. + // The user Clerk holds is B's, so undoing A leaves it alone. + test("leaves an in-flight user that another import run records as created", async () => { + const runA = startRun(runsDir, { + kind: "import", + target: { instanceId: "ins_1", env: "development" }, + source: "clerk", + }); + runA.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + runA.append({ sourceId: "d", status: "creating" }); + const recordA = runA.finish(); + const runB = startRun(runsDir, { + kind: "import", + target: { instanceId: "ins_1", env: "development" }, + source: "clerk", + }); + runB.append({ sourceId: "d", status: "created", clerkId: "user_d" }); + runB.finish(); + inFlight.set("d", "user_d"); + instanceUsers.set("user_d", null); + + await undo(recordA.id, withDir({ yes: true })); + + expect(deletes().map((request) => request.url.split("/").pop())).toEqual(["user_a"]); + }); + + test("deletes what the import created, records an undo run, and marks the import undone", async () => { + const record = importRun(); + + await undo(record.id, withDir({ yes: true })); + + expect( + deletes() + .map((request) => new URL(request.url).pathname) + .sort(), + ).toEqual(["/v1/users/user_a", "/v1/users/user_b"]); + const undoRun = listRuns(runsDir).find((candidate) => candidate.kind === "undo"); + expect(undoRun).toMatchObject({ + status: "complete", + undoes: record.id, + counts: { deleted: 2 }, + }); + expect(readRun(runsDir, record.id)).toMatchObject({ + status: "undone", + undoneBy: undoRun?.id, + }); + expect(process.exitCode).toBe(0); + }); + + test("counts a user already gone from the instance as deleted, without asking Clerk", async () => { + instanceUsers.delete("user_a"); + const record = importRun(); + + await undo(record.id, withDir({ yes: true })); + + expect(deletes()).toHaveLength(1); + expect(readRun(runsDir, record.id)?.status).toBe("undone"); + }); + + test("a failed delete leaves the undo partial, exits 1, and a re-run continues it", async () => { + failing.add("user_b"); + const record = importRun(); + + await undo(record.id, withDir({ yes: true })); + + expect(process.exitCode).toBe(1); + const [partial] = listRuns(runsDir).filter((candidate) => candidate.kind === "undo"); + expect(partial).toMatchObject({ status: "partial", counts: { deleted: 1, failed: 1 } }); + expect(readRun(runsDir, record.id)?.status).toBe("partial"); + + failing.clear(); + requests = []; + process.exitCode = 0; + await undo(record.id, withDir({ yes: true })); + + // Only the user that failed is retried, in the same undo run. + expect(deletes().map((request) => new URL(request.url).pathname)).toEqual(["/v1/users/user_b"]); + const undoRuns = listRuns(runsDir).filter((candidate) => candidate.kind === "undo"); + expect(undoRuns).toHaveLength(1); + expect(undoRuns[0]).toMatchObject({ id: partial?.id, status: "complete" }); + expect(latestUserLines(runsDir, partial!.id).get("b")?.status).toBe("deleted"); + expect(readRun(runsDir, record.id)?.status).toBe("undone"); + }); + + test("--json --yes returns the undo run and the result", async () => { + const record = importRun(); + + await undo(record.id, withDir({ yes: true, json: true })); + + expect(JSON.parse(captured.out)).toMatchObject({ + run: { kind: "undo", undoes: record.id, status: "complete" }, + result: { deleted: 2, failed: 0, errors: [] }, + }); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts new file mode 100644 index 0000000000..c6660a0333 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -0,0 +1,455 @@ +/** + * `clerk migrate undo ` — delete the users an import 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 next to it. Nothing is + * matched by searching the instance, so nothing the import did not create is + * ever in scope. + * + * Refuses (exit 2) rather than guessing when: + * - the resolved key addresses a different instance than the run imported into + * - the run is not an import + * - the run has already been undone + * + * The undo is a run of its own (`kind: "undo"`, `undoes: `). The import is + * marked `undone` only once every user is gone. A partial undo exits 1, and + * running `undo` again continues the same undo run. + */ + +import { bapiRequest } from "../../lib/bapi.ts"; +import { bold, dim, green, red } from "../../lib/color.ts"; +import { BapiError, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { confirm } from "../../lib/prompts.ts"; +import { withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; +import { isAgent, isHuman } from "../../mode.ts"; +import { normalizeErrorMessage } from "./import-users.ts"; +import { resolveLimits, type ResolvedLimits } from "./lib/instance.ts"; +import { RateLimitExceededError, retryOn429 } from "./lib/retry.ts"; +import { + continueRun, + latestUserLines, + listRuns, + liveLockPid, + lockFile, + patchRun, + readRun, + resolveRunsDir, + runState, + startRun, + type Run, + type RunRecord, +} from "./lib/run-store.ts"; +import { withProgress, type ProgressUpdate } from "./lib/progress.ts"; +import { createApiScheduler, type ApiScheduler } from "./lib/scheduler.ts"; +import { + describeTarget, + keyInstanceId, + printTarget, + resolveClerkTarget, + type ClerkTarget, +} from "./lib/target.ts"; +import { findInFlight, lookupUsers } from "./lib/user-lookup.ts"; + +export type UndoOptions = { + dryRun?: boolean; + yes?: boolean; + json?: boolean; + secretKey?: string; + app?: string; + instance?: string; + runsDir?: string; +}; + +/** One user the import created, still to be deleted. */ +export type UndoUser = { sourceId: string; clerkId: string }; + +export type UndoPreview = { + /** Users that will be deleted. */ + toDelete: number; + /** Of those, how many have signed in since the import started. */ + signedInSince: number; + /** Users the import created that are already gone from the instance. */ + alreadyGone: number; + /** Users an earlier attempt at this undo already deleted. */ + alreadyDeleted: number; +}; + +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; + +/** Loads the import run, refusing anything that cannot be undone. */ +function readImportRun(runsDir: string, runId: string): RunRecord { + const record = readRun(runsDir, runId); + if (!record) { + throwUsageError(`No run \`${runId}\` in ${runsDir}. Run \`clerk migrate runs\` to list them.`); + } + if (record.kind !== "import") { + throwUsageError( + `Run ${runId} is an ${record.kind} run. Only an import run can be undone.`, + undefined, + undefined, + [{ command: "clerk migrate runs", description: "Find the import run to undo" }], + ); + } + if (record.status === "undone") { + throwUsageError(`Run ${runId} was already undone by run ${record.undoneBy ?? "(unknown)"}.`); + } + if (runState(runsDir, record) === "running") { + throwUsageError( + `Run ${runId} is still running (PID ${liveLockPid(runsDir, runId)}). Wait for it to finish, then undo it. ` + + `If that process is not a migrate run, delete ${lockFile(runsDir, runId)}.`, + ); + } + return record; +} + +/** Refuses to delete from an instance the import did not write to. */ +function assertSameInstance(record: RunRecord, target: ClerkTarget, secretKey: string): void { + if (record.target.instanceId === target.instanceId) return; + // Recorded under the key's stand-in ID while Clerk could not name the + // instance: the same key is the same instance. + if (record.target.instanceId === keyInstanceId(secretKey)) return; + // A `key_` ID is the fallback for an instance lookup that failed. It cannot + // be compared with an `ins_` ID, so this is "unknown", not "different". + const unconfirmed = [record.target.instanceId, target.instanceId].some((id) => + id?.startsWith("key_"), + ); + if (unconfirmed) { + throwUsageError( + `Could not confirm that the resolved key addresses the instance run ${record.id} imported into: ` + + "Clerk did not answer GET /v1/instance (often rate limiting straight after a large import). " + + "Nothing was deleted. Try again in a minute; --verbose shows the response.", + ); + } + throwUsageError( + `Run ${record.id} imported into ${describeTarget(record.target)}, but the resolved key ` + + `addresses ${describeTarget(target)}. Nothing was deleted.\n` + + "Target the instance the import used with --secret-key, --app or --instance.", + ); +} + +/** The latest undo of this import that has not finished its job, if any. */ +function findOpenUndo(runsDir: string, importId: string): RunRecord | undefined { + return listRuns(runsDir).find( + (candidate) => + candidate.kind === "undo" && + candidate.undoes === importId && + runState(runsDir, candidate) !== "complete", + ); +} + +/** + * Every user the import created, minus those an earlier undo already deleted. + * + * `unconfirmed` holds the source IDs whose create was in flight when the run + * stopped: they may exist in Clerk with no ID on record. + */ +function usersToDelete( + runsDir: string, + record: RunRecord, + openUndo: RunRecord | undefined, +): { users: UndoUser[]; unconfirmed: string[]; alreadyDeleted: number } { + const deleted = new Set(); + if (openUndo) { + for (const line of latestUserLines(runsDir, openUndo.id).values()) { + if (line.status === "deleted") deleted.add(line.sourceId); + } + } + + const users: UndoUser[] = []; + const unconfirmed: string[] = []; + for (const line of latestUserLines(runsDir, record.id).values()) { + if (deleted.has(line.sourceId)) continue; + if (line.status === "creating") unconfirmed.push(line.sourceId); + if (line.status !== "created" || !line.clerkId) continue; + users.push({ sourceId: line.sourceId, clerkId: line.clerkId }); + } + return { users, unconfirmed, alreadyDeleted: deleted.size }; +} + +/** + * Reads each user back from the instance, for the preview. + * + * @returns The users still there, and how many of them have signed in since + * the import started — the ones whose deletion someone will notice. + */ +async function inspectUsers(options: { + users: UndoUser[]; + since: string; + secretKey: string; + schedule: ApiScheduler; + spinner?: SpinnerControls; +}): Promise<{ present: UndoUser[]; gone: UndoUser[]; signedInSince: number }> { + const found = await lookupUsers({ + filter: "user_id", + values: options.users.map((user) => user.clerkId), + secretKey: options.secretKey, + schedule: options.schedule, + spinner: options.spinner, + label: "Checking the imported users", + }); + + const byId = new Map(found.map((user) => [user.id, user])); + const since = Date.parse(options.since); + const present = options.users.filter((user) => byId.has(user.clerkId)); + const gone = options.users.filter((user) => !byId.has(user.clerkId)); + const signedInSince = present.filter((user) => { + const lastSignIn = byId.get(user.clerkId)?.last_sign_in_at; + return typeof lastSignIn === "number" && lastSignIn > since; + }).length; + + return { present, gone, signedInSince }; +} + +export type UndoSummary = { + deleted: number; + failed: number; + errorBreakdown: Map; +}; + +/** Deletes each user, rate-limited and 429-retried exactly as the import is. */ +async function deleteUsers(options: { + users: UndoUser[]; + secretKey: string; + limits: ResolvedLimits; + run: Run; + progress?: ProgressUpdate; +}): Promise { + const { users, secretKey, limits, run, progress: report } = options; + const schedule = createApiScheduler(limits.concurrencyLimit, limits.rateLimit); + const errorBreakdown = new Map(); + let processed = 0; + let deleted = 0; + let failed = 0; + + const progress = () => report?.({ done: processed, ok: deleted, failed }); + + // A failure on one user must not stop the rest: a half-undone import with + // no record of which half is far worse than a reported failure. + const deleteOne = async (user: UndoUser): Promise => { + const retries: string[] = []; + try { + await retryOn429( + async () => + schedule(async () => + bapiRequest({ method: "DELETE", path: `/v1/users/${user.clerkId}`, secretKey }), + ), + { onRetry: ({ message }) => retries.push(message) }, + ); + deleted++; + run.append({ + sourceId: user.sourceId, + clerkId: user.clerkId, + status: "deleted", + ...(retries.length > 0 ? { error: retries.join("; ") } : {}), + }); + } catch (error) { + // Gone already, by the dashboard or another undo: the goal is met. + if (error instanceof BapiError && error.status === 404) { + deleted++; + run.append({ + sourceId: user.sourceId, + clerkId: user.clerkId, + status: "deleted", + reason: "already deleted", + }); + } else { + failed++; + const message = + error instanceof RateLimitExceededError + ? error.message + : ((error as BapiError).longMessage ?? (error as Error).message ?? "Unknown error"); + const code = + error instanceof RateLimitExceededError + ? "429" + : String((error as BapiError).status ?? "unknown"); + const normalized = normalizeErrorMessage(message); + errorBreakdown.set(normalized, (errorBreakdown.get(normalized) ?? 0) + 1); + run.append({ + sourceId: user.sourceId, + clerkId: user.clerkId, + status: "failed", + error: [message, ...retries].join("; "), + code, + }); + } + } + processed++; + progress(); + }; + + progress(); + await Promise.all(users.map(deleteOne)); + return { deleted, failed, errorBreakdown }; +} + +function printPreview(record: RunRecord, preview: UndoPreview): void { + const are = (count: number) => (count === 1 ? "is" : "are"); + log.blank(); + log.info( + `${bold(`Will delete ${plural(preview.toDelete, "user")}`)} created by import run ${record.id}.`, + ); + if (preview.signedInSince > 0) { + const who = + preview.signedInSince === 1 ? "1 of them has" : `${preview.signedInSince} of them have`; + log.warn(`${who} signed in since the import. Deleting removes their accounts and sessions.`); + } + if (preview.alreadyGone > 0) { + log.info( + dim( + `${plural(preview.alreadyGone, "user")} the import created ${are(preview.alreadyGone)} already gone.`, + ), + ); + } + if (preview.alreadyDeleted > 0) { + log.info( + dim( + `${plural(preview.alreadyDeleted, "user")} ${preview.alreadyDeleted === 1 ? "was" : "were"} deleted by an earlier attempt.`, + ), + ); + } +} + +/** The run header shared by every outcome, human and JSON alike. */ +function jsonResult( + target: ClerkTarget, + record: RunRecord, + preview: UndoPreview, + extra: Record = {}, +) { + return { target, run: record, preview, ...extra }; +} + +export async function undo(runId: string, options: UndoOptions = {}): Promise { + const runsDir = await resolveRunsDir(options.runsDir); + const record = readImportRun(runsDir, runId); + + const { secretKey, target } = await resolveClerkTarget(options); + if (!options.json) printTarget(target); + assertSameInstance(record, target, secretKey); + + const limits = resolveLimits(secretKey); + const openUndo = findOpenUndo(runsDir, record.id); + const recorded = usersToDelete(runsDir, record, openUndo); + const { alreadyDeleted } = recorded; + + const schedule = createApiScheduler(limits.concurrencyLimit, limits.rateLimit); + const users = [ + ...recorded.users, + ...(await findInFlight({ + runsDir, + runId: record.id, + sourceIds: recorded.unconfirmed, + secretKey, + schedule, + })), + ]; + const { present, gone, signedInSince } = + users.length > 0 + ? await withSpinner("Checking the imported users...", async (spinner) => + inspectUsers({ users, since: record.startedAt, secretKey, schedule, spinner }), + ) + : { present: [], gone: [], signedInSince: 0 }; + + const preview: UndoPreview = { + toDelete: present.length, + signedInSince, + alreadyGone: gone.length, + alreadyDeleted, + }; + + if (!options.json) printPreview(record, preview); + + const command = `clerk migrate undo ${record.id} --yes`; + const nothingLeft = present.length === 0 && gone.length === 0; + + if (options.dryRun) { + if (options.json) { + log.data(JSON.stringify(jsonResult(target, record, preview, { dryRun: true }), null, 2)); + } else { + log.blank(); + log.info(dim(`Dry run: nothing was deleted. Run \`${command}\` to delete them.`)); + } + return; + } + + // Rule 1: nothing is deleted without consent — `--yes`, or a yes at a + // prompt. `--json` never prompts. + if (!nothingLeft && !options.yes) { + const canAsk = !options.json && isHuman() && !isAgent(); + if (!canAsk) { + if (options.json) { + log.data( + JSON.stringify(jsonResult(target, record, preview, { consent: "required" }), null, 2), + ); + } + throwUsageError( + `\`clerk migrate undo\` permanently deletes ${plural(present.length, "user")} and needs consent. Pass --yes to confirm.`, + undefined, + undefined, + [{ command, description: "Delete the users this import created" }], + ); + } + const proceed = await confirm({ + message: `Permanently delete ${plural(present.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 = openUndo + ? continueRun(runsDir, openUndo) + : startRun(runsDir, { kind: "undo", target, undoes: record.id, source: record.source }); + + // Users the import created that are no longer there: the undo's goal for + // them is already met, so they count as deleted without another request. + for (const user of gone) { + run.append({ ...user, status: "deleted", reason: "not found in the instance" }); + } + + const summary = + present.length > 0 + ? await withProgress({ total: present.length, verb: "deleted" }, async (progress) => + deleteUsers({ users: present, secretKey, limits, run, progress }), + ) + : { deleted: 0, failed: 0, errorBreakdown: new Map() }; + + const undoRecord = run.finish(); + if (undoRecord.status === "complete") { + patchRun(runsDir, record.id, { status: "undone", undoneBy: undoRecord.id }); + } + + if (options.json) { + log.data( + JSON.stringify( + jsonResult(target, undoRecord, preview, { + result: { + deleted: summary.deleted, + failed: summary.failed, + errors: [...summary.errorBreakdown].map(([error, count]) => ({ error, count })), + }, + }), + null, + 2, + ), + ); + } else { + log.blank(); + log.info(`${bold("Deleted:")} ${green(String(summary.deleted))}`); + log.info(`${bold("Failed:")} ${red(String(summary.failed))}`); + for (const [error, count] of summary.errorBreakdown) { + log.info(` ${plural(count, "user")}: ${error}`); + } + log.blank(); + log.info(dim(`Undo run ${undoRecord.id}: ${run.dir}`)); + if (undoRecord.status === "complete") { + log.success(`Import run ${record.id} is undone.`); + } else { + log.info(`Run \`clerk migrate undo ${record.id}\` again to retry the users that failed.`); + } + } + + if (undoRecord.status !== "complete") process.exitCode = 1; +} diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index e11e94a509..487b26ed1f 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -75,9 +75,13 @@ 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: (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`, + 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; @@ -99,9 +103,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 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. + * 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; From 6d37212bafca437fddd4acd5e70a77f50a0ba2f4 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Mon, 5 Oct 2026 14:53:32 -0400 Subject: [PATCH 02/63] test(migrate): undo the e2e import and check the users are gone Co-Authored-By: Claude Opus 5.5 --- test/e2e/migrate.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/e2e/migrate.test.ts b/test/e2e/migrate.test.ts index df94ca644f..00768eac56 100644 --- a/test/e2e/migrate.test.ts +++ b/test/e2e/migrate.test.ts @@ -4,8 +4,8 @@ * * - 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. + * records the run, each bcrypt hash verifies against the password it was + * made from, and `undo` deletes them again. * - 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. @@ -175,6 +175,14 @@ test("a Supabase export dry-runs, imports, and its passwords verify", async () = ]); expect(JSON.parse(verify.stdout.toString())).toMatchObject({ verified: true }); } + + const undo = await cli(["migrate", "undo", result.run.id, "--yes", "--json"]); + expect(undo.exitCode).toBe(0); + for (const { record } of users) { + const line = lines.find((candidate) => candidate.sourceId === record.id); + const gone = await cli(["api", `/users/${line?.clerkId as string}`]); + expect(gone.exitCode).not.toBe(0); + } }, 60_000); test("a user whose only email is unverified is refused where email is required", async () => { From 01830735feba22544817d171fea7bcc7713e6731 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Mon, 5 Oct 2026 14:53:32 -0400 Subject: [PATCH 03/63] docs(changeset): Add clerk migrate runs and undo, and continue a stopped import Co-Authored-By: Claude Opus 5.5 --- .changeset/migrate-runs-undo.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/migrate-runs-undo.md diff --git a/.changeset/migrate-runs-undo.md b/.changeset/migrate-runs-undo.md new file mode 100644 index 0000000000..d9ddd724ba --- /dev/null +++ b/.changeset/migrate-runs-undo.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk migrate runs` and `clerk migrate undo`, and let a re-run of `clerk migrate import` continue where a stopped one left off. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`. From 9dfe6c32a6243bdca65ba505b82b01358474c0af Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Mon, 5 Oct 2026 23:46:38 -0400 Subject: [PATCH 04/63] fix(migrate): lock the import run while undo deletes its users undo checked that the import was not running, then held no lock while it waited for consent. A re-import could continue the run in that window, creating users the undo never listed and overwriting its undone mark. undo now holds the import run's lock from consent until the run is marked undone, and refuses when the run gained users during the preview. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/run-store.ts | 11 ++++ .../src/commands/migrate/undo.test.ts | 51 +++++++++++++++- .../cli-core/src/commands/migrate/undo.ts | 59 +++++++++++++------ 3 files changed, 102 insertions(+), 19 deletions(-) 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 c17ca3bba2..98efa50ac9 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -469,6 +469,17 @@ export function continueRun(runsDir: string, record: RunRecord): Run { return run; } +/** + * Takes a run's lock without opening it, so no other process can continue it. + * + * @returns Releases the lock. + * @throws UsageError when another live process holds it. + */ +export function lockRun(runsDir: string, id: string): () => void { + acquireLock(runsDir, id); + return () => fs.rmSync(lockFile(runsDir, id), { force: true }); +} + /** Merges fields into a run this process is not writing, such as `undoneBy`. */ export function patchRun(runsDir: string, id: string, patch: Partial): void { const record = readRun(runsDir, id); diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index 46a20d0e8f..ed389cd65d 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -4,7 +4,15 @@ import os from "node:os"; import path from "node:path"; import { EXIT_CODE, type CliError } from "../../lib/errors.ts"; import { useCaptureLog } from "../../test/lib/stubs.ts"; -import { latestUserLines, listRuns, readRun, startRun, type RunRecord } from "./lib/run-store.ts"; +import { + continueRun, + latestUserLines, + listRuns, + lockFile, + readRun, + startRun, + type RunRecord, +} from "./lib/run-store.ts"; import { keyInstanceId } from "./lib/target.ts"; import { undo } from "./undo.ts"; @@ -172,6 +180,47 @@ describe("refusals, all exit 2 and delete nothing", () => { expect(captured.err).toContain("Will delete 2 users"); expect(deletes()).toHaveLength(0); }); + + // Another process continues the import while the undo previews it. + describe("a re-import of the run during the preview", () => { + const duringPreview = (act: () => void) => { + const stubbed = globalThis.fetch; + let acted = false; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + if (!acted && new URL(input.toString()).searchParams.has("user_id")) { + acted = true; + act(); + } + return stubbed(input, init); + }) as typeof fetch; + }; + + test("still running: refused by the import run's lock", async () => { + const record = importRun(); + duringPreview(() => fs.writeFileSync(lockFile(runsDir, record.id), String(process.ppid))); + + await expect(undo(record.id, withDir({ yes: true }))).rejects.toThrow( + /in use by another process/, + ); + expect(deletes()).toHaveLength(0); + expect(readRun(runsDir, record.id)?.status).not.toBe("undone"); + }); + + test("finished: refused because the preview is out of date", async () => { + const record = importRun(); + duringPreview(() => { + const run = continueRun(runsDir, record); + run.append({ sourceId: "c", status: "created", clerkId: "user_c" }); + run.finish(); + }); + + await expect(undo(record.id, withDir({ yes: true }))).rejects.toThrow( + /preview is out of date/, + ); + expect(deletes()).toHaveLength(0); + expect(fs.existsSync(lockFile(runsDir, record.id))).toBe(false); + }); + }); }); describe("--dry-run", () => { diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index c6660a0333..a70879793d 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -32,8 +32,10 @@ import { listRuns, liveLockPid, lockFile, + lockRun, patchRun, readRun, + readUserLines, resolveRunsDir, runState, startRun, @@ -323,6 +325,7 @@ function jsonResult( export async function undo(runId: string, options: UndoOptions = {}): Promise { const runsDir = await resolveRunsDir(options.runsDir); const record = readImportRun(runsDir, runId); + const importLines = readUserLines(runsDir, record.id).length; const { secretKey, target } = await resolveClerkTarget(options); if (!options.json) printTarget(target); @@ -399,26 +402,46 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 - ? await withProgress({ total: present.length, verb: "deleted" }, async (progress) => - deleteUsers({ users: present, secretKey, limits, run, progress }), - ) - : { deleted: 0, failed: 0, errorBreakdown: new Map() }; + // Held until the import is marked undone, so a re-import cannot continue + // the run while its users are deleted and overwrite the `undone` mark. + const releaseImport = lockRun(runsDir, record.id); + let run: Run; + let summary: UndoSummary; + let undoRecord: RunRecord; + try { + // A re-import that continued the run during the preview created users + // the preview never listed. + if (readUserLines(runsDir, record.id).length !== importLines) { + throwUsageError( + `Run ${record.id} changed while this undo was waiting, so the preview is out of date. ` + + `Nothing was deleted. Run \`clerk migrate undo ${record.id}\` again.`, + ); + } + + run = openUndo + ? continueRun(runsDir, openUndo) + : startRun(runsDir, { kind: "undo", target, undoes: record.id, source: record.source }); - const undoRecord = run.finish(); - if (undoRecord.status === "complete") { - patchRun(runsDir, record.id, { status: "undone", undoneBy: undoRecord.id }); + // Users the import created that are no longer there: the undo's goal for + // them is already met, so they count as deleted without another request. + for (const user of gone) { + run.append({ ...user, status: "deleted", reason: "not found in the instance" }); + } + + summary = + present.length > 0 + ? await withProgress({ total: present.length, verb: "deleted" }, async (progress) => + deleteUsers({ users: present, secretKey, limits, run, progress }), + ) + : { deleted: 0, failed: 0, errorBreakdown: new Map() }; + + undoRecord = run.finish(); + if (undoRecord.status === "complete") { + patchRun(runsDir, record.id, { status: "undone", undoneBy: undoRecord.id }); + } + } finally { + releaseImport(); } if (options.json) { From a7ffea0069d2ddc22d01544ff38e35e3164d77a9 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 19:40:04 -0400 Subject: [PATCH 05/63] fix(migrate): refuse an undo when another undo finished during its preview Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/undo.test.ts | 17 ++++++++++++++++- packages/cli-core/src/commands/migrate/undo.ts | 2 ++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index ed389cd65d..9f2047371d 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -9,6 +9,7 @@ import { latestUserLines, listRuns, lockFile, + patchRun, readRun, startRun, type RunRecord, @@ -182,7 +183,7 @@ describe("refusals, all exit 2 and delete nothing", () => { }); // Another process continues the import while the undo previews it. - describe("a re-import of the run during the preview", () => { + describe("the run changing during the preview", () => { const duringPreview = (act: () => void) => { const stubbed = globalThis.fetch; let acted = false; @@ -220,6 +221,20 @@ describe("refusals, all exit 2 and delete nothing", () => { expect(deletes()).toHaveLength(0); expect(fs.existsSync(lockFile(runsDir, record.id))).toBe(false); }); + + test("undone by another undo: refused, and its undo is kept", async () => { + const record = importRun(); + duringPreview(() => + patchRun(runsDir, record.id, { status: "undone", undoneBy: "20260901-000000-beef" }), + ); + + await expect(undo(record.id, withDir({ yes: true }))).rejects.toThrow( + /already undone by run 20260901-000000-beef/, + ); + expect(deletes()).toHaveLength(0); + expect(readRun(runsDir, record.id)?.undoneBy).toBe("20260901-000000-beef"); + expect(listRuns(runsDir).filter((run) => run.kind === "undo")).toHaveLength(0); + }); }); }); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index a70879793d..2261868c65 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -410,6 +410,8 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise Date: Tue, 6 Oct 2026 19:40:05 -0400 Subject: [PATCH 06/63] docs(migrate): say undo skips consent when no users remain 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 32841f0e06..bd2fabc0cc 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -493,9 +493,10 @@ 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. +`last_sign_in_at`). When users remain to delete or record as gone, nothing is +deleted without consent: a yes at the prompt, or `--yes`. Without either — an +agent, a non-TTY run, or `--json` — it prints the preview and exits 2 with the +command to run. When none remain, undo completes without prompting. It refuses with exit 2, and deletes nothing, when: From 3e949a9170a039c760361c127fd33f5bc00e472f Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 15:53:07 -0400 Subject: [PATCH 07/63] fix(migrate): check a continued run again once its lock is held A continue plans from the run record and user lines it read before its consent prompt. An undo that finished while the prompt waited was overwritten as running, its deleted users skipped as imported; a second continue's creates could hide the first's Clerk IDs from undo. Once the lock is held the record and line count are read again, and a run that changed is refused with nothing written. Co-Authored-By: Claude Opus 5.5 --- .../commands/migrate/lib/run-store.test.ts | 36 +++++++++++++++++++ .../src/commands/migrate/lib/run-store.ts | 26 +++++++++++++- packages/cli-core/src/commands/migrate/run.ts | 8 +++-- 3 files changed, 67 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 1af0b9f533..63021ebf70 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 @@ -18,7 +18,9 @@ import { continueRun, latestUserLines, listRuns, + lockFile, newRunId, + patchRun, readRun, resolveRunsDir, runState, @@ -276,6 +278,40 @@ describe("locks and interruptions", () => { }); }); +// A continue plans from what it read before its consent prompt; an undo or +// another continue can write to the run while that prompt waits. +describe("continueRun against a plan", () => { + const stopped = () => { + const run = startRun(runsDir, init); + run.append({ sourceId: "a", status: "created" }); + return run.finish(); + }; + + test("continues a run nothing touched", () => { + const record = stopped(); + expect(() => continueRun(runsDir, record, 1).finish()).not.toThrow(); + }); + + test("refuses a run an undo marked while it waited, and lets go of the lock", () => { + const record = stopped(); + patchRun(runsDir, record.id, { status: "undone", undoneBy: "20260101-000000-abcd" }); + + expect(() => continueRun(runsDir, record, 1)).toThrow(/changed while this import was waiting/); + expect(fs.existsSync(lockFile(runsDir, record.id))).toBe(false); + expect(readRun(runsDir, record.id)?.status).toBe("undone"); + }); + + test("refuses a run another continue wrote to while it waited", () => { + const record = stopped(); + fs.appendFileSync( + path.join(runsDir, record.id, "users.ndjson"), + `${JSON.stringify({ sourceId: "b", status: "created", clerkId: "user_b" })}\n`, + ); + + expect(() => continueRun(runsDir, record, 1)).toThrow(/changed while this import was waiting/); + }); +}); + describe("listRuns", () => { test("lists newest first and ignores folders that are not runs", () => { const first = startRun(runsDir, init); 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 98efa50ac9..2d4bdd3b30 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -454,8 +454,32 @@ export function startRun(runsDir: string, init: StartRunInit): Run { * * @throws UsageError when another live process holds its lock. */ -export function continueRun(runsDir: string, record: RunRecord): Run { +export function continueRun( + runsDir: string, + record: RunRecord, + /** + * How many user lines the caller planned against. The record and the lines + * were read before the lock was taken, while a prompt may have waited: an + * undo, or another continue, can change either in the meantime. + */ + plannedLines?: number, +): Run { acquireLock(runsDir, record.id); + if (plannedLines !== undefined) { + const fresh = readRun(runsDir, record.id); + const changed = + !fresh || + fresh.status !== record.status || + fresh.undoneBy !== record.undoneBy || + readUserLines(runsDir, record.id).length !== plannedLines; + if (changed) { + fs.rmSync(lockFile(runsDir, record.id), { force: true }); + throwUsageError( + `Run ${record.id} changed while this import was waiting, so its plan is out of date. ` + + "Nothing was imported. Run the command again.", + ); + } + } // A crash mid-write leaves a torn last line; end it so the next append // starts a line of its own instead of fusing with it. const usersFile = path.join(runDir(runsDir, record.id), USERS_FILE); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 9792760955..217e161663 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -45,6 +45,7 @@ import { listRuns, liveLockPid, lockFile, + readUserLines, resolveRunsDir, runDir, runState, @@ -577,6 +578,9 @@ export async function run(rawOptions: MigrateRunOptions): Promise { // checked or sent again. Those whose extra identifiers never attached // get just the attaches. const continued = resume.kind === "continue" ? resume.record : undefined; + // What the plan below is built from; checked again once the run's lock + // is held, since a prompt can wait while an undo or a continue writes. + const plannedLines = continued ? readUserLines(runsDir, continued.id).length : undefined; const done = new Map(); const attachOnly: UserLine[] = []; const inFlight: string[] = []; @@ -754,7 +758,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { attachOnly.length === 0 ) { // Settled, so a continued run is finished rather than left interrupted. - if (continued) continueRun(runsDir, continued).finish(); + if (continued) continueRun(runsDir, continued, plannedLines).finish(); if (options.json) preview({ nothingToImport: true }); else log.warn("No users left to import."); return; @@ -792,7 +796,7 @@ 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) + ? continueRun(runsDir, continued, plannedLines) : startRun(runsDir, { kind: "import", target, From 926b9a54b9543fbbcecf22b1bb66a03733799473 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 15:54:42 -0400 Subject: [PATCH 08/63] fix(migrate): count an adopted user against dev headroom once An adopted user is in the instance's count and among the candidates too, so it took headroom twice, and a continue that fit was refused on a nearly full development instance. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.test.ts | 14 ++++++++++++++ .../cli-core/src/commands/migrate/lib/checks.ts | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 4601aecf91..fce805ec47 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -458,6 +458,20 @@ describe("rejects", () => { expect(checks.quota).toEqual({ existing: 98, limit: 100, headroom: 2, over: 1 }); }); + // A continued run's adopted users exist already and are never created again. + test("an adopted user does not take headroom twice", async () => { + const checks = await checkImport( + input({ + instanceType: "dev", + existingUsers: 98, + adoptedClerkIds: new Set(["user_c"]), + users: [user("a"), user("b"), user("c")], + }), + ); + expect(checks.importable.map((entry) => entry.userId)).toEqual(["a", "b", "c"]); + expect(checks.quota).toEqual({ existing: 98, limit: 100, headroom: 3, over: 0 }); + }); + test("warns that an unreadable user count was checked as empty", async () => { const checks = await checkImport( input({ instanceType: "dev", existingUsers: null, users: [user("a")] }), diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 99d95e9d2e..c8989fd369 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -1073,7 +1073,10 @@ export async function checkImport(input: CheckInput): Promise { let quota: Quota | undefined; if (input.instanceType === "dev") { const limit = resolveDevUserLimit(); - const headroom = Math.max(0, limit - (input.existingUsers ?? 0)); + // An adopted user is in the instance's count already and among the + // candidates too, though it is never created: counted once, not twice. + const occupied = Math.max(0, (input.existingUsers ?? 0) - (input.adoptedClerkIds?.size ?? 0)); + const headroom = Math.max(0, limit - occupied); const over = Math.max(0, candidates.length - headroom); quota = { existing: input.existingUsers ?? null, limit, headroom, over }; if (over > 0) { From 6263cb8c1f023ca25a647a197d47aa6f266bd688 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 15:56:43 -0400 Subject: [PATCH 09/63] fix(migrate): refuse to start over when Clerk can't name a resumed run's instance With GET /v1/instance down, the target is the key's stand-in ID, which matches no run recorded under the real one, so a re-run started a new run: the checks rejected every user the earlier run created, and that run was left unfinished. It now exits 2 with a retry message, and --new-run stays the way out. The README notes both new refusals. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 7 +++++- .../cli-core/src/commands/migrate/run.test.ts | 22 +++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 22 ++++++++++++++++++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index bd2fabc0cc..e6cbd0ae16 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -271,7 +271,12 @@ decides what happens: | `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. +`--new-run` skips the lookup. A run another live process holds exits 2. When +Clerk cannot name the instance (`GET /v1/instance` failed, often from rate +limiting right after a large import) and a run of this file exists under a real +instance ID, it exits 2 rather than starting over: try again, or pass +`--new-run`. A continue whose run changed while it waited at the prompt, from +an undo or another continue, exits 2 too, with nothing written. A continued run also finishes what the last one left open: diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index ac53eeda2e..56cf8346ee 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -83,6 +83,8 @@ type Stub = { count?: number; /** Source IDs whose `POST /v1/users` fails with a 422. */ failing?: Set; + /** `GET /v1/instance` fails, so the target falls back to the key's stand-in ID. */ + instanceDown?: boolean; }; describe("run", () => { @@ -108,6 +110,9 @@ describe("run", () => { 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" && stub.instanceDown) { + return new Response("unavailable", { status: 503 }); + } if (url.pathname === "/v1/instance") { return Response.json({ object: "instance", id: "ins_1", environment_type: "development" }); } @@ -983,6 +988,23 @@ describe("run", () => { }); // An edited file is a different job. + // Rate limiting right after a large import is when this lookup fails, and + // that is when someone re-runs after an interruption. + test("refuses to start over when Clerk can't name the instance a run of this file was in", async () => { + stubClerk({ failing: new Set(["u2"]) }); + await run(baseOptions); + + requests = []; + process.exitCode = 0; + stubClerk({ instanceDown: true }); + await expect(run(baseOptions)).rejects.toThrow(/did not confirm which instance/); + expect(created()).toEqual([]); + expect(listRuns(runsDir())).toHaveLength(1); + + await run({ ...baseOptions, newRun: true, allowPartial: true }); + expect(listRuns(runsDir())).toHaveLength(2); + }); + test("a changed file is a new run", async () => { await run(baseOptions); fs.writeFileSync( diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 217e161663..c65fc2a9c3 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -328,7 +328,27 @@ export function findResume( (record.target.instanceId === match.instanceId || record.target.instanceId === match.keyInstanceId), ); - if (!latest) return { kind: "new" }; + if (!latest) { + // Clerk did not name the instance (GET /v1/instance failed), so a run of + // this file recorded under a real ID can be neither matched nor ruled out. + // Starting over would reject every user it created and leave it unfinished. + if (match.instanceId.startsWith("key_")) { + const unconfirmed = listRuns(runsDir).find( + (record) => + record.kind === "import" && + record.file?.sha256 === match.sha256 && + record.source === match.source && + record.target.instanceId?.startsWith("ins_") === true, + ); + if (unconfirmed) { + throwUsageError( + `Clerk did not confirm which instance this key addresses, so this import cannot tell whether run ${unconfirmed.id} is for it. ` + + "Nothing was imported. Try again, or pass --new-run to start a new run.", + ); + } + } + return { kind: "new" }; + } const state = runState(runsDir, latest); if (state === "running") { From 7b25fd50c0cda9313b20a602486746b79eb74b8c Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 21:49:11 -0400 Subject: [PATCH 10/63] test(migrate): check undo's users are gone by Clerk's not-found answer The e2e passed whenever the GET after `undo` failed at all, so a wrong path, an auth error or a network error would pass it too. It now asserts each user has a Clerk ID and that Clerk answers `resource_not_found`. Co-Authored-By: Claude Opus 5.5 --- test/e2e/migrate.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/e2e/migrate.test.ts b/test/e2e/migrate.test.ts index 00768eac56..c536afeeb8 100644 --- a/test/e2e/migrate.test.ts +++ b/test/e2e/migrate.test.ts @@ -180,8 +180,14 @@ test("a Supabase export dry-runs, imports, and its passwords verify", async () = expect(undo.exitCode).toBe(0); for (const { record } of users) { const line = lines.find((candidate) => candidate.sourceId === record.id); + expect(typeof line?.clerkId).toBe("string"); + // Clerk's own answer, not just any failure: a wrong path or an auth error + // would exit non-zero too. `clerk api` prints the error body to stdout. const gone = await cli(["api", `/users/${line?.clerkId as string}`]); expect(gone.exitCode).not.toBe(0); + expect(JSON.parse(gone.stdout.toString())).toMatchObject({ + errors: [{ code: "resource_not_found" }], + }); } }, 60_000); From 92c88915ff779ad0e0e57d701892255270c1b74a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 23:46:25 -0400 Subject: [PATCH 11/63] fix(migrate): adopt an in-flight user only if Clerk created it during the run A continue adopted, and undo deleted, any instance user whose external_id matched an in-flight create, unless another import run recorded it. An app or another tool can set the same external_id, so a user this run never created could be recorded as created and then deleted by undo. A match now counts only when Clerk created it between the run's start and its last record write, give or take five minutes for clock skew. Anything else stays unresolved: a continue's checks find it in the instance, and undo leaves it alone. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/user-lookup.ts | 50 +++++++++++++++++-- .../cli-core/src/commands/migrate/run.test.ts | 30 ++++++++++- .../src/commands/migrate/undo.test.ts | 28 ++++++++++- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/user-lookup.ts b/packages/cli-core/src/commands/migrate/lib/user-lookup.ts index f934d25cda..5cac1f10a3 100644 --- a/packages/cli-core/src/commands/migrate/lib/user-lookup.ts +++ b/packages/cli-core/src/commands/migrate/lib/user-lookup.ts @@ -9,10 +9,12 @@ * as the writes do. */ +import fs from "node:fs"; +import path from "node:path"; import { bapiRequest } from "../../../lib/bapi.ts"; import type { SpinnerControls } from "../../../lib/spinner.ts"; import { retryOn429 } from "./retry.ts"; -import { clerkIdsCreatedByOtherRuns } from "./run-store.ts"; +import { clerkIdsCreatedByOtherRuns, readRun, runDir } from "./run-store.ts"; import type { ApiScheduler } from "./scheduler.ts"; /** BAPI accepts at most 100 values per filter on `GET /v1/users`. */ @@ -23,6 +25,8 @@ export type LookedUpUser = { id: string; external_id?: string | null; username?: string | null; + /** Milliseconds since the epoch, Clerk's clock. */ + created_at?: number; last_sign_in_at?: number | null; email_addresses?: { email_address?: string }[]; phone_numbers?: { phone_number?: string }[]; @@ -103,12 +107,42 @@ export async function lookupUsers(options: { return pages.flat().filter((user) => typeof user.id === "string"); } +/** + * How far a Clerk `created_at` may fall outside a run's own times and still + * be the run's: Clerk's clock and this machine's differ. + */ +const CLOCK_SKEW_MS = 5 * 60_000; + +/** + * When run `runId` could have created users: from its start to its last + * record write, widened by {@link CLOCK_SKEW_MS}. `undefined` when either + * time cannot be read. + */ +function createWindow(runsDir: string, runId: string): { from: number; to: number } | undefined { + const from = Date.parse(readRun(runsDir, runId)?.startedAt ?? ""); + let to: number; + try { + to = fs.statSync(path.join(runDir(runsDir, runId), "users.ndjson")).mtimeMs; + } catch { + return undefined; + } + if (!Number.isFinite(from)) return undefined; + return { from: from - CLOCK_SKEW_MS, to: to + CLOCK_SKEW_MS }; +} + /** * The users behind creates that were in flight when run `runId` stopped. * * Found by `external_id`, which the import's checks refused to reuse, but a * later run of the same source IDs can still have created one after this run - * stopped. So any Clerk ID another import run records as created is left out. + * stopped. So any Clerk ID another import run records as created is left out, + * and so is a user Clerk created outside this run's time: an app or another + * tool can set the same `external_id`. One left out stays unresolved: a + * continue's checks find it in the instance, and `undo` leaves it alone. + * + * ponytail: a time window, not proof. A user someone else created with the + * same source ID while the run was going still reads as the run's; a marker + * on each create would prove it, at the cost of writing one into every user. */ export async function findInFlight(options: { runsDir: string; @@ -125,8 +159,18 @@ export async function findInFlight(options: { schedule: options.schedule, }); const otherRuns = clerkIdsCreatedByOtherRuns(options.runsDir, options.runId); + const window = createWindow(options.runsDir, options.runId); + if (!window) return []; const wanted = new Set(options.sourceIds); + const inWindow = (createdAt: number | undefined) => + typeof createdAt === "number" && createdAt >= window.from && createdAt <= window.to; return found - .filter((user) => user.external_id && wanted.has(user.external_id) && !otherRuns.has(user.id)) + .filter( + (user) => + user.external_id && + wanted.has(user.external_id) && + !otherRuns.has(user.id) && + inWindow(user.created_at), + ) .map((user) => ({ sourceId: user.external_id as string, clerkId: user.id })); } diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 56cf8346ee..422290b124 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -77,6 +77,7 @@ type Stub = { id: string; external_id?: string; username?: string; + created_at?: number; email_addresses?: { email_address: string }[]; }[]; /** `GET /v1/users/count`. */ @@ -880,7 +881,7 @@ describe("run", () => { requests = []; process.exitCode = 0; - stubClerk({ existing: [{ id: "user_found", external_id: "u2" }] }); + stubClerk({ existing: [{ id: "user_found", external_id: "u2", created_at: Date.now() }] }); await run(baseOptions); expect(created()).toEqual([]); @@ -892,6 +893,33 @@ describe("run", () => { expect(readRun(runsDir(), first!.id)?.status).toBe("complete"); }); + // Same source ID, but Clerk created it before the run began: an app or + // another tool's user, not this run's, so it is never adopted. + test("an interrupted run does not adopt a match Clerk created outside the run", 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_theirs", external_id: "u2", created_at: Date.parse("2020-01-01") }], + }); + await run({ ...baseOptions, allowPartial: true }); + + expect(created()).toEqual([]); + expect(captured.err).not.toContain("whose create was cut off"); + expect(latestUserLines(runsDir(), first!.id).get("u2")).toMatchObject({ + status: "skipped", + reason: "already in the instance, with this source ID", + }); + }); + test("an interrupted run creates a user whose in-flight create never landed", async () => { stubClerk({ failing: new Set(["u2"]) }); await run(baseOptions); diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index 9f2047371d..bfd25a6cab 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -28,6 +28,8 @@ let instanceUsers: Map; let failing: Set; /** external_id → Clerk ID, for users whose create was in flight. */ let inFlight: Map; +/** When Clerk says it created each in-flight user: by default, during the run. */ +let inFlightCreatedAt: number; const IMPORT_STARTED = "2026-09-01T00:00:00.000Z"; const AFTER_IMPORT = Date.parse("2026-09-02T00:00:00.000Z"); @@ -45,6 +47,7 @@ beforeEach(() => { requests = []; failing = new Set(); inFlight = new Map(); + inFlightCreatedAt = Date.now(); instanceUsers = new Map([ ["user_a", null], ["user_b", AFTER_IMPORT], @@ -64,7 +67,11 @@ beforeEach(() => { .getAll("external_id") .map((externalId) => externalId.replace(/^\+/, "")) .filter((externalId) => inFlight.has(externalId)) - .map((externalId) => ({ id: inFlight.get(externalId), external_id: externalId })), + .map((externalId) => ({ + id: inFlight.get(externalId), + external_id: externalId, + created_at: inFlightCreatedAt, + })), ); } if (method === "GET" && url.pathname === "/v1/users") { @@ -290,6 +297,25 @@ describe("deleting", () => { expect(deletes()).toHaveLength(2); }); + // Same source ID, but Clerk created it before the run began: not the run's. + test("leaves an in-flight match that Clerk created outside the run's time", async () => { + const run = startRun(runsDir, { + kind: "import", + target: { instanceId: "ins_1", env: "development" }, + source: "clerk", + }); + run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + run.append({ sourceId: "d", status: "creating" }); + const record = run.finish(); + inFlight.set("d", "user_d"); + inFlightCreatedAt = Date.parse(record.startedAt) - 24 * 60 * 60_000; + instanceUsers.set("user_d", null); + + await undo(record.id, withDir({ yes: true })); + + expect(deletes().map((request) => request.url.split("/").pop())).toEqual(["user_a"]); + }); + // Run A stopped with d's create in flight; a later run B then created d. // The user Clerk holds is B's, so undoing A leaves it alone. test("leaves an in-flight user that another import run records as created", async () => { From 84d51034dd834684c0b3c0f81ee87bb74d650f64 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 00:55:12 -0400 Subject: [PATCH 12/63] fix(migrate): pause every undo delete on a 429, not just the one that hit it Undo's deletes backed off one at a time on a 429, so under a sustained limit each queued delete hit it in turn. A delete's backoff now pauses the scheduler for the Retry-After wait, as the import's creates do. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/undo.test.ts | 29 +++++++++++++++++++ .../cli-core/src/commands/migrate/undo.ts | 8 ++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index bfd25a6cab..daf7676d14 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -342,6 +342,35 @@ describe("deleting", () => { expect(deletes().map((request) => request.url.split("/").pop())).toEqual(["user_a"]); }); + // The instance is over its limit for every delete, not just the one that hit it. + test("holds every other delete through a 429's wait", async () => { + const record = importRun(); + const stub = globalThis.fetch; + const started = performance.now(); + const deletedAt: number[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + if (init?.method !== "DELETE") return stub(input, init); + deletedAt.push(performance.now() - started); + if (deletedAt.length === 1) { + requests.push({ method: "DELETE", url: input.toString() }); + return new Response(JSON.stringify({ errors: [{ code: "x", message: "slow down" }] }), { + status: 429, + headers: { "retry-after": "1" }, + }); + } + return stub(input, init); + }) as typeof fetch; + process.env.CLERK_MIGRATE_CONCURRENCY_LIMIT = "1"; + try { + await undo(record.id, withDir({ yes: true })); + } finally { + delete process.env.CLERK_MIGRATE_CONCURRENCY_LIMIT; + } + + expect(deletedAt).toHaveLength(3); + for (const at of deletedAt.slice(1)) expect(at - deletedAt[0]!).toBeGreaterThanOrEqual(900); + }); + test("deletes what the import created, records an undo run, and marks the import undone", async () => { const record = importRun(); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 2261868c65..004bd31d7b 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -236,7 +236,13 @@ async function deleteUsers(options: { schedule(async () => bapiRequest({ method: "DELETE", path: `/v1/users/${user.clerkId}`, secretKey }), ), - { onRetry: ({ message }) => retries.push(message) }, + { + // A 429 pauses every delete still queued, as on import. + onRetry: ({ message, delaySeconds }) => { + retries.push(message); + schedule.pause(delaySeconds * 1000); + }, + }, ); deleted++; run.append({ From 19a6a2c59d0a063ad30ddfc4deb21df32e99f533 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 14:16:47 -0400 Subject: [PATCH 13/63] fix(migrate): check for an unfinished undo again once a continue holds the lock `findResume` refuses an import with an unfinished undo, but it runs before `continueRun` takes the lock. An undo that started after it and stopped part-way leaves the import's status and lines unchanged, so the continue went ahead and marked the run running again. `continueRun` now checks under the lock, before it writes the record, and refuses. Co-Authored-By: Claude Opus 5.5 --- .../commands/migrate/lib/run-store.test.ts | 19 +++++++++++++++++++ .../src/commands/migrate/lib/run-store.ts | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) 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 63021ebf70..974b287400 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 @@ -312,6 +312,25 @@ describe("continueRun against a plan", () => { }); }); +describe("continueRun and an unfinished undo", () => { + // The caller checked before taking the lock; an undo can start, and stop + // part-way, after that. + test("refuses an import with an undo that did not finish, and leaves its record", () => { + const run = startRun(runsDir, init); + run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + const record = run.finish(); + const undo = startRun(runsDir, { ...init, kind: "undo", undoes: record.id }); + undo.release(); + + expect(() => continueRun(runsDir, record)).toThrow(/has an undo that did not finish/); + expect(readRun(runsDir, record.id)).toMatchObject({ + status: record.status, + finishedAt: record.finishedAt, + }); + expect(fs.existsSync(lockFile(runsDir, record.id))).toBe(false); + }); +}); + describe("listRuns", () => { test("lists newest first and ignores folders that are not runs", () => { const first = startRun(runsDir, init); 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 2d4bdd3b30..e44fb35206 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -480,6 +480,25 @@ export function continueRun( ); } } + // Checked again under the lock, before the record says running: an undo + // can start, and stop part-way, after the caller's own check, leaving the + // import's status and lines as they were. + const undoRun = + record.kind === "import" + ? listRuns(runsDir).find( + (candidate) => + candidate.kind === "undo" && + candidate.undoes === record.id && + runState(runsDir, candidate) !== "complete", + ) + : undefined; + if (undoRun) { + fs.rmSync(lockFile(runsDir, record.id), { force: true }); + throwUsageError( + `Run ${record.id} has an undo that did not finish (run ${undoRun.id}). ` + + `Finish it with \`clerk migrate undo ${record.id}\`, or pass --new-run to import into a new run.`, + ); + } // A crash mid-write leaves a torn last line; end it so the next append // starts a line of its own instead of fusing with it. const usersFile = path.join(runDir(runsDir, record.id), USERS_FILE); From f20d670b040951dfcec7df5221ed53e204b5b2d0 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 14:17:55 -0400 Subject: [PATCH 14/63] fix(migrate): still check other users against an adopted user's identifiers The instance duplicate check skipped an adopted user entirely, so its identifiers were checked against nobody: another user whose primary email matched the adopted user's secondary one passed the checks and then failed at create. Only the adopted user's own record is exempt now; every other user is still checked against what it holds. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.test.ts | 21 +++++++++++++++++++ .../src/commands/migrate/lib/checks.ts | 10 +++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index fce805ec47..9d2b242a16 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -307,6 +307,27 @@ describe("rejects", () => { // A repeated record (an export that paged past a sign-up) must not take the // kept copy down with it. + // Adopted means created by this run, but only for its own record: another + // user that shares its email would still clash at create. + test("an adopted user's identifiers still clash with other users", async () => { + existing = [ + { + id: "user_mine", + external_id: "mine", + email_addresses: [{ email_address: "mine@x.dev" }, { email_address: "shared@x.dev" }], + }, + ]; + expect( + await reasonsOf({ + users: [ + user("mine", { emailAddresses: ["shared@x.dev"] }), + user("other", { email: "shared@x.dev" }), + ], + adoptedClerkIds: new Set(["user_mine"]), + }), + ).toEqual({ other: "email is already used by a user in the instance" }); + }); + test("a repeated source ID rejects only the later copy", async () => { const checks = await checkImport(input({ users: [user("a"), user("a")] })); expect(checks.importable.map((u) => u.userId)).toEqual(["a"]); diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index c8989fd369..7a4ee28f7c 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -637,12 +637,14 @@ async function findInstanceDuplicates( ).flat(); const reasons = new Map(); - const claim = (sourceId: string | undefined, reason: string) => { - if (sourceId && !reasons.has(sourceId)) reasons.set(sourceId, reason); - }; for (const existing of found) { - if (input.adoptedClerkIds?.has(existing.id)) continue; + // An adopted user is its own record's create, so it clashes with nothing + // in that record; its identifiers still clash with every other user's. + const own = input.adoptedClerkIds?.has(existing.id) ? existing.external_id : undefined; + const claim = (sourceId: string | undefined, reason: string) => { + if (sourceId && sourceId !== own && !reasons.has(sourceId)) reasons.set(sourceId, reason); + }; if (existing.external_id) { claim(byExternalId.get(existing.external_id), "already in the instance, with this source ID"); } From 1762e5e8ff2e537fd1aedc3b92953ded2d32f8a6 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 14:20:55 -0400 Subject: [PATCH 15/63] fix(migrate): adopt an in-flight user only if it carries the run's marker A continue adopted, and undo deleted, a user found by `external_id` when Clerk had created it within the run's time window. That narrowed the gap but proved nothing: a user an app or another tool made with the same source ID during the run still read as the run's own. Every create now carries the run's ID in its private metadata, as `clerkMigrateRun` (merged with the source's own), and `findInFlight` counts only a user that carries it. The time window and the other-runs exclusion it replaces are gone. A user without the marker stays unresolved, as before; so does a create from a run that predates it. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 22 +++++-- .../src/commands/migrate/import-users.test.ts | 18 ++++++ .../src/commands/migrate/import-users.ts | 16 ++++++ .../src/commands/migrate/lib/run-store.ts | 17 ------ .../src/commands/migrate/lib/user-lookup.ts | 57 ++++--------------- .../cli-core/src/commands/migrate/run.test.ts | 16 ++++-- packages/cli-core/src/commands/migrate/run.ts | 2 +- .../src/commands/migrate/undo.test.ts | 15 ++--- .../cli-core/src/commands/migrate/undo.ts | 1 - 9 files changed, 81 insertions(+), 83 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index e6cbd0ae16..44d044cd4b 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -280,8 +280,18 @@ an undo or another continue, exits 2 too, with nothing written. 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 still `creating` is looked up by `external_id`. One Clerk holds + that carries this run's marker is adopted as `created`, and not created + again; one it doesn't hold is created. A match without the marker is + someone else's user: the checks reject that record as already in the + instance. + +Every create sends the run's ID in the user's private metadata, as +`clerkMigrateRun`, merged with any private metadata the source carries. It +is how a cut-off create is told apart from a user an app or another tool +made with the same `external_id`, and it stays on the user. A run recorded +before the marker existed adopts nothing. + - A user whose `created` line has `pending` identifiers gets just those attaches. @@ -474,10 +484,10 @@ 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. +hold the user without its ID on record. Those are looked up by `external_id`, +and only a user carrying the import run's `clerkMigrateRun` marker is deleted: +an app or another tool can make a user with the same source ID, and one +without the marker is left alone. ```sh clerk migrate undo 20260929-141502-a1b2 --dry-run # preview, delete nothing diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index 8115304acd..8738ec0a66 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -210,6 +210,24 @@ describe("importUsers", () => { headers, }); + // How a continue or an undo tells its own cut-off create from a user + // someone else made with the same external_id. + test("marks each create with its run, keeping the source's private metadata", async () => { + stub(() => ok("user_created")); + + await importUsers({ + users: [user({ userId: "u1", privateMetadata: { plan: "pro" } })], + secretKey: "sk_test_x", + limits: LIMITS, + record, + runId: "20260101-000000-abcd", + }); + + expect(requests.find((r) => r.url.endsWith("/v1/users"))?.body).toMatchObject({ + private_metadata: { plan: "pro", clerkMigrateRun: "20260101-000000-abcd" }, + }); + }); + test("creates each user and reports them as successful", async () => { stub(() => ok("user_created")); diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index e128b840eb..c17b14db8c 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -23,6 +23,7 @@ import type { ResolvedLimits } from "./lib/instance.ts"; import type { ProgressUpdate } from "./lib/progress.ts"; import { RateLimitExceededError, retryOn429 } from "./lib/retry.ts"; import type { PendingIdentifier, UserLine } from "./lib/run-store.ts"; +import { RUN_MARKER_KEY } from "./lib/user-lookup.ts"; import { createApiScheduler, type ApiScheduler } from "./lib/scheduler.ts"; import type { ImportSummary, User } from "./types.ts"; @@ -174,6 +175,8 @@ export function buildCreateUserBody( type CreateContext = { secretKey: string; schedule: ApiScheduler; + /** The run sending these creates; each carries it as its marker. */ + runId?: string; /** Aborted by the first `user_quota_exceeded`: every later create would be refused too. */ quota: AbortController; /** Aborted by a run-store write that failed. Nothing goes out after it. */ @@ -320,6 +323,15 @@ async function createUser( ); const body = buildCreateUserBody(user, identifiers, skipPasswordRequirement); + // The run's marker, with whatever private metadata the source brought: + // without it a create cut off mid-flight could never be told from a user + // someone else made with the same external_id. + if (ctx.runId) { + body.private_metadata = { + ...(body.private_metadata as Record | undefined), + [RUN_MARKER_KEY]: ctx.runId, + }; + } const notes: string[] = []; let phoneRefusal: string | undefined; let response; @@ -361,6 +373,8 @@ export type ImportUsersOptions = { * latest `created` line, with `pending`. Only the attaches are sent. */ attachOnly?: UserLine[]; + /** The run these creates belong to, sent on each as its marker. */ + runId?: string; /** * Source ID → Clerk ID for users whose create a stopped run sent with no * answer, and which a continued run then found in the instance. They are @@ -389,6 +403,7 @@ export async function importUsers(options: ImportUsersOptions): Promise(), skipPasswordRequirement = true, validationFailed = 0, @@ -426,6 +441,7 @@ export async function importUsers(options: ImportUsersOptions): Promise report?.({ done: processed, ok: successful, failed }); 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 e44fb35206..f15fc7a05d 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -294,23 +294,6 @@ export function runState(runsDir: string, record: RunRecord): RunState { return liveLockPid(runsDir, record.id) === undefined ? "interrupted" : "running"; } -/** - * Clerk IDs that import runs other than `exceptId` record as created. - * - * A user found by `external_id` may belong to another run of the same source - * IDs, so lookups for in-flight creates leave these out. - */ -export function clerkIdsCreatedByOtherRuns(runsDir: string, exceptId: string): Set { - const ids = new Set(); - for (const record of listRuns(runsDir)) { - if (record.kind !== "import" || record.id === exceptId) continue; - for (const line of latestUserLines(runsDir, record.id).values()) { - if (line.status === "created" && line.clerkId) ids.add(line.clerkId); - } - } - return ids; -} - /** Every readable run, newest first. */ export function listRuns(runsDir: string): RunRecord[] { let entries: fs.Dirent[]; diff --git a/packages/cli-core/src/commands/migrate/lib/user-lookup.ts b/packages/cli-core/src/commands/migrate/lib/user-lookup.ts index 5cac1f10a3..5dd86c04b7 100644 --- a/packages/cli-core/src/commands/migrate/lib/user-lookup.ts +++ b/packages/cli-core/src/commands/migrate/lib/user-lookup.ts @@ -9,12 +9,9 @@ * as the writes do. */ -import fs from "node:fs"; -import path from "node:path"; import { bapiRequest } from "../../../lib/bapi.ts"; import type { SpinnerControls } from "../../../lib/spinner.ts"; import { retryOn429 } from "./retry.ts"; -import { clerkIdsCreatedByOtherRuns, readRun, runDir } from "./run-store.ts"; import type { ApiScheduler } from "./scheduler.ts"; /** BAPI accepts at most 100 values per filter on `GET /v1/users`. */ @@ -25,8 +22,7 @@ export type LookedUpUser = { id: string; external_id?: string | null; username?: string | null; - /** Milliseconds since the epoch, Clerk's clock. */ - created_at?: number; + private_metadata?: Record | null; last_sign_in_at?: number | null; email_addresses?: { email_address?: string }[]; phone_numbers?: { phone_number?: string }[]; @@ -108,44 +104,21 @@ export async function lookupUsers(options: { } /** - * How far a Clerk `created_at` may fall outside a run's own times and still - * be the run's: Clerk's clock and this machine's differ. + * The `private_metadata` key every import create carries: the ID of the run + * that sent it. It is how a continue or an undo knows a user found by + * `external_id` is that run's own create, and not one an app or another tool + * made with the same source ID. */ -const CLOCK_SKEW_MS = 5 * 60_000; +export const RUN_MARKER_KEY = "clerkMigrateRun"; /** - * When run `runId` could have created users: from its start to its last - * record write, widened by {@link CLOCK_SKEW_MS}. `undefined` when either - * time cannot be read. - */ -function createWindow(runsDir: string, runId: string): { from: number; to: number } | undefined { - const from = Date.parse(readRun(runsDir, runId)?.startedAt ?? ""); - let to: number; - try { - to = fs.statSync(path.join(runDir(runsDir, runId), "users.ndjson")).mtimeMs; - } catch { - return undefined; - } - if (!Number.isFinite(from)) return undefined; - return { from: from - CLOCK_SKEW_MS, to: to + CLOCK_SKEW_MS }; -} - -/** - * The users behind creates that were in flight when run `runId` stopped. - * - * Found by `external_id`, which the import's checks refused to reuse, but a - * later run of the same source IDs can still have created one after this run - * stopped. So any Clerk ID another import run records as created is left out, - * and so is a user Clerk created outside this run's time: an app or another - * tool can set the same `external_id`. One left out stays unresolved: a - * continue's checks find it in the instance, and `undo` leaves it alone. - * - * ponytail: a time window, not proof. A user someone else created with the - * same source ID while the run was going still reads as the run's; a marker - * on each create would prove it, at the cost of writing one into every user. + * The users behind creates that were in flight when run `runId` stopped: + * found by `external_id`, and counted only when they carry the run's marker. + * One without it stays unresolved: a continue's checks find it in the + * instance, and `undo` leaves it alone. That includes a create from a run + * recorded before the marker existed. */ export async function findInFlight(options: { - runsDir: string; runId: string; sourceIds: string[]; secretKey: string; @@ -158,19 +131,13 @@ export async function findInFlight(options: { secretKey: options.secretKey, schedule: options.schedule, }); - const otherRuns = clerkIdsCreatedByOtherRuns(options.runsDir, options.runId); - const window = createWindow(options.runsDir, options.runId); - if (!window) return []; const wanted = new Set(options.sourceIds); - const inWindow = (createdAt: number | undefined) => - typeof createdAt === "number" && createdAt >= window.from && createdAt <= window.to; return found .filter( (user) => user.external_id && wanted.has(user.external_id) && - !otherRuns.has(user.id) && - inWindow(user.created_at), + user.private_metadata?.[RUN_MARKER_KEY] === options.runId, ) .map((user) => ({ sourceId: user.external_id as string, clerkId: user.id })); } diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 422290b124..828c4b8129 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -77,7 +77,7 @@ type Stub = { id: string; external_id?: string; username?: string; - created_at?: number; + private_metadata?: Record; email_addresses?: { email_address: string }[]; }[]; /** `GET /v1/users/count`. */ @@ -881,7 +881,11 @@ describe("run", () => { requests = []; process.exitCode = 0; - stubClerk({ existing: [{ id: "user_found", external_id: "u2", created_at: Date.now() }] }); + stubClerk({ + existing: [ + { id: "user_found", external_id: "u2", private_metadata: { clerkMigrateRun: first!.id } }, + ], + }); await run(baseOptions); expect(created()).toEqual([]); @@ -893,9 +897,9 @@ describe("run", () => { expect(readRun(runsDir(), first!.id)?.status).toBe("complete"); }); - // Same source ID, but Clerk created it before the run began: an app or - // another tool's user, not this run's, so it is never adopted. - test("an interrupted run does not adopt a match Clerk created outside the run", async () => { + // Same source ID, but no marker from this run: an app or another tool's + // user, so it is never adopted. + test("an interrupted run does not adopt a match without its marker", async () => { stubClerk({ failing: new Set(["u2"]) }); await run(baseOptions); const [first] = listRuns(runsDir()); @@ -908,7 +912,7 @@ describe("run", () => { requests = []; process.exitCode = 0; stubClerk({ - existing: [{ id: "user_theirs", external_id: "u2", created_at: Date.parse("2020-01-01") }], + existing: [{ id: "user_theirs", external_id: "u2", private_metadata: {} }], }); await run({ ...baseOptions, allowPartial: true }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index c65fc2a9c3..0d6bb45d6e 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -619,7 +619,6 @@ export async function run(rawOptions: MigrateRunOptions): Promise { const adopted = new Map(); if (continued) { const found = await findInFlight({ - runsDir, runId: continued.id, sourceIds: inFlight, secretKey, @@ -846,6 +845,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { secretKey, limits, record: run.append, + runId: run.record.id, attachOnly, adopted, skipPasswordRequirement: !options.requirePassword, diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index daf7676d14..83da210bfe 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -28,8 +28,8 @@ let instanceUsers: Map; let failing: Set; /** external_id → Clerk ID, for users whose create was in flight. */ let inFlight: Map; -/** When Clerk says it created each in-flight user: by default, during the run. */ -let inFlightCreatedAt: number; +/** external_id → the run whose marker Clerk holds on that in-flight user. */ +let inFlightMarker: Map; const IMPORT_STARTED = "2026-09-01T00:00:00.000Z"; const AFTER_IMPORT = Date.parse("2026-09-02T00:00:00.000Z"); @@ -47,7 +47,7 @@ beforeEach(() => { requests = []; failing = new Set(); inFlight = new Map(); - inFlightCreatedAt = Date.now(); + inFlightMarker = new Map(); instanceUsers = new Map([ ["user_a", null], ["user_b", AFTER_IMPORT], @@ -70,7 +70,7 @@ beforeEach(() => { .map((externalId) => ({ id: inFlight.get(externalId), external_id: externalId, - created_at: inFlightCreatedAt, + private_metadata: { clerkMigrateRun: inFlightMarker.get(externalId) }, })), ); } @@ -287,6 +287,7 @@ describe("deleting", () => { run.append({ sourceId: "e", status: "creating" }); const record = run.finish(); inFlight.set("d", "user_d"); + inFlightMarker.set("d", record.id); instanceUsers.set("user_d", null); await undo(record.id, withDir({ yes: true })); @@ -297,8 +298,8 @@ describe("deleting", () => { expect(deletes()).toHaveLength(2); }); - // Same source ID, but Clerk created it before the run began: not the run's. - test("leaves an in-flight match that Clerk created outside the run's time", async () => { + // Same source ID, but no marker from this run: an app or another tool's user. + test("leaves an in-flight match that does not carry the run's marker", async () => { const run = startRun(runsDir, { kind: "import", target: { instanceId: "ins_1", env: "development" }, @@ -308,7 +309,6 @@ describe("deleting", () => { run.append({ sourceId: "d", status: "creating" }); const record = run.finish(); inFlight.set("d", "user_d"); - inFlightCreatedAt = Date.parse(record.startedAt) - 24 * 60 * 60_000; instanceUsers.set("user_d", null); await undo(record.id, withDir({ yes: true })); @@ -335,6 +335,7 @@ describe("deleting", () => { runB.append({ sourceId: "d", status: "created", clerkId: "user_d" }); runB.finish(); inFlight.set("d", "user_d"); + inFlightMarker.set("d", runB.record.id); instanceUsers.set("user_d", null); await undo(recordA.id, withDir({ yes: true })); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 004bd31d7b..211edec2e4 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -346,7 +346,6 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise Date: Fri, 9 Oct 2026 14:59:47 -0400 Subject: [PATCH 16/63] fix(migrate): never reject an adopted user, nor count it against the dev quota A continue's adopted users went through every check as candidates. A check that rejected one (legal consent turned on since, a quota cut) left it `creating`, since rejects skip adopted users: the continue was refused without --allow-partial, and with it the run never finished. The quota also counted adopted users in file order, so new users ahead of them could take the headroom and exceed the limit. The checks exist to stop creates, and an adopted user needs none: it now passes them, and only new users take headroom, against the live count, which holds the adopted ones already. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.test.ts | 30 +++++- .../src/commands/migrate/lib/checks.ts | 102 ++++++++++-------- .../cli-core/src/commands/migrate/run.test.ts | 34 ++++++ packages/cli-core/src/commands/migrate/run.ts | 1 + 4 files changed, 117 insertions(+), 50 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 9d2b242a16..cc040e3364 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -479,18 +479,38 @@ describe("rejects", () => { expect(checks.quota).toEqual({ existing: 98, limit: 100, headroom: 2, over: 1 }); }); - // A continued run's adopted users exist already and are never created again. - test("an adopted user does not take headroom twice", async () => { + // A continued run's adopted users exist already and are never created + // again: the live count has them, and only new users take headroom. + test("an adopted user takes no headroom, wherever it sits in the file", async () => { const checks = await checkImport( input({ instanceType: "dev", - existingUsers: 98, + existingUsers: 99, adoptedClerkIds: new Set(["user_c"]), + adoptedSourceIds: new Set(["c"]), users: [user("a"), user("b"), user("c")], }), ); - expect(checks.importable.map((entry) => entry.userId)).toEqual(["a", "b", "c"]); - expect(checks.quota).toEqual({ existing: 98, limit: 100, headroom: 3, over: 0 }); + expect(checks.importable.map((entry) => entry.userId)).toEqual(["a", "c"]); + expect(checks.rejects.map((reject) => reject.sourceId)).toEqual(["b"]); + expect(checks.quota).toEqual({ existing: 99, limit: 100, headroom: 1, over: 1 }); + }); + + // The checks stop creates; an adopted user needs none, so a reject would + // only leave it `creating` for good. + test("an adopted user is never rejected", async () => { + const checks = await checkImport( + input({ + settings: { + ...settings({ email_address: { enabled: true } }), + sign_up: { legal_consent_enabled: true }, + } as never, + adoptedSourceIds: new Set(["a"]), + users: [user("a"), user("b")], + }), + ); + expect(checks.importable.map((entry) => entry.userId)).toEqual(["a"]); + expect(checks.rejects.map((reject) => reject.sourceId)).toEqual(["b"]); }); test("warns that an unreadable user count was checked as empty", async () => { diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 7a4ee28f7c..2a0c38b32d 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -108,6 +108,12 @@ export type CheckInput = { * them in the instance is expected. */ adoptedClerkIds?: Set; + /** + * Source IDs of those adopted users. Each already exists in Clerk, so none + * is rejected: the checks stop creates, and these need none. They count + * toward no quota either; the instance's live count has them already. + */ + adoptedSourceIds?: Set; spinner?: SpinnerControls; }; @@ -996,42 +1002,44 @@ export async function checkImport(input: CheckInput): Promise { if (phoned.dropped) loosePhoneUsers.add(original.userId); const user = phoned.user; const sent = dropDisabledIdentifiers(user, input.settings); - const reason = - original.skipReason ?? - (refused.length > 0 && phoned.dropped && !hasAnyIdentifier(user) - ? "only has emails Clerk refuses and phones not in E.164 form (numeric usernames are on)" - : undefined) ?? - (refused.length > 0 && !hasAnyIdentifier(user) - ? "only has emails Clerk refuses (malformed, or a domain that can't receive mail)" - : undefined) ?? - (phoned.dropped && !hasAnyIdentifier(user) - ? "only has phones not in E.164 form, which this instance refuses (numeric usernames are on)" - : undefined) ?? - // A required phone the file has, but only in a form Clerk refuses here. - (phoned.dropped && - missingRequiredIdentifier(user, input.settings) && - !missingRequiredIdentifier(emailed, input.settings) - ? "only has phones not in E.164 form, and this instance requires a phone number (numeric usernames are on)" - : undefined) ?? - missingRequiredIdentifier(user, input.settings) ?? - // Stripping the identifiers the instance has off can leave nothing to - // sign in with; Clerk would still create the user. - (!hasAnyIdentifier(sent) - ? "has no identifier this instance accepts (its email or phone is turned off, or its username is one Clerk refuses)" - : undefined) ?? - missingRequiredName(user, input.settings) ?? - mfaProblem(user, input.settings) ?? - (!user.password && input.settings && passwordIsOnlySignIn(input.settings) - ? "no password, and password is this instance's only way to sign in" - : undefined) ?? - (!input.skipLegalChecks && lacksLegalAcceptance(user, input.settings) - ? "no legal acceptance on record, which this instance requires (--skip-legal-checks imports them without it)" - : undefined) ?? - usernameProblem(user, input.settings, loosePhones) ?? - (user.password && user.passwordHasher - ? hashShapeProblem(user.password, user.passwordHasher) - : undefined) ?? - disabledProviders.get(user.userId); + const adopted = input.adoptedSourceIds?.has(original.userId) ?? false; + const reason = adopted + ? undefined + : (original.skipReason ?? + (refused.length > 0 && phoned.dropped && !hasAnyIdentifier(user) + ? "only has emails Clerk refuses and phones not in E.164 form (numeric usernames are on)" + : undefined) ?? + (refused.length > 0 && !hasAnyIdentifier(user) + ? "only has emails Clerk refuses (malformed, or a domain that can't receive mail)" + : undefined) ?? + (phoned.dropped && !hasAnyIdentifier(user) + ? "only has phones not in E.164 form, which this instance refuses (numeric usernames are on)" + : undefined) ?? + // A required phone the file has, but only in a form Clerk refuses here. + (phoned.dropped && + missingRequiredIdentifier(user, input.settings) && + !missingRequiredIdentifier(emailed, input.settings) + ? "only has phones not in E.164 form, and this instance requires a phone number (numeric usernames are on)" + : undefined) ?? + missingRequiredIdentifier(user, input.settings) ?? + // Stripping the identifiers the instance has off can leave nothing to + // sign in with; Clerk would still create the user. + (!hasAnyIdentifier(sent) + ? "has no identifier this instance accepts (its email or phone is turned off, or its username is one Clerk refuses)" + : undefined) ?? + missingRequiredName(user, input.settings) ?? + mfaProblem(user, input.settings) ?? + (!user.password && input.settings && passwordIsOnlySignIn(input.settings) + ? "no password, and password is this instance's only way to sign in" + : undefined) ?? + (!input.skipLegalChecks && lacksLegalAcceptance(user, input.settings) + ? "no legal acceptance on record, which this instance requires (--skip-legal-checks imports them without it)" + : undefined) ?? + usernameProblem(user, input.settings, loosePhones) ?? + (user.password && user.passwordHasher + ? hashShapeProblem(user.password, user.passwordHasher) + : undefined) ?? + disabledProviders.get(user.userId)); if (reason) { rejects.push({ sourceId: user.userId, reason }); } else { @@ -1047,7 +1055,7 @@ export async function checkImport(input: CheckInput): Promise { const { reasons: fileDuplicates, keptBy } = findFileDuplicates(passed); let candidates: User[] = []; for (const user of passed) { - const reason = fileDuplicates.get(user); + const reason = input.adoptedSourceIds?.has(user.userId) ? undefined : fileDuplicates.get(user); const kept = keptBy.get(user); if (reason) { rejects.push({ sourceId: user.userId, reason, ...(kept ? { keptSourceId: kept } : {}) }); @@ -1061,7 +1069,9 @@ export async function checkImport(input: CheckInput): Promise { : new Map(); const unique: User[] = []; for (const user of candidates) { - const reason = instanceDuplicates.get(user.userId); + const reason = input.adoptedSourceIds?.has(user.userId) + ? undefined + : instanceDuplicates.get(user.userId); if (reason) { rejects.push({ sourceId: user.userId, reason }); duplicates.add(originals.get(user) ?? user); @@ -1075,20 +1085,22 @@ export async function checkImport(input: CheckInput): Promise { let quota: Quota | undefined; if (input.instanceType === "dev") { const limit = resolveDevUserLimit(); - // An adopted user is in the instance's count already and among the - // candidates too, though it is never created: counted once, not twice. - const occupied = Math.max(0, (input.existingUsers ?? 0) - (input.adoptedClerkIds?.size ?? 0)); - const headroom = Math.max(0, limit - occupied); - const over = Math.max(0, candidates.length - headroom); + // An adopted user is in the instance's count already, and is never + // created: only the new users take headroom, in file order. + const headroom = Math.max(0, limit - (input.existingUsers ?? 0)); + const isNew = (user: User) => !input.adoptedSourceIds?.has(user.userId); + const fresh = candidates.filter(isNew); + const over = Math.max(0, fresh.length - headroom); quota = { existing: input.existingUsers ?? null, limit, headroom, over }; if (over > 0) { - for (const user of candidates.slice(headroom)) { + const past = new Set(fresh.slice(headroom)); + for (const user of past) { rejects.push({ sourceId: user.userId, reason: `over the development instance's ${limit}-user limit (raised by Clerk? set CLERK_MIGRATE_DEV_USER_LIMIT)`, }); } - candidates = candidates.slice(0, headroom); + candidates = candidates.filter((user) => !past.has(user)); } } diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 828c4b8129..a193578700 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -897,6 +897,40 @@ describe("run", () => { expect(readRun(runsDir(), first!.id)?.status).toBe("complete"); }); + // A reject would stop a create; an adopted user needs none. Rejected, it + // stayed `creating` and the run could never finish. + test("an adopted user a check would reject still finishes the run", 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({ + // Legal consent turned on since: u2 has no acceptance on record. + settings: { + attributes: { email_address: { enabled: true } }, + sign_up: { legal_consent_enabled: true }, + enterprise_sso: { enabled: true }, + }, + existing: [ + { id: "user_found", external_id: "u2", private_metadata: { clerkMigrateRun: first!.id } }, + ], + }); + await run(baseOptions); + + expect(latestUserLines(runsDir(), first!.id).get("u2")).toMatchObject({ + status: "created", + clerkId: "user_found", + }); + expect(readRun(runsDir(), first!.id)?.status).toBe("complete"); + }); + // Same source ID, but no marker from this run: an app or another tool's // user, so it is never adopted. test("an interrupted run does not adopt a match without its marker", async () => { diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 0d6bb45d6e..ea96c2dab8 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -716,6 +716,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { secretKey, schedule, adoptedClerkIds: new Set(adopted.values()), + adoptedSourceIds: new Set(adopted.keys()), spinner, }), ); From 76e0d8e99f06f78bc67a41a25b86ed5237bd7741 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 14:59:56 -0400 Subject: [PATCH 17/63] docs(migrate): say a cut-off create is redone only when nothing matches "One it doesn't hold is created" could read as covering a match without the run's marker, which the next sentence says is rejected. The README now says a user is created again only when the lookup finds no one. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 44d044cd4b..c00296abb7 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -280,11 +280,11 @@ an undo or another continue, exits 2 too, with nothing written. A continued run also finishes what the last one left open: -- A user still `creating` is looked up by `external_id`. One Clerk holds - that carries this run's marker is adopted as `created`, and not created - again; one it doesn't hold is created. A match without the marker is - someone else's user: the checks reject that record as already in the - instance. +- A user still `creating` is looked up by `external_id`. A match that + carries this run's marker is adopted as `created`, and not created again. + A match without the marker is someone else's user: the checks reject that + record as already in the instance. Only when the lookup finds no user at + all is it created. Every create sends the run's ID in the user's private metadata, as `clerkMigrateRun`, merged with any private metadata the source carries. It From 3d39832696b7dbc56f4179ff107bf7ab852f19f0 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 17:16:37 -0400 Subject: [PATCH 18/63] refactor(migrate): drop the adopted-user guard the checks made dead when recording rejects Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/run.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index ea96c2dab8..1407fefee4 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -534,13 +534,10 @@ function rejectSummary(checks: ImportChecks, json = false): string { /** * 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. + * The checks never reject an adopted user: it is created already. */ -function recordRejects(run: Run, checks: ImportChecks, adopted: Map): void { +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", @@ -827,7 +824,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { // 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, adopted); + recordRejects(run, checks); for (const user of withoutPassword) { run.append({ sourceId: user.userId, From 595e4a5065c5d639f32468720f93db3ca07a4024 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 18:22:15 -0400 Subject: [PATCH 19/63] fix(migrate): say a plain re-run sends the users a quota stop left, now that it continues 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 a193578700..a60a11cffc 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -743,7 +743,7 @@ describe("run", () => { const err = Bun.stripANSI(captured.err); expect(err).toContain("Not sent: 1"); expect(err).toContain( - "No more users were sent. Once the limit is raised, run the import again with `--allow-partial --yes` to send them.", + "No more users were sent. Once the limit is raised, run the import again to send them.", ); }); }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 1407fefee4..0355aeedf1 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -461,7 +461,7 @@ function formatSummary( lines.push( "", yellow( - "No more users were sent. Once the limit is raised, run the import again with `--allow-partial --yes` to send them.", + "No more users were sent. Once the limit is raised, run the import again to send them.", ), ); } From 258fdd4e53e1b66a0e10037744b98ae7163964e3 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 19:34:40 -0400 Subject: [PATCH 20/63] fix(migrate): record a created user with its pending attaches in one line Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/import-users.test.ts | 20 +++++++++++-------- .../src/commands/migrate/import-users.ts | 12 ++++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index 8738ec0a66..7507760d88 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -345,14 +345,18 @@ describe("importUsers", () => { }); expect(summary).toMatchObject({ successful: 1, failed: 0 }); - // On record once created, then with its attach pending, then with what - // the attach added. A refused attach is not retried, so nothing is pending. - expect(lines).toHaveLength(3); - expect(lines[0]).toEqual({ sourceId: "u1", clerkId: "user_created", status: "created" }); - expect(lines[1]?.pending).toEqual([{ kind: "email", value: "b@x.dev", verified: true }]); - expect(lines[2]).toMatchObject({ status: "created", clerkId: "user_created" }); - expect(lines[2]?.error).toContain("Failed to add additional email b@x.dev"); - expect(lines[2]).not.toHaveProperty("pending"); + // On record once created with its attach pending, in one line, then with + // what the attach added. A refused attach is not retried, so nothing is pending. + expect(lines).toHaveLength(2); + expect(lines[0]).toEqual({ + sourceId: "u1", + clerkId: "user_created", + status: "created", + pending: [{ kind: "email", value: "b@x.dev", verified: true }], + }); + expect(lines[1]).toMatchObject({ status: "created", clerkId: "user_created" }); + expect(lines[1]?.error).toContain("Failed to add additional email b@x.dev"); + expect(lines[1]).not.toHaveProperty("pending"); }); // A Ctrl-C mid-backoff should not wait out the pause, nor send the attach. diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index c17b14db8c..930c5f2d4d 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -466,12 +466,11 @@ export async function importUsers(options: ImportUsersOptions): Promise { const { error: _error, pending: _pending, ...base } = line; - if (toAttach.length > 0) record({ ...base, pending: toAttach }); const attached = await attachAll(ctx, base.clerkId ?? "", toAttach); const error = [...notes, ...attached.notes].join("; "); // A second line, which wins as the latest, adds what happened on the way. @@ -555,12 +554,15 @@ export async function importUsers(options: ImportUsersOptions): Promise 0 ? { ...line, pending: toAttach } : line); if (created.phoneRefusal) { const reason = normalizeErrorMessage(created.phoneRefusal); droppedPhones.set(reason, (droppedPhones.get(reason) ?? 0) + 1); } - await finishUser(line, pendingIdentifiers(identifiers), [...created.notes, ...retries]); + await finishUser(line, toAttach, [...created.notes, ...retries]); successful++; processed++; progress(); From 8f53c88670f55cc7ae6f91e4a94817ba1f758415 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 19:34:41 -0400 Subject: [PATCH 21/63] fix(migrate): still reject a second record with an adopted user's source ID Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.test.ts | 14 ++++++++++++++ .../cli-core/src/commands/migrate/lib/checks.ts | 13 +++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index cc040e3364..0667142469 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -513,6 +513,20 @@ describe("rejects", () => { expect(checks.rejects.map((reject) => reject.sourceId)).toEqual(["b"]); }); + // Both would go to importUsers as the one adopted user. + test("a second record with an adopted user's source ID is still rejected", async () => { + const checks = await checkImport( + input({ + adoptedSourceIds: new Set(["a"]), + users: [user("a"), user("a", { email: "z@x.dev" })], + }), + ); + expect(checks.importable).toHaveLength(1); + expect(checks.rejects).toEqual([ + { sourceId: "a", reason: "duplicate source ID in the file", keptSourceId: "a" }, + ]); + }); + test("warns that an unreadable user count was checked as empty", async () => { const checks = await checkImport( input({ instanceType: "dev", existingUsers: null, users: [user("a")] }), diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 2a0c38b32d..6461d62065 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -525,6 +525,8 @@ const hasAnyIdentifier = (user: User) => hasValue(user[field as keyof User]), ); +const DUPLICATE_SOURCE_ID = "duplicate source ID in the file"; + /** * First user in the file to claim each email, phone and source ID. * @@ -550,7 +552,7 @@ function findFileDuplicates(users: User[]): { for (const user of users) { if (seenIds.has(user.userId)) { - reasons.set(user, "duplicate source ID in the file"); + reasons.set(user, DUPLICATE_SOURCE_ID); // The earlier record under the same ID is the one kept. keptBy.set(user, user.userId); continue; @@ -1055,7 +1057,14 @@ export async function checkImport(input: CheckInput): Promise { const { reasons: fileDuplicates, keptBy } = findFileDuplicates(passed); let candidates: User[] = []; for (const user of passed) { - const reason = input.adoptedSourceIds?.has(user.userId) ? undefined : fileDuplicates.get(user); + // An adopted user is in Clerk already, so an earlier record sharing its + // email costs it nothing. A second record with its source ID would be + // created as the same user, so that one is still rejected. + const duplicate = fileDuplicates.get(user); + const reason = + input.adoptedSourceIds?.has(user.userId) && duplicate !== DUPLICATE_SOURCE_ID + ? undefined + : duplicate; const kept = keptBy.get(user); if (reason) { rejects.push({ sourceId: user.userId, reason, ...(kept ? { keptSourceId: kept } : {}) }); From 83a0a1a61706d67a71378fabda36ffc2d1f3eeb4 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 20:41:32 -0400 Subject: [PATCH 22/63] docs(migrate): say the run's marker replaces one the source already carries Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 7 ++++--- .../src/commands/migrate/import-users.test.ts | 18 ++++++++++++++++++ .../src/commands/migrate/import-users.ts | 3 ++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index c00296abb7..1d7f2550ff 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -288,9 +288,10 @@ A continued run also finishes what the last one left open: Every create sends the run's ID in the user's private metadata, as `clerkMigrateRun`, merged with any private metadata the source carries. It -is how a cut-off create is told apart from a user an app or another tool -made with the same `external_id`, and it stays on the user. A run recorded -before the marker existed adopts nothing. +replaces a `clerkMigrateRun` the source already has, from a run that imported +the user into the source instance. It is how a cut-off create is told apart +from a user an app or another tool made with the same `external_id`, and it +stays on the user. A run recorded before the marker existed adopts nothing. - A user whose `created` line has `pending` identifiers gets just those attaches. diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index 7507760d88..a3cb837836 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -228,6 +228,24 @@ describe("importUsers", () => { }); }); + // A Clerk export of users an earlier migration imported carries that run's + // marker; this run's must win, or a cut-off create could never be adopted. + test("replaces a marker the source's private metadata already carries", async () => { + stub(() => ok("user_created")); + + await importUsers({ + users: [user({ userId: "u1", privateMetadata: { clerkMigrateRun: "20250101-000000-0000" } })], + secretKey: "sk_test_x", + limits: LIMITS, + record, + runId: "20260101-000000-abcd", + }); + + expect(requests.find((r) => r.url.endsWith("/v1/users"))?.body).toMatchObject({ + private_metadata: { clerkMigrateRun: "20260101-000000-abcd" }, + }); + }); + test("creates each user and reports them as successful", async () => { stub(() => ok("user_created")); diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index 930c5f2d4d..6f5d7534c2 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -325,7 +325,8 @@ async function createUser( const body = buildCreateUserBody(user, identifiers, skipPasswordRequirement); // The run's marker, with whatever private metadata the source brought: // without it a create cut off mid-flight could never be told from a user - // someone else made with the same external_id. + // someone else made with the same external_id. It replaces a marker the + // source carries, from a run that imported the user there. if (ctx.runId) { body.private_metadata = { ...(body.private_metadata as Record | undefined), From fc70e18bca5b1974307a1cde434d560984c8c6ec Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 20:41:32 -0400 Subject: [PATCH 23/63] fix(migrate): reject an earlier record that claims an identifier an adopted user holds Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.test.ts | 19 ++++++++++++++++++ .../src/commands/migrate/lib/checks.ts | 20 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 0667142469..02e5df18de 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -514,6 +514,25 @@ describe("rejects", () => { }); // Both would go to importUsers as the one adopted user. + // The adopted user is in Clerk with that email, so the earlier record's + // create would be the one Clerk refuses. + test("rejects an earlier record that shares an adopted user's email", async () => { + const checks = await checkImport( + input({ + adoptedSourceIds: new Set(["late"]), + users: [user("early", { email: "same@x.dev" }), user("late", { email: "same@x.dev" })], + }), + ); + expect(checks.importable.map((entry) => entry.userId)).toEqual(["late"]); + expect(checks.rejects).toEqual([ + { + sourceId: "early", + reason: "shares an email, phone or username with a user an earlier attempt already created", + keptSourceId: "late", + }, + ]); + }); + test("a second record with an adopted user's source ID is still rejected", async () => { const checks = await checkImport( input({ diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 6461d62065..4ab0d5c102 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -1055,8 +1055,28 @@ export async function checkImport(input: CheckInput): Promise { // No setting would let a duplicate in, so none earns a fix. const duplicates = new Set(); const { reasons: fileDuplicates, keptBy } = findFileDuplicates(passed); + // An adopted user already holds its email, phone and username in Clerk, so + // an earlier record that claimed one first is the one whose create would + // fail. Keyed by that record's source ID, naming the adopted user. + const heldByAdopted = new Map(); + for (const user of passed) { + const kept = keptBy.get(user); + if (kept && input.adoptedSourceIds?.has(user.userId) && !input.adoptedSourceIds.has(kept)) { + heldByAdopted.set(kept, user.userId); + } + } let candidates: User[] = []; for (const user of passed) { + const holder = heldByAdopted.get(user.userId); + if (holder) { + rejects.push({ + sourceId: user.userId, + reason: "shares an email, phone or username with a user an earlier attempt already created", + keptSourceId: holder, + }); + duplicates.add(originals.get(user) ?? user); + continue; + } // An adopted user is in Clerk already, so an earlier record sharing its // email costs it nothing. A second record with its source ID would be // created as the same user, so that one is still rejected. From 798b4861b03ac0db9a9e86a3bc24439c14b854f2 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 13 Oct 2026 17:56:58 -0400 Subject: [PATCH 24/63] fix(migrate): let adopted users claim their identifiers before the file's order does A record rejected for an adopted user's email still held its phone against a later record, which was rejected as its duplicate though neither imported user had the phone. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/checks.test.ts | 22 +++++++++++++++++++ .../src/commands/migrate/lib/checks.ts | 21 ++++++++---------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 02e5df18de..23492f82cf 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -533,6 +533,28 @@ describe("rejects", () => { ]); }); + // A, rejected for B's email, no longer holds its phone against C. + test("a record rejected for an adopted user's email leaves its phone to a later one", async () => { + const checks = await checkImport( + input({ + adoptedSourceIds: new Set(["b"]), + users: [ + user("a", { email: "x@x.dev", phone: "+15555550100" }), + user("b", { email: "x@x.dev" }), + user("c", { email: "c@x.dev", phone: "+15555550100" }), + ], + }), + ); + expect(checks.importable.map((entry) => entry.userId)).toEqual(["b", "c"]); + expect(checks.rejects).toEqual([ + { + sourceId: "a", + reason: "shares an email, phone or username with a user an earlier attempt already created", + keptSourceId: "b", + }, + ]); + }); + test("a second record with an adopted user's source ID is still rejected", async () => { const checks = await checkImport( input({ diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 4ab0d5c102..bb91d866a9 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -1054,20 +1054,18 @@ export async function checkImport(input: CheckInput): Promise { // No setting would let a duplicate in, so none earns a fix. const duplicates = new Set(); - const { reasons: fileDuplicates, keptBy } = findFileDuplicates(passed); // An adopted user already holds its email, phone and username in Clerk, so - // an earlier record that claimed one first is the one whose create would - // fail. Keyed by that record's source ID, naming the adopted user. - const heldByAdopted = new Map(); - for (const user of passed) { - const kept = keptBy.get(user); - if (kept && input.adoptedSourceIds?.has(user.userId) && !input.adoptedSourceIds.has(kept)) { - heldByAdopted.set(kept, user.userId); - } - } + // it claims them first, wherever it sits in the file: a fresh record sharing + // one is the create Clerk would refuse, and is not kept in its place. + const adopted = (user: User) => input.adoptedSourceIds?.has(user.userId) ?? false; + const { reasons: fileDuplicates, keptBy } = findFileDuplicates([ + ...passed.filter(adopted), + ...passed.filter((user) => !adopted(user)), + ]); let candidates: User[] = []; for (const user of passed) { - const holder = heldByAdopted.get(user.userId); + const kept = keptBy.get(user); + const holder = kept && !adopted(user) && input.adoptedSourceIds?.has(kept) ? kept : undefined; if (holder) { rejects.push({ sourceId: user.userId, @@ -1085,7 +1083,6 @@ export async function checkImport(input: CheckInput): Promise { input.adoptedSourceIds?.has(user.userId) && duplicate !== DUPLICATE_SOURCE_ID ? undefined : duplicate; - const kept = keptBy.get(user); if (reason) { rejects.push({ sourceId: user.userId, reason, ...(kept ? { keptSourceId: kept } : {}) }); duplicates.add(originals.get(user) ?? user); From e643e3256eb9f0afaf266bf69b5e5404d94d8424 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 13 Oct 2026 17:57:24 -0400 Subject: [PATCH 25/63] fix(migrate): have a dry run say the run it would start or continue Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/run.test.ts | 15 +++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 7 +++++-- 2 files changed, 20 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 a60a11cffc..65b82604c6 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -823,6 +823,21 @@ describe("run", () => { expect(listRuns(runsDir())).toHaveLength(2); }); + test("a dry run says what it would start or continue", async () => { + stubClerk({ failing: new Set(["u2"]) }); + await run({ ...baseOptions, dryRun: true }); + expect(captured.err).toContain("Would start a new run."); + expect(captured.err).not.toContain("Starting a new run."); + + await run(baseOptions); + const [first] = listRuns(runsDir()); + process.exitCode = 0; + captured.clear(); + await run({ ...baseOptions, dryRun: true }); + expect(captured.err).toContain(`Would continue run ${first!.id}, which finished partial`); + expect(captured.err).not.toContain("Continuing run"); + }); + 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); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 0355aeedf1..3d57c66bbb 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -625,11 +625,14 @@ export async function run(rawOptions: MigrateRunOptions): Promise { } if (!options.json) { + // A dry run writes nothing, so it only says what the import would do. log.info( resume.kind === "continue" - ? `Continuing run ${resume.record.id}, which ${resume.because === "interrupted" ? "was interrupted" : "finished partial"}: ` + + ? `${options.dryRun ? "Would 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.", + : options.dryRun + ? "Would start a new run." + : "Starting a new run.", ); if (adopted.size > 0) { log.info( From 5f99502efd6f3b2def46e36ada71f469bd45cc6f Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 13 Oct 2026 17:58:39 -0400 Subject: [PATCH 26/63] fix(migrate): have undo --yes refuse once users have signed in, unless --include-signed-in --yes consents to undoing the import, not to deleting accounts people have used since. With any such user, --yes alone now deletes nothing and names the command that adds --include-signed-in. A yes at the prompt follows the preview's warning, so it needs no flag. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 21 ++++++++++----- .../src/commands/migrate/index.test.ts | 1 + .../cli-core/src/commands/migrate/index.ts | 1 + .../src/commands/migrate/undo.test.ts | 26 ++++++++++++++++++- .../cli-core/src/commands/migrate/undo.ts | 22 +++++++++++++++- 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 1d7f2550ff..fe605f50e3 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -497,13 +497,14 @@ clerk migrate undo 20260929-141502-a1b2 --yes # no prompt clerk migrate undo 20260929-141502-a1b2 --json --yes ``` -| 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 | +| Flag | Description | +| --------------------- | -------------------------------------------------------------- | +| `` | The import run to undo | +| `--dry-run` | Show the preview and delete nothing | +| `-y, --yes` | Delete without prompting | +| `--include-signed-in` | With `--yes`, also delete users who signed in since the import | +| `--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`. @@ -514,6 +515,12 @@ 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. When none remain, undo completes without prompting. +`--yes` is consent to undo the import, not to delete accounts people have used +since: when any user has signed in after the import, `--yes` alone exits 2 and +deletes nothing, naming the command with `--include-signed-in` that deletes +them too. A yes at the prompt follows the preview's warning, so it needs no +flag. + It refuses with exit 2, and deletes nothing, when: - the resolved key addresses a different instance than the run imported into diff --git a/packages/cli-core/src/commands/migrate/index.test.ts b/packages/cli-core/src/commands/migrate/index.test.ts index 2dd590f1a7..7d742c553d 100644 --- a/packages/cli-core/src/commands/migrate/index.test.ts +++ b/packages/cli-core/src/commands/migrate/index.test.ts @@ -73,6 +73,7 @@ describe("registerMigrate", () => { expect(undoCommand?.options.map((option) => option.long)).toEqual([ "--dry-run", "--yes", + "--include-signed-in", "--json", "--secret-key", "--app", diff --git a/packages/cli-core/src/commands/migrate/index.ts b/packages/cli-core/src/commands/migrate/index.ts index 90a42ef54d..cbf062084a 100644 --- a/packages/cli-core/src/commands/migrate/index.ts +++ b/packages/cli-core/src/commands/migrate/index.ts @@ -109,6 +109,7 @@ export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = proce .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("--include-signed-in", "With --yes, also delete users who signed in since the import") .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)") diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index 83da210bfe..94113e99dd 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -116,7 +116,8 @@ function importRun(overrides: Partial = {}): RunRecord { return { ...record, ...overrides }; } -const options = { secretKey: "sk_test_x", runsDir: "" }; +// `user_b` has signed in since the import; the guard on that has its own tests. +const options = { secretKey: "sk_test_x", runsDir: "", includeSignedIn: true }; const withDir = (extra: Record = {}) => ({ ...options, runsDir, ...extra }); const deletes = () => requests.filter((request) => request.method === "DELETE"); @@ -272,6 +273,29 @@ describe("--dry-run", () => { }); }); +describe("users who signed in since the import", () => { + test("--yes alone refuses to delete them, and deletes nothing", async () => { + const record = importRun(); + + const error = (await undo(record.id, withDir({ yes: true, includeSignedIn: false })).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain("1 user the import created has signed in since"); + expect(error.examples?.[0]?.command).toContain("--yes --include-signed-in"); + expect(deletes()).toHaveLength(0); + }); + + test("--include-signed-in deletes them too", async () => { + const record = importRun(); + + await undo(record.id, withDir({ yes: true, includeSignedIn: true })); + + expect(deletes()).toHaveLength(2); + }); +}); + describe("deleting", () => { // The run stopped with this user's POST /v1/users in flight: Clerk created // it, but its ID never reached the run record. diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 211edec2e4..2453355a86 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -56,6 +56,8 @@ import { findInFlight, lookupUsers } from "./lib/user-lookup.ts"; export type UndoOptions = { dryRun?: boolean; yes?: boolean; + /** With `--yes`, also delete users who have signed in since the import. */ + includeSignedIn?: boolean; json?: boolean; secretKey?: string; app?: string; @@ -368,7 +370,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 ? " --include-signed-in" : ""}`; const nothingLeft = present.length === 0 && gone.length === 0; if (options.dryRun) { @@ -405,6 +407,24 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 && !options.includeSignedIn) { + if (options.json) { + log.data( + JSON.stringify(jsonResult(target, record, preview, { consent: "required" }), null, 2), + ); + } + throwUsageError( + `${plural(preview.signedInSince, "user")} the import created ${preview.signedInSince === 1 ? "has" : "have"} signed in since, and --yes alone does not delete them. ` + + "Pass --include-signed-in to delete them too. Nothing was deleted.", + undefined, + undefined, + [{ command, description: "Delete every user this import created" }], + ); + } + // Gitignored only now, once there is consent to write a run. await resolveRunsDir(options.runsDir, { write: true }); From 98ca4d7235267db5341329ad0aa63deb057e3846 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 13 Oct 2026 17:58:53 -0400 Subject: [PATCH 27/63] docs(changeset): say every imported user carries the run marker in private_metadata Co-Authored-By: Claude Opus 5.5 --- .changeset/migrate-runs-undo.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/migrate-runs-undo.md b/.changeset/migrate-runs-undo.md index d9ddd724ba..2c498fb32e 100644 --- a/.changeset/migrate-runs-undo.md +++ b/.changeset/migrate-runs-undo.md @@ -3,3 +3,5 @@ --- Add `clerk migrate runs` and `clerk migrate undo`, and let a re-run of `clerk migrate import` continue where a stopped one left off. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`. + +Every user an import creates now carries `clerkMigrateRun` (the run's ID) in its `private_metadata`, replacing any value the source had under that key. A re-run reads it to adopt a user whose create was cut off. Nothing removes it afterwards, and your backend can read it. From 41912cf43ff52626ad1827cda2bea40669bd7bfc Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 13 Oct 2026 18:12:52 -0400 Subject: [PATCH 28/63] fix(migrate): refuse an undo whose preview another undo overtook The open undo was read before consent and never again. Two undos started together each saw none, and the second started an undo run of its own, splitting the import's deletes between the two. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 2 ++ .../src/commands/migrate/undo.test.ts | 32 +++++++++++++++++++ .../cli-core/src/commands/migrate/undo.ts | 12 +++++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index fe605f50e3..be7d6727c5 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -527,6 +527,8 @@ It refuses with exit 2, and deletes nothing, when: (the error names both) - the run is not an import run - the run has already been undone +- the run, or an undo of it, changed while the preview waited for consent (a + re-import, or another undo): the preview is out of date, so run it again 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, diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index 94113e99dd..17abf51f01 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -273,6 +273,38 @@ describe("--dry-run", () => { }); }); +describe("another undo during the preview", () => { + test("refuses when another undo started an undo run on the import", async () => { + const record = importRun(); + const stubbed = globalThis.fetch; + let raced = false; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + // The other undo gets its lock between this one's preview and its own. + if (!raced && (init?.method ?? "GET") === "GET" && input.toString().includes("/v1/users")) { + raced = true; + const other = startRun(runsDir, { + kind: "undo", + target: { instanceId: "ins_1", env: "development" }, + undoes: record.id, + source: "clerk", + }); + other.append({ sourceId: "a", clerkId: "user_a", status: "deleted" }); + other.append({ sourceId: "b", clerkId: "user_b", status: "failed", error: "boom" }); + other.finish(); + } + return stubbed(input, init); + }) as typeof fetch; + + const error = (await undo(record.id, withDir({ yes: true })).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.message).toContain("preview is out of date"); + expect(deletes()).toHaveLength(0); + expect(listRuns(runsDir).filter((run) => run.kind === "undo")).toHaveLength(1); + }); +}); + describe("users who signed in since the import", () => { test("--yes alone refuses to delete them, and deletes nothing", async () => { const record = importRun(); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 2453355a86..74d927c494 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -341,6 +341,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise Date: Thu, 15 Oct 2026 00:34:54 -0400 Subject: [PATCH 29/63] fix(migrate): keep the runs folder and target in every suggested migrate command, and name undo's import in its JSON Undo's suggested `--yes` command, an import's next steps, the "Finish it with clerk migrate undo" refusals and `clerk migrate runs `'s next command all dropped --runs-dir, --app, --instance and --secret-key, so following them looked in the default runs folder or reached another instance and refused. They now share carryFlags, which import's own suggested command already spelled out; `runs` carries only --runs-dir, the one it has. Undo's --json used `run` for the import on a dry run or consent refusal and for the undo run on success. The import is now `import`, and `run` is the undo's own run, null until one is written, as an import's is. The README documents the shape. Undo now leaves its run unfinished on a Ctrl-C, as an import does, rather than finishing it partial with the queued deletes recorded as failed. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 24 ++++++++- .../commands/migrate/lib/carry-flags.test.ts | 30 +++++++++++ .../src/commands/migrate/lib/carry-flags.ts | 27 ++++++++++ .../src/commands/migrate/lib/run-store.ts | 5 +- .../cli-core/src/commands/migrate/run.test.ts | 3 +- packages/cli-core/src/commands/migrate/run.ts | 34 +++++++------ .../src/commands/migrate/runs.test.ts | 11 ++++ .../cli-core/src/commands/migrate/runs.ts | 29 +++++++---- .../src/commands/migrate/undo.test.ts | 50 ++++++++++++++++++- .../cli-core/src/commands/migrate/undo.ts | 33 +++++++++--- packages/cli-core/src/lib/next-steps.ts | 24 ++++++--- 11 files changed, 227 insertions(+), 43 deletions(-) create mode 100644 packages/cli-core/src/commands/migrate/lib/carry-flags.test.ts create mode 100644 packages/cli-core/src/commands/migrate/lib/carry-flags.ts diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index be7d6727c5..2dab5f0ae8 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -513,7 +513,10 @@ how many of them have signed in since the import (from each user's `last_sign_in_at`). When users remain to delete or record as gone, nothing is deleted without consent: a yes at the prompt, or `--yes`. Without either — an agent, a non-TTY run, or `--json` — it prints the preview and exits 2 with the -command to run. When none remain, undo completes without prompting. +command to run. That command keeps `--runs-dir`, `--app`, `--instance` and +`--secret-key` (as ``) when they were passed, or it would look in another +runs folder or reach another instance. When none remain, undo completes +without prompting. `--yes` is consent to undo the import, not to delete accounts people have used since: when any user has signed in after the import, `--yes` alone exits 2 and @@ -534,7 +537,24 @@ 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. +the users that failed, in the same undo run. A Ctrl-C leaves the undo run +`interrupted`, as it does an import, and running `undo` again carries on. + +`--json` prints one object on stdout, whatever the outcome: + +```json +{ + "target": { "instanceId": "ins_…", "env": "development", "keySource": "--secret-key" }, + "import": { "id": "20260929-141502-a1b2", "kind": "import", … }, + "run": null, + "preview": { "toDelete": 2, "signedInSince": 1, "alreadyGone": 0, "alreadyDeleted": 0 } +} +``` + +`import` is the run being undone. `run` is the undo's own run, `null` until one +is written, as an import's is: a dry run adds `"dryRun": true`, and a refusal +for consent `"consent": "required"`. Once the deletes run, `run` is the undo run +and `result` holds `deleted`, `failed` and an `errors` breakdown. ### `clerk migrate help` diff --git a/packages/cli-core/src/commands/migrate/lib/carry-flags.test.ts b/packages/cli-core/src/commands/migrate/lib/carry-flags.test.ts new file mode 100644 index 0000000000..5a3b13b07e --- /dev/null +++ b/packages/cli-core/src/commands/migrate/lib/carry-flags.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { NEXT_STEPS } from "../../../lib/next-steps.ts"; +import { carryFlags } from "./carry-flags.ts"; + +describe("carryFlags", () => { + test("keeps the runs folder and target, with the key as a placeholder", () => { + expect( + carryFlags({ secretKey: "sk_test_x", app: "app_1", instance: "dev", runsDir: "/a b" }), + ).toEqual([ + "--secret-key", + "", + "--app", + "app_1", + "--instance", + "dev", + "--runs-dir", + "'/a b'", + ]); + }); + + // `clerk migrate runs` takes no target flags. + test("an import's next steps keep its flags, and `runs` only the runs folder", () => { + const [runsLine, undoLine] = NEXT_STEPS.MIGRATE_DONE( + "r1", + carryFlags({ secretKey: "sk_test_x", runsDir: "/a b" }), + ); + expect(runsLine).toContain("`clerk migrate runs r1 --runs-dir '/a b'`"); + expect(undoLine).toContain("`clerk migrate undo r1 --secret-key --runs-dir '/a b'`"); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts new file mode 100644 index 0000000000..a778938f51 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts @@ -0,0 +1,27 @@ +import { quoteArg } from "../../../lib/json-body.ts"; + +/** + * The flags that chose a migrate command's runs folder and instance, for a + * command it suggests. Left out, the suggested command looks in the default + * runs folder or reaches another instance, and refuses: an agent that follows + * it loops on exit 2. Values are shell-quoted, and the secret key is a + * placeholder. + */ +export function carryFlags(options: { + secretKey?: string; + app?: string; + instance?: string; + runsDir?: string; +}): string[] { + return [ + ...(options.secretKey ? ["--secret-key", ""] : []), + ...(options.app ? ["--app", quoteArg(options.app)] : []), + ...(options.instance ? ["--instance", quoteArg(options.instance)] : []), + ...(options.runsDir ? ["--runs-dir", quoteArg(options.runsDir)] : []), + ]; +} + +/** `--` flags appended to a command, or nothing. */ +export function withFlags(command: string, flags: string[]): string { + return flags.length > 0 ? `${command} ${flags.join(" ")}` : command; +} 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 f15fc7a05d..5ddd2a60f6 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -25,6 +25,7 @@ import { resolveProfile } from "../../../lib/config.ts"; import { throwUsageError } from "../../../lib/errors.ts"; import { ensureGitignoreEntry, getGitRepoRoot } from "../../../lib/git.ts"; import { log } from "../../../lib/log.ts"; +import { withFlags } from "./carry-flags.ts"; /** Overrides the runs directory, below `--runs-dir`. */ export const RUNS_DIR_ENV = "CLERK_MIGRATE_DIR"; @@ -446,6 +447,8 @@ export function continueRun( * undo, or another continue, can change either in the meantime. */ plannedLines?: number, + /** The caller's runs-folder and target flags, for the command a refusal suggests. */ + carry: string[] = [], ): Run { acquireLock(runsDir, record.id); if (plannedLines !== undefined) { @@ -479,7 +482,7 @@ export function continueRun( fs.rmSync(lockFile(runsDir, record.id), { force: true }); throwUsageError( `Run ${record.id} has an undo that did not finish (run ${undoRun.id}). ` + - `Finish it with \`clerk migrate undo ${record.id}\`, or pass --new-run to import into a new run.`, + `Finish it with \`${withFlags(`clerk migrate undo ${record.id}`, carry)}\`, or pass --new-run to import into a new run.`, ); } // A crash mid-write leaves a torn last line; end it so the next append diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 65b82604c6..fe023b4890 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -1035,7 +1035,8 @@ describe("run", () => { 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}`); + // With the key it was given, or it reaches whatever key it finds next. + expect(error.message).toContain(`clerk migrate undo ${first!.id} --secret-key `); expect(created()).toEqual([]); }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 3d57c66bbb..bf164766e2 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -29,6 +29,7 @@ import { } from "../../lib/errors.ts"; import { resolveKeylessTarget } from "../../lib/keyless-target.ts"; import { quoteArg } from "../../lib/json-body.ts"; +import { carryFlags, withFlags } from "./lib/carry-flags.ts"; import { log } from "../../lib/log.ts"; import { NEXT_STEPS, printAgentNextSteps } from "../../lib/next-steps.ts"; import { confirm } from "../../lib/prompts.ts"; @@ -319,6 +320,8 @@ export function findResume( /** The key's stand-in ID, for a run recorded while Clerk could not name the instance. */ keyInstanceId?: string; }, + /** The import's runs-folder and target flags, for the command a refusal suggests. */ + carry: string[] = [], ): ResumeCase { const latest = listRuns(runsDir).find( (record) => @@ -365,7 +368,7 @@ export function findResume( 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.`, + `Finish it with \`${withFlags(`clerk migrate undo ${latest.id}`, carry)}\`, or pass --new-run to import into a new run.`, ); } // Undone in full, though the import run was never marked so. @@ -510,10 +513,7 @@ function commandFor(options: MigrateRunOptions, extra: string[]) { if (options.newRun) parts.push("--new-run"); if (options.requirePassword) parts.push("--require-password"); if (options.skipLegalChecks) parts.push("--skip-legal-checks"); - 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)); + parts.push(...carryFlags(options)); if (options.json) parts.push("--json"); return [...parts, ...extra].join(" "); } @@ -573,12 +573,16 @@ export async function run(rawOptions: MigrateRunOptions): Promise { const resume: ResumeCase = options.newRun ? { kind: "new" } - : findResume(runsDir, { - sha256, - source, - instanceId: target.instanceId, - keyInstanceId: keyInstanceId(secretKey), - }); + : findResume( + runsDir, + { + sha256, + source, + instanceId: target.instanceId, + keyInstanceId: keyInstanceId(secretKey), + }, + carryFlags(options), + ); if (resume.kind === "complete") { if (options.json) { @@ -778,7 +782,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { attachOnly.length === 0 ) { // Settled, so a continued run is finished rather than left interrupted. - if (continued) continueRun(runsDir, continued, plannedLines).finish(); + if (continued) continueRun(runsDir, continued, plannedLines, carryFlags(options)).finish(); if (options.json) preview({ nothingToImport: true }); else log.warn("No users left to import."); return; @@ -816,7 +820,7 @@ 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, plannedLines) + ? continueRun(runsDir, continued, plannedLines, carryFlags(options)) : startRun(runsDir, { kind: "import", target, @@ -919,8 +923,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { 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(record.id, carryFlags(options)) + : NEXT_STEPS.MIGRATE_DONE(record.id, carryFlags(options)); setNextSteps(steps); printAgentNextSteps(steps); }, diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 5a751b9707..dd37f4c8d4 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { quoteArg } from "../../lib/json-body.ts"; import { useCaptureLog } from "../../test/lib/stubs.ts"; import { startRun } from "./lib/run-store.ts"; import { runs } from "./runs.ts"; @@ -104,6 +105,16 @@ describe("runs ", () => { }); }); + // The suggested undo has to look in the same runs folder. + test("the next command keeps the runs folder", async () => { + const record = partialImport(); + + await runs(record.id, { runsDir, json: true }); + + const { next } = JSON.parse(captured.out) as { next: string[] }; + expect(next[0]).toContain(`clerk migrate undo ${record.id} --runs-dir ${quoteArg(runsDir)}`); + }); + test("an unknown ID is a usage error", async () => { await expect(runs("nope", { runsDir })).rejects.toThrow(/No run `nope`/); }); diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index 5c9a70d9f4..206fd41753 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -23,6 +23,7 @@ import { type RunState, type UserLine, } from "./lib/run-store.ts"; +import { carryFlags, withFlags } from "./lib/carry-flags.ts"; import { describeTarget } from "./lib/target.ts"; export type RunsOptions = { @@ -94,14 +95,21 @@ function errorBreakdown(lines: UserLine[]): { error: string; count: number }[] { return [...counts].map(([error, count]) => ({ error, count })).sort((a, b) => b.count - a.count); } -/** What to run after reading about this run. */ -export function nextCommands(record: RunRecord, state: RunState): string[] { +/** + * What to run after reading about this run. `carry` is the runs folder this + * listing read; the target flags the import used are not on record. + */ +export function nextCommands(record: RunRecord, state: RunState, carry: string[] = []): string[] { if (state === "running") return []; if (record.kind === "import" && state !== "undone") { - return [`Run \`clerk migrate undo ${record.id}\` to delete the users it created`]; + return [ + `Run \`${withFlags(`clerk migrate undo ${record.id}`, carry)}\` to delete the users it created`, + ]; } if (record.kind === "undo" && state !== "complete" && record.undoes) { - return [`Run \`clerk migrate undo ${record.undoes}\` to retry the users that failed`]; + return [ + `Run \`${withFlags(`clerk migrate undo ${record.undoes}`, carry)}\` to retry the users that failed`, + ]; } return []; } @@ -139,12 +147,12 @@ function printList(runsDir: string, records: RunRecord[]): void { log.info(dim(`${records.length} run${records.length === 1 ? "" : "s"}`)); } -function showJson(runsDir: string, record: RunRecord) { +function showJson(runsDir: string, record: RunRecord, carry: string[]) { const lines = [...latestUserLines(runsDir, record.id).values()]; return { runsDir, run: { ...record, state: runState(runsDir, record) }, - next: nextCommands(record, runState(runsDir, record)), + next: nextCommands(record, runState(runsDir, record), carry), errors: errorBreakdown(lines), failed: lines.filter((line) => line.status === "failed"), skipped: lines.filter((line) => line.status === "skipped"), @@ -164,7 +172,7 @@ function printUsers(title: string, lines: UserLine[], usersFile: string): void { } } -function printRun(runsDir: string, record: RunRecord): void { +function printRun(runsDir: string, record: RunRecord, carry: string[]): void { const state = runState(runsDir, record); const dir = runDir(runsDir, record.id); const usersFile = path.join(dir, "users.ndjson"); @@ -213,7 +221,7 @@ function printRun(runsDir: string, record: RunRecord): void { log.blank(); log.info(dim(`Every outcome: ${usersFile}`)); - for (const step of nextCommands(record, state)) log.info(` → ${step}`); + for (const step of nextCommands(record, state, carry)) log.info(` → ${step}`); } /** @@ -241,6 +249,7 @@ export async function runs(id: string | undefined, options: RunsOptions = {}): P } const record = withLiveCounts(runsDir, stored); - if (options.json) log.data(JSON.stringify(showJson(runsDir, record), null, 2)); - else printRun(runsDir, record); + const carry = carryFlags({ runsDir: options.runsDir }); + if (options.json) log.data(JSON.stringify(showJson(runsDir, record, carry), null, 2)); + else printRun(runsDir, record, carry); } diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index 17abf51f01..ad9d30aa5a 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -3,6 +3,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { EXIT_CODE, type CliError } from "../../lib/errors.ts"; +import { quoteArg } from "../../lib/json-body.ts"; +import { _resetInterruptState, abortInFlight, beginInterrupt } from "../../lib/signals.ts"; import { useCaptureLog } from "../../test/lib/stubs.ts"; import { continueRun, @@ -259,14 +261,16 @@ describe("--dry-run", () => { expect(listRuns(runsDir)).toHaveLength(1); }); - test("--json returns the target, the run and the preview", async () => { + // `run` is the undo's own run, as an import's is: null until one is written. + test("--json returns the target, the import, no run yet, and the preview", async () => { const record = importRun(); await undo(record.id, withDir({ dryRun: true, json: true })); expect(JSON.parse(captured.out)).toMatchObject({ target: { instanceId: "ins_1", keySource: "--secret-key" }, - run: { id: record.id }, + import: { id: record.id }, + run: null, preview: { toDelete: 2, signedInSince: 1, alreadyGone: 0, alreadyDeleted: 0 }, dryRun: true, }); @@ -305,6 +309,33 @@ describe("another undo during the preview", () => { }); }); +describe("stopped part-way", () => { + // The deletes still queued are rejected; the undo must not read as done. + test("a Ctrl-C during the deletes leaves the undo unfinished, as interrupted", async () => { + const record = importRun(); + const clerk = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const response = await clerk(input, init); + if (init?.method === "DELETE") { + beginInterrupt(); + abortInFlight(); + } + return response; + }) as typeof fetch; + try { + await expect(undo(record.id, withDir({ yes: true }))).rejects.toThrow(); + } finally { + _resetInterruptState(); + globalThis.fetch = clerk; + } + + const undoRun = listRuns(runsDir).find((r) => r.kind === "undo"); + expect(undoRun?.finishedAt).toBeUndefined(); + expect(fs.existsSync(path.join(runsDir, undoRun?.id ?? "", "lock"))).toBe(false); + expect(readRun(runsDir, record.id)?.status).not.toBe("undone"); + }); +}); + describe("users who signed in since the import", () => { test("--yes alone refuses to delete them, and deletes nothing", async () => { const record = importRun(); @@ -319,6 +350,20 @@ describe("users who signed in since the import", () => { expect(deletes()).toHaveLength(0); }); + // Without them the suggested command looks in the default runs folder, or + // reaches another instance, and refuses. + test("the suggested command keeps the runs folder and the key", async () => { + const record = importRun(); + + const error = (await undo(record.id, withDir({ yes: true, includeSignedIn: false })).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.examples?.[0]?.command).toBe( + `clerk migrate undo ${record.id} --yes --include-signed-in --secret-key --runs-dir ${quoteArg(runsDir)}`, + ); + }); + test("--include-signed-in deletes them too", async () => { const record = importRun(); @@ -492,6 +537,7 @@ describe("deleting", () => { await undo(record.id, withDir({ yes: true, json: true })); expect(JSON.parse(captured.out)).toMatchObject({ + import: { id: record.id, kind: "import" }, run: { kind: "undo", undoes: record.id, status: "complete" }, result: { deleted: 2, failed: 0, errors: [] }, }); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 74d927c494..92a4095702 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -20,10 +20,12 @@ import { bapiRequest } from "../../lib/bapi.ts"; import { bold, dim, green, red } from "../../lib/color.ts"; import { BapiError, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; +import { interruptedExitCode } from "../../lib/signals.ts"; import { confirm } from "../../lib/prompts.ts"; import { withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; import { isAgent, isHuman } from "../../mode.ts"; import { normalizeErrorMessage } from "./import-users.ts"; +import { carryFlags, withFlags } from "./lib/carry-flags.ts"; import { resolveLimits, type ResolvedLimits } from "./lib/instance.ts"; import { RateLimitExceededError, retryOn429 } from "./lib/retry.ts"; import { @@ -320,14 +322,17 @@ function printPreview(record: RunRecord, preview: UndoPreview): void { } } -/** The run header shared by every outcome, human and JSON alike. */ +/** + * The JSON every outcome prints. `import` is the run being undone; `run` is the + * undo's own run, null until one is written, as an import's is. + */ function jsonResult( target: ClerkTarget, record: RunRecord, preview: UndoPreview, extra: Record = {}, ) { - return { target, run: record, preview, ...extra }; + return { target, import: record, run: null, preview, ...extra }; } export async function undo(runId: string, options: UndoOptions = {}): Promise { @@ -371,7 +376,14 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 ? " --include-signed-in" : ""}`; + // The flags that found this run and instance, or the command looks elsewhere + // and refuses. + const command = [ + `clerk migrate undo ${record.id} --yes`, + ...(preview.signedInSince > 0 ? ["--include-signed-in"] : []), + ...carryFlags(options), + ...(options.json ? ["--json"] : []), + ].join(" "); const nothingLeft = present.length === 0 && gone.length === 0; if (options.dryRun) { @@ -450,7 +462,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise() }; + // A Ctrl-C rejects every delete still queued. Left unfinished, the undo + // reads as interrupted, as an import does. + if (interruptedExitCode() !== null) { + run.release(); + throwUserAbort(); + } undoRecord = run.finish(); if (undoRecord.status === "complete") { patchRun(runsDir, record.id, { status: "undone", undoneBy: undoRecord.id }); @@ -482,7 +500,8 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise [ - `Run \`clerk migrate runs ${runId}\` to see what happened to each user`, - `Run \`clerk migrate undo ${runId}\` to delete the users it created`, + // `carry` is the import's runs-folder and target flags, so the commands + // reach the same run and instance. + MIGRATE_DONE: (runId: string, carry: string[] = []) => [ + `Run \`${migrateCommand(`runs ${runId}`, carry, true)}\` to see what happened to each user`, + `Run \`${migrateCommand(`undo ${runId}`, carry)}\` 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_WITH_ERRORS: (runId: string, carry: string[] = []) => [ + `Run \`${migrateCommand(`runs ${runId}`, carry, true)}\` to see every user that failed and why`, + `Run \`${migrateCommand(`undo ${runId}`, carry)}\` to delete the users it created`, ], } as const; From 927ac90e46a61e11e8ecb82ff4e45bca471ab9f1 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 02:01:18 -0400 Subject: [PATCH 30/63] fix(migrate): report a continued run with nothing left as finished in --json, and say which undo outcomes print JSON A continued run with no users left is finished, but --json printed the record from before finish(), so a crashed run read as `running` beside a run.json that said `complete`. It prints the finished record now. The README said undo's --json prints its object whatever the outcome; a refusal before the preview (no such run, not an import, another instance's key) is an error like any command's. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 5 ++++- .../cli-core/src/commands/migrate/run.test.ts | 17 +++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 10 +++++++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 2dab5f0ae8..2303483328 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -540,7 +540,10 @@ every user is deleted. A partial undo exits 1, and running `undo` again retries the users that failed, in the same undo run. A Ctrl-C leaves the undo run `interrupted`, as it does an import, and running `undo` again carries on. -`--json` prints one object on stdout, whatever the outcome: +Once undo reaches its preview, `--json` prints one object on stdout, whatever +happens next. A refusal before that — no such run, a run that isn't an import, +or a key for another instance — is an error, as it is for every command. The +object: ```json { diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index fe023b4890..18397718e4 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -1005,6 +1005,23 @@ describe("run", () => { expect(readRun(runsDir(), first!.id)?.finishedAt).toBeDefined(); }); + // Automation reads the status from the JSON, not run.json. + test("--json reports a continued run with nothing left as finished", async () => { + await run(baseOptions); + const [first] = listRuns(runsDir()); + // A crash leaves the record as it was while running. + interrupt(first!.id, { status: "running" }); + requests = []; + captured.clear(); + + await run({ ...baseOptions, json: true }); + + expect(JSON.parse(captured.out)).toMatchObject({ + nothingToImport: true, + run: { id: first!.id, status: "complete", finishedAt: expect.any(String) }, + }); + }); + // "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 () => { diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index bf164766e2..39df1c2594 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -781,9 +781,13 @@ export async function run(rawOptions: MigrateRunOptions): Promise { withoutPassword.length === 0 && attachOnly.length === 0 ) { - // Settled, so a continued run is finished rather than left interrupted. - if (continued) continueRun(runsDir, continued, plannedLines, carryFlags(options)).finish(); - if (options.json) preview({ nothingToImport: true }); + // Settled, so a continued run is finished rather than left interrupted, + // and the JSON reports it as finished, as run.json now does. + const finished = continued + ? continueRun(runsDir, continued, plannedLines, carryFlags(options)).finish() + : undefined; + if (options.json) + preview({ nothingToImport: true, ...(finished ? { run: finished } : {}) }); else log.warn("No users left to import."); return; } From 1583996aed6405cd4e428a764adaf6c8e45c7d6f Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 11:47:18 -0400 Subject: [PATCH 31/63] fix(migrate): print undo's import as undone, recheck sign-ins before deleting, and stop prompting with no terminal From Michael's re-test of #515: - undo --json printed the import as read before the deletes; patchRun now returns what it wrote, and the result prints the import undone, with undoneBy. - Right before the deletes, undo looks up the users again: one who signed in after the preview is refused unless --include-signed-in. - Refusals at lock time (another undo or a re-import got there first) print the preview object with "changed": true under --json, so after the preview stdout always holds JSON. - Import's and undo's consent prompts check for a terminal (stdin, or /dev/tty behind it) as well as the mode, so CLERK_MODE=human with none exits 2 instead of spinning. The check lives in lib/terminal.ts. - `clerk migrate runs ` builds its undo hint from the record's target: --secret-key or --app/--instance where the import's key came from. - A changed file's refusal names the earlier run of the same source and instance that created the users it rejects, and offers undoing it. - The README says nothing writes to Clerk without consent; settling a run's own record is not a write to Clerk. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 19 +++-- .../src/commands/migrate/lib/checks.ts | 1 + .../src/commands/migrate/lib/run-store.ts | 12 ++- .../src/commands/migrate/lib/terminal.ts | 18 +++++ .../src/commands/migrate/readme.test.ts | 2 +- .../commands/migrate/run-interactive.test.ts | 15 ++++ .../cli-core/src/commands/migrate/run.test.ts | 24 ++++++ packages/cli-core/src/commands/migrate/run.ts | 45 ++++++++++- .../src/commands/migrate/runs.test.ts | 15 ++++ .../cli-core/src/commands/migrate/runs.ts | 11 ++- .../src/commands/migrate/undo.test.ts | 78 ++++++++++++++++++- .../cli-core/src/commands/migrate/undo.ts | 49 ++++++++++-- 12 files changed, 267 insertions(+), 22 deletions(-) create mode 100644 packages/cli-core/src/commands/migrate/lib/terminal.ts diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 2303483328..ea78fcc6cd 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -37,9 +37,11 @@ shows what every run did. Every subcommand takes `--runs-dir ` (or 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. +1. **Nothing writes to Clerk 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. A run with nothing left to send is still marked finished, which + touches only its own record. 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 @@ -231,7 +233,7 @@ 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 +**Nothing is written to Clerk 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. Printed commands shell-quote their @@ -522,7 +524,9 @@ without prompting. since: when any user has signed in after the import, `--yes` alone exits 2 and deletes nothing, naming the command with `--include-signed-in` that deletes them too. A yes at the prompt follows the preview's warning, so it needs no -flag. +flag. Right before the deletes, undo looks again: a user who signed in after +the preview was in no one's consent, so without `--include-signed-in` it exits +2 and deletes nothing. It refuses with exit 2, and deletes nothing, when: @@ -555,8 +559,9 @@ object: ``` `import` is the run being undone. `run` is the undo's own run, `null` until one -is written, as an import's is: a dry run adds `"dryRun": true`, and a refusal -for consent `"consent": "required"`. Once the deletes run, `run` is the undo run +is written, as an import's is: a dry run adds `"dryRun": true`, a refusal for +consent `"consent": "required"`, and a refusal because the run changed while +the preview waited `"changed": true`. Once the deletes run, `run` is the undo run and `result` holds `deleted`, `failed` and an `errors` breakdown. ### `clerk migrate help` diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index bb91d866a9..0983d0466c 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -654,6 +654,7 @@ async function findInstanceDuplicates( if (sourceId && sourceId !== own && !reasons.has(sourceId)) reasons.set(sourceId, reason); }; if (existing.external_id) { + // Matched by run.ts's ALREADY_IMPORTED, which names the run that created them. claim(byExternalId.get(existing.external_id), "already in the instance, with this source ID"); } for (const email of existing.email_addresses ?? []) { 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 5ddd2a60f6..6c1111b611 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -510,7 +510,15 @@ export function lockRun(runsDir: string, id: string): () => void { } /** Merges fields into a run this process is not writing, such as `undoneBy`. */ -export function patchRun(runsDir: string, id: string, patch: Partial): void { +/** Writes `patch` over the stored record, and returns what was written. */ +export function patchRun( + runsDir: string, + id: string, + patch: Partial, +): RunRecord | undefined { const record = readRun(runsDir, id); - if (record) writeRecord(runsDir, { ...record, ...patch }); + if (!record) return undefined; + const patched = { ...record, ...patch }; + writeRecord(runsDir, patched); + return patched; } diff --git a/packages/cli-core/src/commands/migrate/lib/terminal.ts b/packages/cli-core/src/commands/migrate/lib/terminal.ts new file mode 100644 index 0000000000..6df83dbadd --- /dev/null +++ b/packages/cli-core/src/commands/migrate/lib/terminal.ts @@ -0,0 +1,18 @@ +import fs from "node:fs"; + +/** + * Whether a prompt has a terminal to read: stdin, or `/dev/tty` behind a piped + * stdin. `CLERK_MODE=human` forces human mode without one, and a prompt with + * no terminal fails to open it and spins. An object so tests can swap it. + */ +export const terminal = { + available(): boolean { + if (process.stdin.isTTY) return true; + try { + fs.closeSync(fs.openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r")); + return true; + } catch { + return false; + } + }, +}; diff --git a/packages/cli-core/src/commands/migrate/readme.test.ts b/packages/cli-core/src/commands/migrate/readme.test.ts index 05faefc31f..88e5f6af44 100644 --- a/packages/cli-core/src/commands/migrate/readme.test.ts +++ b/packages/cli-core/src/commands/migrate/readme.test.ts @@ -167,7 +167,7 @@ describe("migrate README", () => { test("states the rules every command follows", () => { expect(README).toContain("## The rules"); for (const rule of [ - "Nothing writes without consent", + "Nothing writes to Clerk without consent", "`--dry-run` checks against the real instance", "State lives in one place", "Every command prints its target first", 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 c4c201526d..998056a66f 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -42,6 +42,9 @@ mock.module("../../lib/prompts.ts", () => ({ })); const { run } = await import("./run.ts"); +const { terminal } = await import("./lib/terminal.ts"); +// The prompts are stubs; whether this process has a terminal is a test of its own. +const realTerminal = terminal.available; const { UserAbortError } = await import("../../lib/errors.ts"); const { _setConfigDir } = await import("../../lib/config.ts"); @@ -72,6 +75,7 @@ beforeAll(() => { }); afterAll(() => { + terminal.available = realTerminal; if (originalMode === undefined) delete process.env.CLERK_MODE; else process.env.CLERK_MODE = originalMode; globalThis.fetch = originalFetch; @@ -82,6 +86,7 @@ afterAll(() => { }); beforeEach(() => { + terminal.available = () => true; requests = []; confirmAnswer = true; instanceSettings = undefined; @@ -142,6 +147,16 @@ describe("prompts for what was not passed", () => { }); }); +// `CLERK_MODE=human` with no terminal: the prompt would fail to open one and spin. +test("with no terminal, refuses for consent rather than prompting", async () => { + terminal.available = () => false; + + await expect(run(importOptions)).rejects.toThrow(/--yes/); + + expect(confirmMessages).toEqual([]); + expect(requests.some((r) => r.url.endsWith("/v1/users") && r.method === "POST")).toBe(false); +}); + describe("consent", () => { test("asks before writing, naming how many users", 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 18397718e4..f9caa3b956 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -1104,6 +1104,30 @@ describe("run", () => { expect(listRuns(runsDir())).toHaveLength(2); }); + // Fixing the row a partial run skipped changes the file, so its run meets + // the users the first one created: the refusal says which run that was. + test("a changed file's refusal names the earlier run that created the users it rejects", async () => { + stubClerk({ failing: new Set(["u2"]) }); + await run(baseOptions); + const [first] = listRuns(runsDir()); + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([export2[0], { ...export2[1], primary_email_address: "fixed@x.dev" }]), + ); + requests = []; + process.exitCode = 0; + stubClerk({ existing: [{ id: "user_u1", external_id: "u1" }] }); + + const error = (await run(baseOptions).catch((caught: unknown) => caught)) as CliError; + + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain(`1 of them was created by run ${first!.id}`); + expect(error.examples?.map((example) => example.command)).toContain( + `clerk migrate undo ${first!.id} --secret-key `, + ); + expect(created()).toEqual([]); + }); + test("a changed file is a new run", async () => { await run(baseOptions); fs.writeFileSync( diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 39df1c2594..ef17dbd4b1 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -30,6 +30,7 @@ import { import { resolveKeylessTarget } from "../../lib/keyless-target.ts"; import { quoteArg } from "../../lib/json-body.ts"; import { carryFlags, withFlags } from "./lib/carry-flags.ts"; +import { terminal } from "./lib/terminal.ts"; import { log } from "../../lib/log.ts"; import { NEXT_STEPS, printAgentNextSteps } from "../../lib/next-steps.ts"; import { confirm } from "../../lib/prompts.ts"; @@ -105,7 +106,7 @@ const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? /** Nobody can answer a prompt: an agent, a non-TTY run, or `--json`. */ function canPrompt(options: MigrateRunOptions): boolean { - return !options.json && isHuman() && !isAgent(); + return !options.json && isHuman() && !isAgent() && terminal.available(); } /** @@ -532,6 +533,36 @@ function rejectSummary(checks: ImportChecks, json = false): string { return ` ${why}${json ? " See `checks.rejects` for each user." : ""}`; } +/** How the checks name a user an earlier import already created here. */ +const ALREADY_IMPORTED = "already in the instance, with this source ID"; + +/** + * The earlier import run, of this source into this instance, that created the + * users the checks reject as already there, and how many of them: fixing a + * skipped row changes the file, and the changed file's run meets them. + */ +function earlierRunHolding( + runsDir: string, + source: string, + instanceId: string, + checks: ImportChecks, +): { id: string; count: number } | undefined { + const already = new Set( + checks.rejects.filter((reject) => reject.reason === ALREADY_IMPORTED).map((r) => r.sourceId), + ); + if (already.size === 0) return undefined; + for (const record of listRuns(runsDir)) { + if (record.kind !== "import" || record.source !== source) continue; + if (record.target.instanceId !== instanceId || runState(runsDir, record) === "undone") continue; + let count = 0; + for (const line of latestUserLines(runsDir, record.id).values()) { + if (line.status === "created" && already.has(line.sourceId)) count++; + } + if (count > 0) return { id: record.id, count }; + } + return undefined; +} + /** * Records the checks' rejects as skipped users, so the run says who they were. * The checks never reject an adopted user: it is created already. @@ -760,10 +791,17 @@ export async function run(rawOptions: MigrateRunOptions): Promise { if (refused) { if (options.json) preview({ refused: true }); + const earlier = earlierRunHolding(runsDir, source, target.instanceId, checks); + const undoEarlier = earlier + ? withFlags(`clerk migrate undo ${earlier.id}`, carryFlags(options)) + : undefined; throwUsageError( `${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.", + (earlier + ? `${earlier.count} of them ${earlier.count === 1 ? "was" : "were"} created by run ${earlier.id}, from an earlier version of this file. ` + + `Pass --allow-partial to import the rest into a new run, or undo run ${earlier.id} first (\`${undoEarlier}\`) to import the whole file again.` + : "Fix them, or pass --allow-partial to import the rest and record them as skipped."), undefined, undefined, [ @@ -771,6 +809,9 @@ export async function run(rawOptions: MigrateRunOptions): Promise { command: commandFor(options, ["--allow-partial", "--yes"]), description: "Import the users that pass", }, + ...(undoEarlier + ? [{ command: undoEarlier, description: `Undo run ${earlier!.id} first` }] + : []), ], ); } diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index dd37f4c8d4..300fe22437 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -105,6 +105,21 @@ describe("runs ", () => { }); }); + // The record says where the key came from, which a linked profile may not reach. + test.each([ + [{ keySource: "--secret-key" }, "--secret-key "], + [{ keySource: "--app", appId: "app_1" }, "--app app_1 --instance ins_1"], + ])("the next command keeps the import's target from %o", async (target, expected) => { + const record = partialImport(); + const file = path.join(runsDir, record.id, "run.json"); + fs.writeFileSync(file, JSON.stringify({ ...record, target: { ...record.target, ...target } })); + + await runs(record.id, { runsDir, json: true }); + + const { next } = JSON.parse(captured.out) as { next: string[] }; + expect(next[0]).toContain(`clerk migrate undo ${record.id} ${expected}`); + }); + // The suggested undo has to look in the same runs folder. test("the next command keeps the runs folder", async () => { const record = partialImport(); diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index 206fd41753..3d5c873347 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -97,7 +97,7 @@ function errorBreakdown(lines: UserLine[]): { error: string; count: number }[] { /** * What to run after reading about this run. `carry` is the runs folder this - * listing read; the target flags the import used are not on record. + * listing read, and the target flags the record says the import used. */ export function nextCommands(record: RunRecord, state: RunState, carry: string[] = []): string[] { if (state === "running") return []; @@ -249,7 +249,14 @@ export async function runs(id: string | undefined, options: RunsOptions = {}): P } const record = withLiveCounts(runsDir, stored); - const carry = carryFlags({ runsDir: options.runsDir }); + // The record says where the import's key came from: `--secret-key` or + // `--app` is the instance to reach again, which a linked profile may not be. + const { keySource, appId, instanceId } = record.target; + const carry = carryFlags({ + runsDir: options.runsDir, + ...(keySource === "--secret-key" ? { secretKey: "" } : {}), + ...(keySource === "--app" && appId ? { app: appId, instance: instanceId } : {}), + }); if (options.json) log.data(JSON.stringify(showJson(runsDir, record, carry), null, 2)); else printRun(runsDir, record, carry); } diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index ad9d30aa5a..54c36c51d6 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -17,6 +17,7 @@ import { type RunRecord, } from "./lib/run-store.ts"; import { keyInstanceId } from "./lib/target.ts"; +import { terminal } from "./lib/terminal.ts"; import { undo } from "./undo.ts"; const captured = useCaptureLog(); @@ -232,6 +233,58 @@ describe("refusals, all exit 2 and delete nothing", () => { expect(fs.existsSync(lockFile(runsDir, record.id))).toBe(false); }); + // After the preview, --json always prints its object. + test("--json prints the preview, marked changed, when it is out of date", async () => { + const record = importRun(); + duringPreview(() => { + const run = continueRun(runsDir, record); + run.append({ sourceId: "c", status: "created", clerkId: "user_c" }); + run.finish(); + }); + + await expect(undo(record.id, withDir({ yes: true, json: true }))).rejects.toThrow( + /preview is out of date/, + ); + expect(JSON.parse(captured.out)).toMatchObject({ import: { id: record.id }, changed: true }); + }); + + // --yes agreed to the preview's count, not to someone who signed in since. + test("refuses when a user signs in after the preview, without --include-signed-in", async () => { + instanceUsers.set("user_b", null); + const record = importRun(); + let lookups = 0; + const stubbed = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + if (url.searchParams.has("user_id") && lookups++ === 1) + instanceUsers.set("user_a", AFTER_IMPORT); + return stubbed(input, init); + }) as typeof fetch; + + await expect(undo(record.id, withDir({ yes: true, includeSignedIn: false }))).rejects.toThrow( + /1 more user the import created signed in after the preview/, + ); + expect(deletes()).toHaveLength(0); + expect(readRun(runsDir, record.id)?.status).not.toBe("undone"); + }); + + test("--include-signed-in deletes a user who signed in after the preview", async () => { + instanceUsers.set("user_b", null); + const record = importRun(); + let lookups = 0; + const stubbed = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + if (url.searchParams.has("user_id") && lookups++ === 0) + instanceUsers.set("user_a", AFTER_IMPORT); + return stubbed(input, init); + }) as typeof fetch; + + await undo(record.id, withDir({ yes: true, includeSignedIn: true })); + + expect(deletes()).toHaveLength(2); + }); + test("undone by another undo: refused, and its undo is kept", async () => { const record = importRun(); duringPreview(() => @@ -336,6 +389,25 @@ describe("stopped part-way", () => { }); }); +// `CLERK_MODE=human` with no terminal: the prompt would fail to open one and spin. +describe("forced human mode with no terminal", () => { + test("refuses for consent rather than prompting", async () => { + const record = importRun(); + const mode = process.env.CLERK_MODE; + const available = terminal.available; + process.env.CLERK_MODE = "human"; + terminal.available = () => false; + try { + await expect(undo(record.id, withDir())).rejects.toThrow(/needs consent/); + } finally { + terminal.available = available; + if (mode === undefined) delete process.env.CLERK_MODE; + else process.env.CLERK_MODE = mode; + } + expect(deletes()).toHaveLength(0); + }); +}); + describe("users who signed in since the import", () => { test("--yes alone refuses to delete them, and deletes nothing", async () => { const record = importRun(); @@ -536,8 +608,10 @@ describe("deleting", () => { await undo(record.id, withDir({ yes: true, json: true })); - expect(JSON.parse(captured.out)).toMatchObject({ - import: { id: record.id, kind: "import" }, + const out = JSON.parse(captured.out); + // The import as it is now: undone, by this undo. + expect(out.import).toMatchObject({ id: record.id, status: "undone", undoneBy: out.run.id }); + expect(out).toMatchObject({ run: { kind: "undo", undoes: record.id, status: "complete" }, result: { deleted: 2, failed: 0, errors: [] }, }); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 92a4095702..d7c46eaeae 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -18,7 +18,7 @@ import { bapiRequest } from "../../lib/bapi.ts"; import { bold, dim, green, red } from "../../lib/color.ts"; -import { BapiError, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; +import { BapiError, CliError, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { interruptedExitCode } from "../../lib/signals.ts"; import { confirm } from "../../lib/prompts.ts"; @@ -26,6 +26,7 @@ import { withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; import { isAgent, isHuman } from "../../mode.ts"; import { normalizeErrorMessage } from "./import-users.ts"; import { carryFlags, withFlags } from "./lib/carry-flags.ts"; +import { terminal } from "./lib/terminal.ts"; import { resolveLimits, type ResolvedLimits } from "./lib/instance.ts"; import { RateLimitExceededError, retryOn429 } from "./lib/retry.ts"; import { @@ -399,7 +400,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise = {}): never => { + if (options.json) { + log.data( + JSON.stringify(jsonResult(target, record, preview, { changed: true, ...extra }), null, 2), + ); + } + throwUsageError(message); + }; try { // Another undo that finished during the preview has already marked it. - readImportRun(runsDir, record.id); + try { + readImportRun(runsDir, record.id); + } catch (error) { + if (error instanceof CliError) refuseChanged(error.message); + throw error; + } // A re-import that continued the run during the preview created users // the preview never listed. Another undo that started or carried on an // undo run in the meantime changed which users are left to delete, and a @@ -460,12 +477,31 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 && !options.includeSignedIn) { + const now = await inspectUsers({ + users: present, + since: record.startedAt, + secretKey, + schedule, + }); + const more = now.signedInSince - preview.signedInSince; + if (more > 0) { + refuseChanged( + `${plural(more, "more user")} the import created signed in after the preview, and nothing agreed to delete them. Nothing was deleted.\n` + + `Run \`${withFlags(`clerk migrate undo ${record.id}`, carryFlags(options))}\` again to see them, or pass --include-signed-in to delete them too.`, + { signedInSincePreview: more }, + ); + } + } + run = openUndo ? continueRun(runsDir, openUndo) : startRun(runsDir, { kind: "undo", target, undoes: record.id, source: record.source }); @@ -491,7 +527,8 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise Date: Thu, 15 Oct 2026 12:27:45 -0400 Subject: [PATCH 32/63] fix(migrate): recheck undo's sign-ins by user, not count, and fail a lookup whose cursor stalls From Michael's second re-test of #515 (and CodeRabbit on the same line): - Undo's recheck compared how many users had signed in, so one the preview showed leaving as another signed in let the new one be deleted. It now refuses on any signed-in user the preview didn't list. A recheck that fails prints the preview object with "recheckFailed": true under --json. - lookupUsers threw nothing when the cursor stopped moving and returned what it had, so undo and a continue could read a stalled lookup as complete. It now throws. - checks.ts exports ALREADY_IMPORTED, which run.ts matches on, instead of the two keeping copies of the text. - The E.164 reject for a required phone no longer says the user "only has" phones, since it fires for users with an email too. - patchRun's stale doc comment is merged into the new one. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 15 +-- .../src/commands/migrate/lib/checks.test.ts | 2 +- .../src/commands/migrate/lib/checks.ts | 11 +- .../src/commands/migrate/lib/run-store.ts | 6 +- .../commands/migrate/lib/user-lookup.test.ts | 18 +-- .../src/commands/migrate/lib/user-lookup.ts | 9 +- packages/cli-core/src/commands/migrate/run.ts | 5 +- .../commands/migrate/undo-interactive.test.ts | 108 ++++++++++++++++++ .../src/commands/migrate/undo.test.ts | 24 ++++ .../cli-core/src/commands/migrate/undo.ts | 50 +++++--- 10 files changed, 204 insertions(+), 44 deletions(-) create mode 100644 packages/cli-core/src/commands/migrate/undo-interactive.test.ts diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index ea78fcc6cd..740ae09197 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -522,11 +522,11 @@ without prompting. `--yes` is consent to undo the import, not to delete accounts people have used since: when any user has signed in after the import, `--yes` alone exits 2 and -deletes nothing, naming the command with `--include-signed-in` that deletes -them too. A yes at the prompt follows the preview's warning, so it needs no -flag. Right before the deletes, undo looks again: a user who signed in after -the preview was in no one's consent, so without `--include-signed-in` it exits -2 and deletes nothing. +deletes nothing, naming the command with `--include-signed-in` that deletes them +too. A yes at the prompt follows the preview's warning, so it needs no flag. +Right before the deletes, undo looks again, by user rather than by count: a user +who signed in after the preview was in no one's consent, so without +`--include-signed-in` it exits 2 and deletes nothing. It refuses with exit 2, and deletes nothing, when: @@ -560,8 +560,9 @@ object: `import` is the run being undone. `run` is the undo's own run, `null` until one is written, as an import's is: a dry run adds `"dryRun": true`, a refusal for -consent `"consent": "required"`, and a refusal because the run changed while -the preview waited `"changed": true`. Once the deletes run, `run` is the undo run +consent `"consent": "required"`, a refusal because the run changed while +the preview waited `"changed": true`, and a failed second look at sign-ins +`"recheckFailed": true`. Once the deletes run, `run` is the undo run and `result` holds `deleted`, `failed` and an `errors` breakdown. ### `clerk migrate help` diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index 23492f82cf..ff1fa96f43 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -796,7 +796,7 @@ describe("rejects", () => { expect( await reasonsOf({ settings: required, users: [user("a", { phone: "415 555 0100" })] }), ).toEqual({ - a: "only has phones not in E.164 form, and this instance requires a phone number (numeric usernames are on)", + a: "its phones aren't in E.164 form, and this instance requires a phone number (numeric usernames are on)", }); }); diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 0983d0466c..d9fa7b2174 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -654,8 +654,7 @@ async function findInstanceDuplicates( if (sourceId && sourceId !== own && !reasons.has(sourceId)) reasons.set(sourceId, reason); }; if (existing.external_id) { - // Matched by run.ts's ALREADY_IMPORTED, which names the run that created them. - claim(byExternalId.get(existing.external_id), "already in the instance, with this source ID"); + claim(byExternalId.get(existing.external_id), ALREADY_IMPORTED); } for (const email of existing.email_addresses ?? []) { claim( @@ -974,6 +973,12 @@ function placeholderWarning(count: number): string[] { ]; } +/** + * The reason for a user an earlier import already created here. Exported so + * the import can name the run that created them. + */ +export const ALREADY_IMPORTED = "already in the instance, with this source ID"; + export async function checkImport(input: CheckInput): Promise { const rejects: Reject[] = input.failures.map((failure) => ({ sourceId: failure.userId, @@ -1022,7 +1027,7 @@ export async function checkImport(input: CheckInput): Promise { (phoned.dropped && missingRequiredIdentifier(user, input.settings) && !missingRequiredIdentifier(emailed, input.settings) - ? "only has phones not in E.164 form, and this instance requires a phone number (numeric usernames are on)" + ? "its phones aren't in E.164 form, and this instance requires a phone number (numeric usernames are on)" : undefined) ?? missingRequiredIdentifier(user, input.settings) ?? // Stripping the identifiers the instance has off can leave nothing to 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 6c1111b611..3492146bc3 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -509,8 +509,10 @@ export function lockRun(runsDir: string, id: string): () => void { return () => fs.rmSync(lockFile(runsDir, id), { force: true }); } -/** Merges fields into a run this process is not writing, such as `undoneBy`. */ -/** Writes `patch` over the stored record, and returns what was written. */ +/** + * Merges fields into a run this process is not writing, such as `undoneBy`, + * and returns what was written. + */ export function patchRun( runsDir: string, id: string, diff --git a/packages/cli-core/src/commands/migrate/lib/user-lookup.test.ts b/packages/cli-core/src/commands/migrate/lib/user-lookup.test.ts index 8f74286c4f..9fa040931f 100644 --- a/packages/cli-core/src/commands/migrate/lib/user-lookup.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/user-lookup.test.ts @@ -87,7 +87,8 @@ test("reads every page of a batch that matches more than one page", async () => expect(urls[0]?.searchParams.getAll("external_id")).toEqual(["+-dup"]); }); -test("stops paging when the cursor does not move", async () => { +// Stopping quietly would pass a short lookup off as complete. +test("fails, rather than looping or stopping short, when the cursor does not move", async () => { let requests = 0; // A server that ignores `starting_after` serves the same full page again. globalThis.fetch = (async () => { @@ -97,12 +98,13 @@ test("stops paging when the cursor does not move", async () => { ); }) as unknown as typeof fetch; - await lookupUsers({ - filter: "external_id", - values: ["a"], - secretKey: "sk_test_x", - schedule: createApiScheduler(1, 10_000), - }); - + await expect( + lookupUsers({ + filter: "external_id", + values: ["a"], + secretKey: "sk_test_x", + schedule: createApiScheduler(1, 10_000), + }), + ).rejects.toThrow(/same page of users twice/); expect(requests).toBe(2); }); diff --git a/packages/cli-core/src/commands/migrate/lib/user-lookup.ts b/packages/cli-core/src/commands/migrate/lib/user-lookup.ts index 5dd86c04b7..217072c16d 100644 --- a/packages/cli-core/src/commands/migrate/lib/user-lookup.ts +++ b/packages/cli-core/src/commands/migrate/lib/user-lookup.ts @@ -88,8 +88,13 @@ export async function lookupUsers(options: { found.push(...page); const last = page.at(-1)?.id; if (page.length < LOOKUP_BATCH || typeof last !== "string") break; - // A cursor that does not move would page forever. - if (params.get("starting_after") === last) break; + // A cursor that does not move would page forever, and stopping there + // would pass a short lookup off as complete. + if (params.get("starting_after") === last) { + throw new Error( + "Clerk returned the same page of users twice, so the lookup can't finish", + ); + } params.set("starting_after", last); } progress.done++; diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index ef17dbd4b1..f1cce12432 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -38,7 +38,7 @@ 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"; -import { checkImport, type ImportChecks } from "./lib/checks.ts"; +import { ALREADY_IMPORTED, checkImport, type ImportChecks } from "./lib/checks.ts"; import { fetchInstanceSettings, fetchUserCount } from "./lib/clerk-config.ts"; import { DEV_USER_LIMIT, resolveLimits, type InstanceType } from "./lib/instance.ts"; import { @@ -533,9 +533,6 @@ function rejectSummary(checks: ImportChecks, json = false): string { return ` ${why}${json ? " See `checks.rejects` for each user." : ""}`; } -/** How the checks name a user an earlier import already created here. */ -const ALREADY_IMPORTED = "already in the instance, with this source ID"; - /** * The earlier import run, of this source into this instance, that created the * users the checks reject as already there, and how many of them: fixing a diff --git a/packages/cli-core/src/commands/migrate/undo-interactive.test.ts b/packages/cli-core/src/commands/migrate/undo-interactive.test.ts new file mode 100644 index 0000000000..09e4c690b0 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/undo-interactive.test.ts @@ -0,0 +1,108 @@ +/** + * `clerk migrate undo` with a human at the prompt. + * + * Its own file because `mock.module` replaces `prompts.ts` for the whole + * process, and `undo.test.ts` runs non-interactively. + */ +import { afterAll, afterEach, beforeAll, beforeEach, expect, mock, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +let confirmed: () => void = () => {}; +mock.module("../../lib/prompts.ts", () => ({ + confirm: async () => { + confirmed(); + return true; + }, +})); + +const { startRun } = await import("./lib/run-store.ts"); +const { terminal } = await import("./lib/terminal.ts"); +const { undo } = await import("./undo.ts"); + +const IMPORT_STARTED = "2026-09-01T00:00:00.000Z"; +const AFTER_IMPORT = Date.parse("2026-09-02T00:00:00.000Z"); + +let runsDir: string; +let originalFetch: typeof globalThis.fetch; +let originalMode: string | undefined; +const realTerminal = terminal.available; +/** Clerk IDs the stubbed instance holds, with each one's last sign-in. */ +let instanceUsers: Map; +let deletes: string[]; + +beforeAll(() => { + originalFetch = globalThis.fetch; + originalMode = process.env.CLERK_MODE; +}); + +afterAll(() => { + terminal.available = realTerminal; + if (originalMode === undefined) delete process.env.CLERK_MODE; + else process.env.CLERK_MODE = originalMode; +}); + +beforeEach(() => { + process.env.CLERK_MODE = "human"; + terminal.available = () => true; + runsDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-undo-human-"))); + deletes = []; + confirmed = () => {}; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + 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 (method === "GET" && url.pathname === "/v1/users") { + return Response.json( + url.searchParams + .getAll("user_id") + .filter((id) => instanceUsers.has(id)) + .map((id) => ({ id, last_sign_in_at: instanceUsers.get(id) })), + ); + } + if (method === "DELETE") { + const id = url.pathname.split("/").pop() as string; + deletes.push(id); + instanceUsers.delete(id); + return Response.json({ id, deleted: true }); + } + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + process.exitCode = 0; + fs.rmSync(runsDir, { recursive: true, force: true }); +}); + +// The prompt warned about user_a. While it waited, user_a was deleted +// elsewhere and user_b signed in: one signed in either way, so a count would +// match, and user_b would be deleted with nobody agreeing to it. +test("refuses a user who signed in while the prompt waited, though the count is the same", async () => { + instanceUsers = new Map([ + ["user_a", AFTER_IMPORT], + ["user_b", null], + ]); + const run = startRun(runsDir, { + kind: "import", + target: { instanceId: "ins_1", env: "development" }, + source: "clerk", + }); + run.update({ startedAt: IMPORT_STARTED }); + run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + run.append({ sourceId: "b", status: "created", clerkId: "user_b" }); + const record = run.finish(); + confirmed = () => { + instanceUsers.delete("user_a"); + instanceUsers.set("user_b", AFTER_IMPORT); + }; + + await expect(undo(record.id, { secretKey: "sk_test_x", runsDir })).rejects.toThrow( + /1 more user the import created signed in after the preview/, + ); + expect(deletes).toEqual([]); +}); diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index 54c36c51d6..18672b2790 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -268,6 +268,30 @@ describe("refusals, all exit 2 and delete nothing", () => { expect(readRun(runsDir, record.id)?.status).not.toBe("undone"); }); + // Past the preview, --json still prints its object, whatever happens next. + test("--json prints the preview, marked, when the recheck itself fails", async () => { + instanceUsers.set("user_b", null); + const record = importRun(); + let lookups = 0; + const stubbed = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + if (url.searchParams.has("user_id") && lookups++ === 1) { + return Response.json({ errors: [{ code: "boom", message: "boom" }] }, { status: 500 }); + } + return stubbed(input, init); + }) as typeof fetch; + + await expect( + undo(record.id, withDir({ yes: true, includeSignedIn: false, json: true })), + ).rejects.toThrow(); + expect(JSON.parse(captured.out)).toMatchObject({ + import: { id: record.id }, + recheckFailed: true, + }); + expect(deletes()).toHaveLength(0); + }); + test("--include-signed-in deletes a user who signed in after the preview", async () => { instanceUsers.set("user_b", null); const record = importRun(); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index d7c46eaeae..2bc77bdc34 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -186,7 +186,13 @@ async function inspectUsers(options: { secretKey: string; schedule: ApiScheduler; spinner?: SpinnerControls; -}): Promise<{ present: UndoUser[]; gone: UndoUser[]; signedInSince: number }> { +}): Promise<{ + present: UndoUser[]; + gone: UndoUser[]; + signedInSince: number; + /** The Clerk IDs of the users who signed in since the import. */ + signedIn: string[]; +}> { const found = await lookupUsers({ filter: "user_id", values: options.users.map((user) => user.clerkId), @@ -200,12 +206,14 @@ async function inspectUsers(options: { const since = Date.parse(options.since); const present = options.users.filter((user) => byId.has(user.clerkId)); const gone = options.users.filter((user) => !byId.has(user.clerkId)); - const signedInSince = present.filter((user) => { - const lastSignIn = byId.get(user.clerkId)?.last_sign_in_at; - return typeof lastSignIn === "number" && lastSignIn > since; - }).length; - - return { present, gone, signedInSince }; + const signedIn = present + .filter((user) => { + const lastSignIn = byId.get(user.clerkId)?.last_sign_in_at; + return typeof lastSignIn === "number" && lastSignIn > since; + }) + .map((user) => user.clerkId); + + return { present, gone, signedInSince: signedIn.length, signedIn }; } export type UndoSummary = { @@ -361,12 +369,12 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 ? await withSpinner("Checking the imported users...", async (spinner) => inspectUsers({ users, since: record.startedAt, secretKey, schedule, spinner }), ) - : { present: [], gone: [], signedInSince: 0 }; + : { present: [], gone: [], signedInSince: 0, signedIn: [] }; const preview: UndoPreview = { toDelete: present.length, @@ -484,15 +492,23 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 && !options.includeSignedIn) { - const now = await inspectUsers({ - users: present, - since: record.startedAt, - secretKey, - schedule, - }); - const more = now.signedInSince - preview.signedInSince; + let now: Awaited>; + try { + now = await inspectUsers({ users: present, since: record.startedAt, secretKey, schedule }); + } catch (error) { + // Past the preview, --json still prints its object. + if (options.json) { + log.data( + JSON.stringify(jsonResult(target, record, preview, { recheckFailed: true }), null, 2), + ); + } + throw error; + } + const seen = new Set(signedIn); + const more = now.signedIn.filter((clerkId) => !seen.has(clerkId)).length; if (more > 0) { refuseChanged( `${plural(more, "more user")} the import created signed in after the preview, and nothing agreed to delete them. Nothing was deleted.\n` + From 15726052c6d0de2483da6eaa55d0c8b516285081 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 13:25:41 -0400 Subject: [PATCH 33/63] fix(migrate): print undo's preview under --json when another process holds the run The import run's lock, and writing the runs folder, came before every JSON handler after the preview, so a run held by another live process refused with nothing on stdout. Both now print the preview object first: a held run as `"changed": true`, like the other lock-time refusals, and a runs folder that can't be written as `"failed": true`. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 5 ++- .../src/commands/migrate/undo.test.ts | 12 ++++++ .../cli-core/src/commands/migrate/undo.ts | 39 +++++++++++++------ 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 740ae09197..af7f854145 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -561,8 +561,9 @@ object: `import` is the run being undone. `run` is the undo's own run, `null` until one is written, as an import's is: a dry run adds `"dryRun": true`, a refusal for consent `"consent": "required"`, a refusal because the run changed while -the preview waited `"changed": true`, and a failed second look at sign-ins -`"recheckFailed": true`. Once the deletes run, `run` is the undo run +the preview waited, or another process holds it, `"changed": true`, a failed +second look at sign-ins `"recheckFailed": true`, and a runs folder that can't +be written `"failed": true`. Once the deletes run, `run` is the undo run and `result` holds `deleted`, `failed` and an `errors` breakdown. ### `clerk migrate help` diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index 18672b2790..e5373a00e6 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -207,6 +207,18 @@ describe("refusals, all exit 2 and delete nothing", () => { }) as typeof fetch; }; + // After the preview, --json always prints its object. + test("--json prints the preview, marked changed, when another process holds the run", async () => { + const record = importRun(); + duringPreview(() => fs.writeFileSync(lockFile(runsDir, record.id), String(process.ppid))); + + await expect(undo(record.id, withDir({ yes: true, json: true }))).rejects.toThrow( + /in use by another process/, + ); + expect(JSON.parse(captured.out)).toMatchObject({ import: { id: record.id }, changed: true }); + expect(deletes()).toHaveLength(0); + }); + test("still running: refused by the import run's lock", async () => { const record = importRun(); duringPreview(() => fs.writeFileSync(lockFile(runsDir, record.id), String(process.ppid))); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 2bc77bdc34..dbfc3a674a 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -447,26 +447,41 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise) => { + if (options.json) { + log.data(JSON.stringify(jsonResult(target, record, preview, extra), null, 2)); + } + }; + const refuseChanged = (message: string, extra: Record = {}): never => { + printJsonPreview({ changed: true, ...extra }); + throwUsageError(message); + }; + // Gitignored only now, once there is consent to write a run. - await resolveRunsDir(options.runsDir, { write: true }); + try { + await resolveRunsDir(options.runsDir, { write: true }); + } catch (error) { + printJsonPreview({ failed: true }); + throw error; + } // Held until the import is marked undone, so a re-import cannot continue // the run while its users are deleted and overwrite the `undone` mark. - const releaseImport = lockRun(runsDir, record.id); + let releaseImport: () => void; + try { + releaseImport = lockRun(runsDir, record.id); + } catch (error) { + if (error instanceof CliError) refuseChanged(error.message); + printJsonPreview({ failed: true }); + throw error; + } let run: Run; let summary: UndoSummary; let undoRecord: RunRecord; let importRecord = record; - // After the preview, --json always prints its object: a refusal here - // carries `changed`, and the reason goes to stderr as for any error. - const refuseChanged = (message: string, extra: Record = {}): never => { - if (options.json) { - log.data( - JSON.stringify(jsonResult(target, record, preview, { changed: true, ...extra }), null, 2), - ); - } - throwUsageError(message); - }; try { // Another undo that finished during the preview has already marked it. try { From 9614c1eb37acb63bf56958a463544a9d3f549dca Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 13:55:44 -0400 Subject: [PATCH 34/63] fix(migrate): print undo's preview once for anything that stops it after the preview Creating the undo run, appending to it and finishing it could throw after the preview with nothing on stdout, a read-only runs folder for one. Everything after the preview now runs inside one handler that prints the preview object once, unless a refusal or the result already did: marked `writeFailed` for a file-system error, `stopped` for anything else. The marker was `failed`, beside `result.failed`, which is a count. The README lists the markers one per line. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 18 +- .../src/commands/migrate/undo.test.ts | 21 ++ .../cli-core/src/commands/migrate/undo.ts | 250 +++++++++--------- 3 files changed, 164 insertions(+), 125 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index af7f854145..6a207f6ab2 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -559,12 +559,18 @@ object: ``` `import` is the run being undone. `run` is the undo's own run, `null` until one -is written, as an import's is: a dry run adds `"dryRun": true`, a refusal for -consent `"consent": "required"`, a refusal because the run changed while -the preview waited, or another process holds it, `"changed": true`, a failed -second look at sign-ins `"recheckFailed": true`, and a runs folder that can't -be written `"failed": true`. Once the deletes run, `run` is the undo run -and `result` holds `deleted`, `failed` and an `errors` breakdown. +is written, as an import's is. Once the deletes run, `run` is the undo run, and +`result` holds `deleted`, `failed` and an `errors` breakdown. When undo stops +before that, the object says why: + +- `"dryRun": true`: a dry run. +- `"consent": "required"`: refused for consent. +- `"changed": true`: refused because the run changed while the preview waited, + or another process holds it. +- `"recheckFailed": true`: the second look at sign-ins, right before the + deletes, failed. +- `"writeFailed": true`: the file system refused a write to the runs folder. +- `"stopped": true`: anything else stopped it, such as a Ctrl-C. ### `clerk migrate help` diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index e5373a00e6..f666b06d34 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -207,6 +207,27 @@ describe("refusals, all exit 2 and delete nothing", () => { }) as typeof fetch; }; + // The run's own folder takes the lock, but the undo run can't be created + // beside it. `--runs-dir` is never written to by resolveRunsDir. + test.skipIf(process.platform === "win32")( + "--json prints the preview, marked writeFailed, when the runs folder can't be written", + async () => { + const record = importRun(); + fs.chmodSync(runsDir, 0o555); + try { + await expect(undo(record.id, withDir({ yes: true, json: true }))).rejects.toThrow(); + } finally { + fs.chmodSync(runsDir, 0o755); + } + expect(JSON.parse(captured.out)).toMatchObject({ + import: { id: record.id }, + run: null, + writeFailed: true, + }); + expect(deletes()).toHaveLength(0); + }, + ); + // After the preview, --json always prints its object. test("--json prints the preview, marked changed, when another process holds the run", async () => { const record = importRun(); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index dbfc3a674a..9909ce658a 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -447,156 +447,168 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise) => { - if (options.json) { - log.data(JSON.stringify(jsonResult(target, record, preview, extra), null, 2)); - } + if (!options.json || printed) return; + printed = true; + log.data(JSON.stringify(jsonResult(target, record, preview, extra), null, 2)); }; const refuseChanged = (message: string, extra: Record = {}): never => { printJsonPreview({ changed: true, ...extra }); throwUsageError(message); }; - // Gitignored only now, once there is consent to write a run. try { - await resolveRunsDir(options.runsDir, { write: true }); + await deleteAfterPreview(); } catch (error) { - printJsonPreview({ failed: true }); + printJsonPreview(isFileSystemError(error) ? { writeFailed: true } : { stopped: true }); throw error; } - // Held until the import is marked undone, so a re-import cannot continue - // the run while its users are deleted and overwrite the `undone` mark. - let releaseImport: () => void; - try { - releaseImport = lockRun(runsDir, record.id); - } catch (error) { - if (error instanceof CliError) refuseChanged(error.message); - printJsonPreview({ failed: true }); - throw error; - } - let run: Run; - let summary: UndoSummary; - let undoRecord: RunRecord; - let importRecord = record; - try { - // Another undo that finished during the preview has already marked it. + async function deleteAfterPreview(): Promise { + // Gitignored only now, once there is consent to write a run. + await resolveRunsDir(options.runsDir, { write: true }); + + // Held until the import is marked undone, so a re-import cannot continue + // the run while its users are deleted and overwrite the `undone` mark. + let releaseImport: () => void; try { - readImportRun(runsDir, record.id); + releaseImport = lockRun(runsDir, record.id); } catch (error) { if (error instanceof CliError) refuseChanged(error.message); throw error; } - // A re-import that continued the run during the preview created users - // the preview never listed. Another undo that started or carried on an - // undo run in the meantime changed which users are left to delete, and a - // second undo run would split the import's deletes between the two. - const openNow = findOpenUndo(runsDir, record.id); - if ( - readUserLines(runsDir, record.id).length !== importLines || - openNow?.id !== openUndo?.id || - (openUndo !== undefined && readUserLines(runsDir, openUndo.id).length !== undoLines) - ) { - refuseChanged( - `Run ${record.id} changed while this undo was waiting, so the preview is out of date. ` + - `Nothing was deleted. Run \`${withFlags(`clerk migrate undo ${record.id}`, carryFlags(options))}\` again.`, - ); - } - - // A user who signed in after the preview is one nobody agreed to delete: - // --yes covered the preview's users, and so did a yes at the prompt. By - // ID, not count: one the preview showed can leave as another signs in. - if (present.length > 0 && !options.includeSignedIn) { - let now: Awaited>; + let run: Run; + let summary: UndoSummary; + let undoRecord: RunRecord; + let importRecord = record; + try { + // Another undo that finished during the preview has already marked it. try { - now = await inspectUsers({ users: present, since: record.startedAt, secretKey, schedule }); + readImportRun(runsDir, record.id); } catch (error) { - // Past the preview, --json still prints its object. - if (options.json) { - log.data( - JSON.stringify(jsonResult(target, record, preview, { recheckFailed: true }), null, 2), - ); - } + if (error instanceof CliError) refuseChanged(error.message); throw error; } - const seen = new Set(signedIn); - const more = now.signedIn.filter((clerkId) => !seen.has(clerkId)).length; - if (more > 0) { + // A re-import that continued the run during the preview created users + // the preview never listed. Another undo that started or carried on an + // undo run in the meantime changed which users are left to delete, and a + // second undo run would split the import's deletes between the two. + const openNow = findOpenUndo(runsDir, record.id); + if ( + readUserLines(runsDir, record.id).length !== importLines || + openNow?.id !== openUndo?.id || + (openUndo !== undefined && readUserLines(runsDir, openUndo.id).length !== undoLines) + ) { refuseChanged( - `${plural(more, "more user")} the import created signed in after the preview, and nothing agreed to delete them. Nothing was deleted.\n` + - `Run \`${withFlags(`clerk migrate undo ${record.id}`, carryFlags(options))}\` again to see them, or pass --include-signed-in to delete them too.`, - { signedInSincePreview: more }, + `Run ${record.id} changed while this undo was waiting, so the preview is out of date. ` + + `Nothing was deleted. Run \`${withFlags(`clerk migrate undo ${record.id}`, carryFlags(options))}\` again.`, ); } - } - run = openUndo - ? continueRun(runsDir, openUndo) - : startRun(runsDir, { kind: "undo", target, undoes: record.id, source: record.source }); + // A user who signed in after the preview is one nobody agreed to delete: + // --yes covered the preview's users, and so did a yes at the prompt. By + // ID, not count: one the preview showed can leave as another signs in. + if (present.length > 0 && !options.includeSignedIn) { + let now: Awaited>; + try { + now = await inspectUsers({ + users: present, + since: record.startedAt, + secretKey, + schedule, + }); + } catch (error) { + printJsonPreview({ recheckFailed: true }); + throw error; + } + const seen = new Set(signedIn); + const more = now.signedIn.filter((clerkId) => !seen.has(clerkId)).length; + if (more > 0) { + refuseChanged( + `${plural(more, "more user")} the import created signed in after the preview, and nothing agreed to delete them. Nothing was deleted.\n` + + `Run \`${withFlags(`clerk migrate undo ${record.id}`, carryFlags(options))}\` again to see them, or pass --include-signed-in to delete them too.`, + { signedInSincePreview: more }, + ); + } + } - // Users the import created that are no longer there: the undo's goal for - // them is already met, so they count as deleted without another request. - for (const user of gone) { - run.append({ ...user, status: "deleted", reason: "not found in the instance" }); - } + run = openUndo + ? continueRun(runsDir, openUndo) + : startRun(runsDir, { kind: "undo", target, undoes: record.id, source: record.source }); - summary = - present.length > 0 - ? await withProgress({ total: present.length, verb: "deleted" }, async (progress) => - deleteUsers({ users: present, secretKey, limits, run, progress }), - ) - : { deleted: 0, failed: 0, errorBreakdown: new Map() }; - - // A Ctrl-C rejects every delete still queued. Left unfinished, the undo - // reads as interrupted, as an import does. - if (interruptedExitCode() !== null) { - run.release(); - throwUserAbort(); - } - undoRecord = run.finish(); - if (undoRecord.status === "complete") { - importRecord = - patchRun(runsDir, record.id, { status: "undone", undoneBy: undoRecord.id }) ?? record; - } - } finally { - releaseImport(); - } + // Users the import created that are no longer there: the undo's goal for + // them is already met, so they count as deleted without another request. + for (const user of gone) { + run.append({ ...user, status: "deleted", reason: "not found in the instance" }); + } - if (options.json) { - log.data( - JSON.stringify( - jsonResult(target, importRecord, preview, { - run: undoRecord, - result: { - deleted: summary.deleted, - failed: summary.failed, - errors: [...summary.errorBreakdown].map(([error, count]) => ({ error, count })), - }, - }), - null, - 2, - ), - ); - } else { - log.blank(); - log.info(`${bold("Deleted:")} ${green(String(summary.deleted))}`); - log.info(`${bold("Failed:")} ${red(String(summary.failed))}`); - for (const [error, count] of summary.errorBreakdown) { - log.info(` ${plural(count, "user")}: ${error}`); + summary = + present.length > 0 + ? await withProgress({ total: present.length, verb: "deleted" }, async (progress) => + deleteUsers({ users: present, secretKey, limits, run, progress }), + ) + : { deleted: 0, failed: 0, errorBreakdown: new Map() }; + + // A Ctrl-C rejects every delete still queued. Left unfinished, the undo + // reads as interrupted, as an import does. + if (interruptedExitCode() !== null) { + run.release(); + throwUserAbort(); + } + undoRecord = run.finish(); + if (undoRecord.status === "complete") { + importRecord = + patchRun(runsDir, record.id, { status: "undone", undoneBy: undoRecord.id }) ?? record; + } + } finally { + releaseImport(); } - log.blank(); - log.info(dim(`Undo run ${undoRecord.id}: ${run.dir}`)); - if (undoRecord.status === "complete") { - log.success(`Import run ${record.id} is undone.`); - } else { - log.info( - `Run \`${withFlags(`clerk migrate undo ${record.id}`, carryFlags(options))}\` again to retry the users that failed.`, + + if (options.json) { + printed = true; + log.data( + JSON.stringify( + jsonResult(target, importRecord, preview, { + run: undoRecord, + result: { + deleted: summary.deleted, + failed: summary.failed, + errors: [...summary.errorBreakdown].map(([error, count]) => ({ error, count })), + }, + }), + null, + 2, + ), ); + } else { + log.blank(); + log.info(`${bold("Deleted:")} ${green(String(summary.deleted))}`); + log.info(`${bold("Failed:")} ${red(String(summary.failed))}`); + for (const [error, count] of summary.errorBreakdown) { + log.info(` ${plural(count, "user")}: ${error}`); + } + log.blank(); + log.info(dim(`Undo run ${undoRecord.id}: ${run.dir}`)); + if (undoRecord.status === "complete") { + log.success(`Import run ${record.id} is undone.`); + } else { + log.info( + `Run \`${withFlags(`clerk migrate undo ${record.id}`, carryFlags(options))}\` again to retry the users that failed.`, + ); + } } + + if (undoRecord.status !== "complete") process.exitCode = 1; } +} - if (undoRecord.status !== "complete") process.exitCode = 1; +/** An error the file system raised (EACCES, ENOSPC, …), rather than the API or the user. */ +function isFileSystemError(error: unknown): boolean { + const { code, errno } = (error ?? {}) as { code?: unknown; errno?: unknown }; + return typeof code === "string" && typeof errno === "number"; } From 9e63b939a8857ada8f7e901d64e43c596fc8897a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 14:17:20 -0400 Subject: [PATCH 35/63] fix(migrate): raise a stalled lookup as a CliError, log the run store's lock and patch, and spy on the terminal check in tests From the Oct 15 slice 2 review's standards pass: - The stalled-cursor failure reaches the user, so it is a CliError (errors.md), not a plain Error. - Taking, reclaiming and releasing a run's lock, and patching a record, get a log.debug line at the file I/O boundary (debug-logging.md). - The tests replace terminal.available through spyOn and restore it (testing.md), rather than assigning to it. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/run-store.ts | 9 ++++++++- .../src/commands/migrate/lib/user-lookup.ts | 3 ++- .../commands/migrate/run-interactive.test.ts | 20 ++++++++++++++----- .../commands/migrate/undo-interactive.test.ts | 8 ++++---- .../src/commands/migrate/undo.test.ts | 16 +++++++++++---- 5 files changed, 41 insertions(+), 15 deletions(-) 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 3492146bc3..f6752429e6 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -228,15 +228,18 @@ function acquireLock(runsDir: string, id: string): void { try { take(); + log.debug(`migrate: locked run ${id} (${file})`); return; } catch (error) { if (!isExists(error)) throw error; } const holder = liveLockPid(runsDir, id); if (holder !== undefined) refuse(holder); + log.debug(`migrate: removing a stale lock on run ${id} (${file})`); fs.rmSync(file, { force: true }); try { take(); + log.debug(`migrate: locked run ${id} (${file})`); } catch (error) { // Another process took the stale lock between the remove and the write. if (isExists(error)) refuse(liveLockPid(runsDir, id)); @@ -506,7 +509,10 @@ export function continueRun( */ export function lockRun(runsDir: string, id: string): () => void { acquireLock(runsDir, id); - return () => fs.rmSync(lockFile(runsDir, id), { force: true }); + return () => { + log.debug(`migrate: released run ${id}`); + fs.rmSync(lockFile(runsDir, id), { force: true }); + }; } /** @@ -521,6 +527,7 @@ export function patchRun( const record = readRun(runsDir, id); if (!record) return undefined; const patched = { ...record, ...patch }; + log.debug(`migrate: patching run ${id}: ${Object.keys(patch).join(", ")}`); writeRecord(runsDir, patched); return patched; } diff --git a/packages/cli-core/src/commands/migrate/lib/user-lookup.ts b/packages/cli-core/src/commands/migrate/lib/user-lookup.ts index 217072c16d..b94e8bab34 100644 --- a/packages/cli-core/src/commands/migrate/lib/user-lookup.ts +++ b/packages/cli-core/src/commands/migrate/lib/user-lookup.ts @@ -10,6 +10,7 @@ */ import { bapiRequest } from "../../../lib/bapi.ts"; +import { CliError } from "../../../lib/errors.ts"; import type { SpinnerControls } from "../../../lib/spinner.ts"; import { retryOn429 } from "./retry.ts"; import type { ApiScheduler } from "./scheduler.ts"; @@ -91,7 +92,7 @@ export async function lookupUsers(options: { // A cursor that does not move would page forever, and stopping there // would pass a short lookup off as complete. if (params.get("starting_after") === last) { - throw new Error( + throw new CliError( "Clerk returned the same page of users twice, so the lookup can't finish", ); } 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 998056a66f..1f45c7649b 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -8,7 +8,17 @@ * `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 { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, + spyOn, +} from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -44,7 +54,7 @@ mock.module("../../lib/prompts.ts", () => ({ const { run } = await import("./run.ts"); const { terminal } = await import("./lib/terminal.ts"); // The prompts are stubs; whether this process has a terminal is a test of its own. -const realTerminal = terminal.available; +const terminalAvailable = spyOn(terminal, "available"); const { UserAbortError } = await import("../../lib/errors.ts"); const { _setConfigDir } = await import("../../lib/config.ts"); @@ -75,7 +85,7 @@ beforeAll(() => { }); afterAll(() => { - terminal.available = realTerminal; + terminalAvailable.mockRestore(); if (originalMode === undefined) delete process.env.CLERK_MODE; else process.env.CLERK_MODE = originalMode; globalThis.fetch = originalFetch; @@ -86,7 +96,7 @@ afterAll(() => { }); beforeEach(() => { - terminal.available = () => true; + terminalAvailable.mockReturnValue(true); requests = []; confirmAnswer = true; instanceSettings = undefined; @@ -149,7 +159,7 @@ describe("prompts for what was not passed", () => { // `CLERK_MODE=human` with no terminal: the prompt would fail to open one and spin. test("with no terminal, refuses for consent rather than prompting", async () => { - terminal.available = () => false; + terminalAvailable.mockReturnValue(false); await expect(run(importOptions)).rejects.toThrow(/--yes/); diff --git a/packages/cli-core/src/commands/migrate/undo-interactive.test.ts b/packages/cli-core/src/commands/migrate/undo-interactive.test.ts index 09e4c690b0..6029a3d16c 100644 --- a/packages/cli-core/src/commands/migrate/undo-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/undo-interactive.test.ts @@ -4,7 +4,7 @@ * Its own file because `mock.module` replaces `prompts.ts` for the whole * process, and `undo.test.ts` runs non-interactively. */ -import { afterAll, afterEach, beforeAll, beforeEach, expect, mock, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, expect, mock, spyOn, test } from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -27,7 +27,7 @@ const AFTER_IMPORT = Date.parse("2026-09-02T00:00:00.000Z"); let runsDir: string; let originalFetch: typeof globalThis.fetch; let originalMode: string | undefined; -const realTerminal = terminal.available; +const terminalAvailable = spyOn(terminal, "available"); /** Clerk IDs the stubbed instance holds, with each one's last sign-in. */ let instanceUsers: Map; let deletes: string[]; @@ -38,14 +38,14 @@ beforeAll(() => { }); afterAll(() => { - terminal.available = realTerminal; + terminalAvailable.mockRestore(); if (originalMode === undefined) delete process.env.CLERK_MODE; else process.env.CLERK_MODE = originalMode; }); beforeEach(() => { process.env.CLERK_MODE = "human"; - terminal.available = () => true; + terminalAvailable.mockReturnValue(true); runsDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-undo-human-"))); deletes = []; confirmed = () => {}; diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index f666b06d34..591f7ac0dc 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -1,4 +1,13 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + spyOn, + test, +} from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -451,13 +460,12 @@ describe("forced human mode with no terminal", () => { test("refuses for consent rather than prompting", async () => { const record = importRun(); const mode = process.env.CLERK_MODE; - const available = terminal.available; + const available = spyOn(terminal, "available").mockReturnValue(false); process.env.CLERK_MODE = "human"; - terminal.available = () => false; try { await expect(undo(record.id, withDir())).rejects.toThrow(/needs consent/); } finally { - terminal.available = available; + available.mockRestore(); if (mode === undefined) delete process.env.CLERK_MODE; else process.env.CLERK_MODE = mode; } From 97eb4d38957ed74f76bf0de15040910c9a7de328 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 14:20:46 -0400 Subject: [PATCH 36/63] refactor(migrate): share the unfinished-undo lookup, the undo command, plural and error tallies across slice 2 From the Oct 15 slice 2 review's duplication pass, behaviour unchanged: - openUndoOf, unfinishedUndoMessage and inUseMessage in run-store replace three drifted copies of the unfinished-undo lookup and two of each refusal; findResume refuses any open undo, as continueRun and undo did. - undoCommand(id, carry) builds every suggested `clerk migrate undo`, and undo builds its own once. MIGRATE_DONE* take ready-made commands, so next-steps no longer digs --runs-dir out of a flag list. - One plural, one failureOf and tallyError beside normalizeErrorMessage, isStandInInstanceId/isClerkInstanceId for the key_/ins_ checks, an exported USERS_FILE, and SourceUser shared with undo's UndoUser. - Undo's two consent refusals print through printJsonPreview, its markers are one UndoOutcome union, and inspectUsers drops signedInSince, which was always signedIn.length. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/import-users.ts | 23 +++- .../commands/migrate/lib/carry-flags.test.ts | 13 +- .../src/commands/migrate/lib/carry-flags.ts | 5 + .../src/commands/migrate/lib/checks.ts | 6 +- .../src/commands/migrate/lib/plural.ts | 3 + .../src/commands/migrate/lib/run-store.ts | 48 ++++--- .../src/commands/migrate/lib/target.ts | 14 +- .../src/commands/migrate/lib/user-lookup.ts | 5 +- packages/cli-core/src/commands/migrate/run.ts | 56 ++++---- .../cli-core/src/commands/migrate/runs.ts | 19 +-- .../cli-core/src/commands/migrate/undo.ts | 125 ++++++++---------- packages/cli-core/src/lib/next-steps.ts | 25 +--- 12 files changed, 183 insertions(+), 159 deletions(-) create mode 100644 packages/cli-core/src/commands/migrate/lib/plural.ts diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index 6f5d7534c2..70eccfb0a8 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -34,6 +34,22 @@ export { readRetryAfter } from "./lib/retry.ts"; * Groups error messages that differ only in field ordering, so the summary * reports "12 users: [\"first_name\" \"last_name\"] ..." once instead of twice. */ +/** A failed request's message and code, as a run line records them. */ +export function failureOf(error: unknown): { message: string; code: string } { + if (error instanceof RateLimitExceededError) return { message: error.message, code: "429" }; + const apiError = error as BapiError; + return { + message: apiError.longMessage ?? apiError.message ?? "Unknown error", + code: String(apiError.status ?? "unknown"), + }; +} + +/** Counts a failure under its normalized message, the way every summary groups them. */ +export function tallyError(breakdown: Map, message: string): void { + const key = normalizeErrorMessage(message); + breakdown.set(key, (breakdown.get(key) ?? 0) + 1); +} + export function normalizeErrorMessage(errorMessage: string): string { let normalized = ""; let lastCopiedIndex = 0; @@ -456,8 +472,7 @@ export async function importUsers(options: ImportUsersOptions): Promise { failed++; processed++; - const normalized = normalizeErrorMessage(message); - errorBreakdown.set(normalized, (errorBreakdown.get(normalized) ?? 0) + 1); + tallyError(errorBreakdown, message); // With no answer, the `creating` line stays the latest: Clerk may hold // the user, and a re-run looks it up before creating it again. if (!unknown) { @@ -538,11 +553,11 @@ export async function importUsers(options: ImportUsersOptions): Promise { test("keeps the runs folder and target, with the key as a placeholder", () => { @@ -18,13 +17,9 @@ describe("carryFlags", () => { ]); }); - // `clerk migrate runs` takes no target flags. - test("an import's next steps keep its flags, and `runs` only the runs folder", () => { - const [runsLine, undoLine] = NEXT_STEPS.MIGRATE_DONE( - "r1", - carryFlags({ secretKey: "sk_test_x", runsDir: "/a b" }), + test("an undo command keeps them, after the run ID", () => { + expect(undoCommand("r1", carryFlags({ secretKey: "sk_test_x", runsDir: "/a b" }))).toBe( + "clerk migrate undo r1 --secret-key --runs-dir '/a b'", ); - expect(runsLine).toContain("`clerk migrate runs r1 --runs-dir '/a b'`"); - expect(undoLine).toContain("`clerk migrate undo r1 --secret-key --runs-dir '/a b'`"); }); }); diff --git a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts index a778938f51..da1f342c1a 100644 --- a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts +++ b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts @@ -21,6 +21,11 @@ export function carryFlags(options: { ]; } +/** `clerk migrate undo ` with the flags that reach its runs folder and instance. */ +export function undoCommand(id: string, carry: string[]): string { + return withFlags(`clerk migrate undo ${id}`, carry); +} + /** `--` flags appended to a command, or nothing. */ export function withFlags(command: string, flags: string[]): string { return flags.length > 0 ? `${command} ${flags.join(" ")}` : command; diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index d9fa7b2174..43bf16d075 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -16,6 +16,8 @@ * Each rejected user gets the first reason that applies to them, so the counts * add up to the file. */ +import { isClerkInstanceId } from "./target.ts"; +import { plural } from "./plural.ts"; import type { UserSettingsJSON } from "../../../lib/fapi.ts"; import type { SpinnerControls } from "../../../lib/spinner.ts"; @@ -117,8 +119,6 @@ export type CheckInput = { spinner?: SpinnerControls; }; -const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; - // --- Hash shapes ----------------------------------------------------------- /** @@ -854,7 +854,7 @@ function buildFixes(input: CheckInput, users: User[]): Fix[] { // passes as is and Clerk answers "app not found", where `` would // be read as a redirect. const { appId, instanceId } = input.target; - const named = instanceId.startsWith("ins_"); + const named = isClerkInstanceId(instanceId); const flags = ` --app ${appId ?? "APP_ID"} --instance ${instanceId}`; const settings = input.settings; const mfa = MFA_SETTINGS.filter( diff --git a/packages/cli-core/src/commands/migrate/lib/plural.ts b/packages/cli-core/src/commands/migrate/lib/plural.ts new file mode 100644 index 0000000000..2f3ae26cd1 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/lib/plural.ts @@ -0,0 +1,3 @@ +/** `1 user`, `2 users`: the count and the word, in English. */ +export const plural = (count: number, word: string): string => + `${count} ${word}${count === 1 ? "" : "s"}`; 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 f6752429e6..0101c9162d 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -25,7 +25,7 @@ import { resolveProfile } from "../../../lib/config.ts"; import { throwUsageError } from "../../../lib/errors.ts"; import { ensureGitignoreEntry, getGitRepoRoot } from "../../../lib/git.ts"; import { log } from "../../../lib/log.ts"; -import { withFlags } from "./carry-flags.ts"; +import { undoCommand } from "./carry-flags.ts"; /** Overrides the runs directory, below `--runs-dir`. */ export const RUNS_DIR_ENV = "CLERK_MIGRATE_DIR"; @@ -107,7 +107,7 @@ export type UserLine = { export type RunState = RunStatus | "interrupted"; const RUN_FILE = "run.json"; -const USERS_FILE = "users.ndjson"; +export const USERS_FILE = "users.ndjson"; const LOCK_FILE = "lock"; // --- Location -------------------------------------------------------------- @@ -298,6 +298,35 @@ export function runState(runsDir: string, record: RunRecord): RunState { return liveLockPid(runsDir, record.id) === undefined ? "interrupted" : "running"; } +/** The undo of this import that has not finished, if any. */ +export function openUndoOf(runsDir: string, importId: string): RunRecord | undefined { + return listRuns(runsDir).find( + (candidate) => + candidate.kind === "undo" && + candidate.undoes === importId && + runState(runsDir, candidate) !== "complete", + ); +} + +/** + * Why an import with an unfinished undo can't continue: some of its users are + * gone and some are not. `carry` reaches the same runs folder and instance. + */ +export function unfinishedUndoMessage(importId: string, undoId: string, carry: string[]): string { + return ( + `Run ${importId} has an undo that did not finish (run ${undoId}). ` + + `Finish it with \`${undoCommand(importId, carry)}\`, or pass --new-run to import into a new run.` + ); +} + +/** Why a run another live process holds is refused, and how to clear a stale lock. */ +export function inUseMessage(runsDir: string, id: string, doing: string, then: string): string { + return ( + `Run ${id} is ${doing} right now in another process (PID ${liveLockPid(runsDir, id)}). ${then} ` + + `If that process is not a migrate run, delete ${lockFile(runsDir, id)}.` + ); +} + /** Every readable run, newest first. */ export function listRuns(runsDir: string): RunRecord[] { let entries: fs.Dirent[]; @@ -472,21 +501,10 @@ export function continueRun( // Checked again under the lock, before the record says running: an undo // can start, and stop part-way, after the caller's own check, leaving the // import's status and lines as they were. - const undoRun = - record.kind === "import" - ? listRuns(runsDir).find( - (candidate) => - candidate.kind === "undo" && - candidate.undoes === record.id && - runState(runsDir, candidate) !== "complete", - ) - : undefined; + const undoRun = record.kind === "import" ? openUndoOf(runsDir, record.id) : undefined; if (undoRun) { fs.rmSync(lockFile(runsDir, record.id), { force: true }); - throwUsageError( - `Run ${record.id} has an undo that did not finish (run ${undoRun.id}). ` + - `Finish it with \`${withFlags(`clerk migrate undo ${record.id}`, carry)}\`, or pass --new-run to import into a new run.`, - ); + throwUsageError(unfinishedUndoMessage(record.id, undoRun.id, carry)); } // A crash mid-write leaves a torn last line; end it so the next append // starts a line of its own instead of fusing with it. diff --git a/packages/cli-core/src/commands/migrate/lib/target.ts b/packages/cli-core/src/commands/migrate/lib/target.ts index 0735e528b4..ec72fa97c8 100644 --- a/packages/cli-core/src/commands/migrate/lib/target.ts +++ b/packages/cli-core/src/commands/migrate/lib/target.ts @@ -85,7 +85,7 @@ export async function fetchInstanceIdentity( bapiRequest({ method: "GET", path: "/v1/instance", secretKey }), ); const instance = body as { id?: unknown; environment_type?: unknown }; - if (typeof instance.id === "string" && instance.id.startsWith("ins_")) { + if (typeof instance.id === "string" && isClerkInstanceId(instance.id)) { return { instanceId: instance.id, env: @@ -103,6 +103,16 @@ export async function fetchInstanceIdentity( * key. A run recorded under it is matched by the same key later, once * `GET /v1/instance` answers again. */ +/** A stand-in for an instance Clerk did not name: {@link keyInstanceId}'s `key_` hash. */ +export function isStandInInstanceId(id: string | undefined): boolean { + return id?.startsWith("key_") === true; +} + +/** An instance ID Clerk itself returned. */ +export function isClerkInstanceId(id: string | undefined): boolean { + return id?.startsWith("ins_") === true; +} + export function keyInstanceId(secretKey: string): string { return `key_${createHash("sha256").update(secretKey).digest("hex").slice(0, 16)}`; } @@ -127,7 +137,7 @@ function assertInstanceFlagMatches( if (matches) return; // A `key_` ID means Clerk did not name the instance, so a literal ID cannot // be checked; saying it "does not match" would blame the flag. - if (!wanted && identity.instanceId.startsWith("key_")) { + if (!wanted && isStandInInstanceId(identity.instanceId)) { throwUsageError( `Could not verify that the key from ${keySource} addresses --instance ${flag}: ` + "Clerk did not answer GET /v1/instance. Nothing was changed.\n" + diff --git a/packages/cli-core/src/commands/migrate/lib/user-lookup.ts b/packages/cli-core/src/commands/migrate/lib/user-lookup.ts index b94e8bab34..0e44207c85 100644 --- a/packages/cli-core/src/commands/migrate/lib/user-lookup.ts +++ b/packages/cli-core/src/commands/migrate/lib/user-lookup.ts @@ -117,6 +117,9 @@ export async function lookupUsers(options: { */ export const RUN_MARKER_KEY = "clerkMigrateRun"; +/** A user a run recorded: its source ID, and the Clerk user it became. */ +export type SourceUser = { sourceId: string; clerkId: string }; + /** * The users behind creates that were in flight when run `runId` stopped: * found by `external_id`, and counted only when they carry the run's marker. @@ -129,7 +132,7 @@ export async function findInFlight(options: { sourceIds: string[]; secretKey: string; schedule: ApiScheduler; -}): Promise<{ sourceId: string; clerkId: string }[]> { +}): Promise { if (options.sourceIds.length === 0) return []; const found = await lookupUsers({ filter: "external_id", diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index f1cce12432..9e312bc14b 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -29,7 +29,8 @@ import { } from "../../lib/errors.ts"; import { resolveKeylessTarget } from "../../lib/keyless-target.ts"; import { quoteArg } from "../../lib/json-body.ts"; -import { carryFlags, withFlags } from "./lib/carry-flags.ts"; +import { carryFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; +import { plural } from "./lib/plural.ts"; import { terminal } from "./lib/terminal.ts"; import { log } from "../../lib/log.ts"; import { NEXT_STEPS, printAgentNextSteps } from "../../lib/next-steps.ts"; @@ -45,8 +46,6 @@ import { continueRun, latestUserLines, listRuns, - liveLockPid, - lockFile, readUserLines, resolveRunsDir, runDir, @@ -56,11 +55,20 @@ import { type Run, type RunRecord, type UserLine, + openUndoOf, + unfinishedUndoMessage, + inUseMessage, } 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 { + isClerkInstanceId, + isStandInInstanceId, + keyInstanceId, + printTarget, + resolveClerkTarget, +} from "./lib/target.ts"; import { findInFlight } from "./lib/user-lookup.ts"; import { fileExists, @@ -102,8 +110,6 @@ export type MigrateRunOptions = { runsDir?: string; }; -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() && terminal.available(); @@ -336,13 +342,13 @@ export function findResume( // Clerk did not name the instance (GET /v1/instance failed), so a run of // this file recorded under a real ID can be neither matched nor ruled out. // Starting over would reject every user it created and leave it unfinished. - if (match.instanceId.startsWith("key_")) { + if (isStandInInstanceId(match.instanceId)) { const unconfirmed = listRuns(runsDir).find( (record) => record.kind === "import" && record.file?.sha256 === match.sha256 && record.source === match.source && - record.target.instanceId?.startsWith("ins_") === true, + isClerkInstanceId(record.target.instanceId), ); if (unconfirmed) { throwUsageError( @@ -357,23 +363,17 @@ export function findResume( 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)}.`, + inUseMessage(runsDir, latest.id, "importing this file", "Wait for it to finish."), ); } 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 \`${withFlags(`clerk migrate undo ${latest.id}`, carry)}\`, or pass --new-run to import into a new run.`, - ); - } + const openUndo = openUndoOf(runsDir, latest.id); + if (openUndo) throwUsageError(unfinishedUndoMessage(latest.id, openUndo.id, carry)); // Undone in full, though the import run was never marked so. - if (undoRun) return { kind: "new" }; + if (listRuns(runsDir).some((record) => record.kind === "undo" && record.undoes === latest.id)) { + return { kind: "new" }; + } if (state === "complete") return { kind: "complete", record: latest }; return { @@ -789,9 +789,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { if (refused) { if (options.json) preview({ refused: true }); const earlier = earlierRunHolding(runsDir, source, target.instanceId, checks); - const undoEarlier = earlier - ? withFlags(`clerk migrate undo ${earlier.id}`, carryFlags(options)) - : undefined; + const undoEarlier = earlier ? undoCommand(earlier.id, carryFlags(options)) : undefined; throwUsageError( `${plural(checks.rejects.length, "user")} would be rejected, so nothing was imported.` + `${rejectSummary(checks, options.json)} ` + @@ -963,10 +961,18 @@ export async function run(rawOptions: MigrateRunOptions): Promise { for (const line of cleanupLines(runsDir, record)) log.info(line); } + // `runs` takes only --runs-dir. + const runsCommand = withFlags( + `clerk migrate runs ${record.id}`, + carryFlags({ runsDir: options.runsDir }), + ); const steps = summary.failed > 0 - ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS(record.id, carryFlags(options)) - : NEXT_STEPS.MIGRATE_DONE(record.id, carryFlags(options)); + ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS( + runsCommand, + undoCommand(record.id, carryFlags(options)), + ) + : NEXT_STEPS.MIGRATE_DONE(runsCommand, undoCommand(record.id, carryFlags(options))); setNextSteps(steps); printAgentNextSteps(steps); }, diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index 3d5c873347..477627ed3c 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -10,7 +10,7 @@ import path from "node:path"; import { bold, dim, green, red, yellow } from "../../lib/color.ts"; import { throwUsageError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; -import { normalizeErrorMessage } from "./import-users.ts"; +import { tallyError } from "./import-users.ts"; import { countLines, latestUserLines, @@ -22,8 +22,9 @@ import { type RunRecord, type RunState, type UserLine, + USERS_FILE, } from "./lib/run-store.ts"; -import { carryFlags, withFlags } from "./lib/carry-flags.ts"; +import { carryFlags, undoCommand } from "./lib/carry-flags.ts"; import { describeTarget } from "./lib/target.ts"; export type RunsOptions = { @@ -88,9 +89,7 @@ function pad(rows: string[][]): string[] { function errorBreakdown(lines: UserLine[]): { error: string; count: number }[] { const counts = new Map(); for (const line of lines) { - if (line.status !== "failed" || !line.error) continue; - const key = normalizeErrorMessage(line.error); - counts.set(key, (counts.get(key) ?? 0) + 1); + if (line.status === "failed" && line.error) tallyError(counts, line.error); } return [...counts].map(([error, count]) => ({ error, count })).sort((a, b) => b.count - a.count); } @@ -102,14 +101,10 @@ function errorBreakdown(lines: UserLine[]): { error: string; count: number }[] { export function nextCommands(record: RunRecord, state: RunState, carry: string[] = []): string[] { if (state === "running") return []; if (record.kind === "import" && state !== "undone") { - return [ - `Run \`${withFlags(`clerk migrate undo ${record.id}`, carry)}\` to delete the users it created`, - ]; + return [`Run \`${undoCommand(record.id, carry)}\` to delete the users it created`]; } if (record.kind === "undo" && state !== "complete" && record.undoes) { - return [ - `Run \`${withFlags(`clerk migrate undo ${record.undoes}`, carry)}\` to retry the users that failed`, - ]; + return [`Run \`${undoCommand(record.undoes, carry)}\` to retry the users that failed`]; } return []; } @@ -175,7 +170,7 @@ function printUsers(title: string, lines: UserLine[], usersFile: string): void { function printRun(runsDir: string, record: RunRecord, carry: string[]): void { const state = runState(runsDir, record); const dir = runDir(runsDir, record.id); - const usersFile = path.join(dir, "users.ndjson"); + const usersFile = path.join(dir, USERS_FILE); const lines = [...latestUserLines(runsDir, record.id).values()]; log.info(`Runs folder: ${runsDir}`); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 9909ce658a..2b9ce5ff44 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -24,17 +24,14 @@ import { interruptedExitCode } from "../../lib/signals.ts"; import { confirm } from "../../lib/prompts.ts"; import { withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; import { isAgent, isHuman } from "../../mode.ts"; -import { normalizeErrorMessage } from "./import-users.ts"; -import { carryFlags, withFlags } from "./lib/carry-flags.ts"; +import { failureOf, tallyError } from "./import-users.ts"; +import { carryFlags, undoCommand } from "./lib/carry-flags.ts"; import { terminal } from "./lib/terminal.ts"; import { resolveLimits, type ResolvedLimits } from "./lib/instance.ts"; -import { RateLimitExceededError, retryOn429 } from "./lib/retry.ts"; +import { retryOn429 } from "./lib/retry.ts"; import { continueRun, latestUserLines, - listRuns, - liveLockPid, - lockFile, lockRun, patchRun, readRun, @@ -44,17 +41,21 @@ import { startRun, type Run, type RunRecord, + openUndoOf, + inUseMessage, } from "./lib/run-store.ts"; import { withProgress, type ProgressUpdate } from "./lib/progress.ts"; import { createApiScheduler, type ApiScheduler } from "./lib/scheduler.ts"; import { + type ClerkTarget, describeTarget, + isStandInInstanceId, keyInstanceId, printTarget, resolveClerkTarget, - type ClerkTarget, } from "./lib/target.ts"; -import { findInFlight, lookupUsers } from "./lib/user-lookup.ts"; +import { plural } from "./lib/plural.ts"; +import { findInFlight, lookupUsers, type SourceUser } from "./lib/user-lookup.ts"; export type UndoOptions = { dryRun?: boolean; @@ -69,7 +70,7 @@ export type UndoOptions = { }; /** One user the import created, still to be deleted. */ -export type UndoUser = { sourceId: string; clerkId: string }; +export type UndoUser = SourceUser; export type UndoPreview = { /** Users that will be deleted. */ @@ -82,8 +83,6 @@ export type UndoPreview = { alreadyDeleted: number; }; -const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; - /** Loads the import run, refusing anything that cannot be undone. */ function readImportRun(runsDir: string, runId: string): RunRecord { const record = readRun(runsDir, runId); @@ -103,8 +102,7 @@ function readImportRun(runsDir: string, runId: string): RunRecord { } if (runState(runsDir, record) === "running") { throwUsageError( - `Run ${runId} is still running (PID ${liveLockPid(runsDir, runId)}). Wait for it to finish, then undo it. ` + - `If that process is not a migrate run, delete ${lockFile(runsDir, runId)}.`, + inUseMessage(runsDir, runId, "running", "Wait for it to finish, then undo it."), ); } return record; @@ -118,9 +116,7 @@ function assertSameInstance(record: RunRecord, target: ClerkTarget, secretKey: s if (record.target.instanceId === keyInstanceId(secretKey)) return; // A `key_` ID is the fallback for an instance lookup that failed. It cannot // be compared with an `ins_` ID, so this is "unknown", not "different". - const unconfirmed = [record.target.instanceId, target.instanceId].some((id) => - id?.startsWith("key_"), - ); + const unconfirmed = [record.target.instanceId, target.instanceId].some(isStandInInstanceId); if (unconfirmed) { throwUsageError( `Could not confirm that the resolved key addresses the instance run ${record.id} imported into: ` + @@ -135,16 +131,6 @@ function assertSameInstance(record: RunRecord, target: ClerkTarget, secretKey: s ); } -/** The latest undo of this import that has not finished its job, if any. */ -function findOpenUndo(runsDir: string, importId: string): RunRecord | undefined { - return listRuns(runsDir).find( - (candidate) => - candidate.kind === "undo" && - candidate.undoes === importId && - runState(runsDir, candidate) !== "complete", - ); -} - /** * Every user the import created, minus those an earlier undo already deleted. * @@ -189,7 +175,6 @@ async function inspectUsers(options: { }): Promise<{ present: UndoUser[]; gone: UndoUser[]; - signedInSince: number; /** The Clerk IDs of the users who signed in since the import. */ signedIn: string[]; }> { @@ -213,7 +198,7 @@ async function inspectUsers(options: { }) .map((user) => user.clerkId); - return { present, gone, signedInSince: signedIn.length, signedIn }; + return { present, gone, signedIn }; } export type UndoSummary = { @@ -276,16 +261,8 @@ async function deleteUsers(options: { }); } else { failed++; - const message = - error instanceof RateLimitExceededError - ? error.message - : ((error as BapiError).longMessage ?? (error as Error).message ?? "Unknown error"); - const code = - error instanceof RateLimitExceededError - ? "429" - : String((error as BapiError).status ?? "unknown"); - const normalized = normalizeErrorMessage(message); - errorBreakdown.set(normalized, (errorBreakdown.get(normalized) ?? 0) + 1); + const { message, code } = failureOf(error); + tallyError(errorBreakdown, message); run.append({ sourceId: user.sourceId, clerkId: user.clerkId, @@ -335,11 +312,27 @@ function printPreview(record: RunRecord, preview: UndoPreview): void { * The JSON every outcome prints. `import` is the run being undone; `run` is the * undo's own run, null until one is written, as an import's is. */ +/** + * What the JSON adds to the preview: why undo stopped before deleting, or, + * once it did, its run and result. One of these, never two. + */ +type UndoOutcome = + | { dryRun: true } + | { consent: "required" } + | { changed: true; signedInSincePreview?: number } + | { recheckFailed: true } + | { writeFailed: true } + | { stopped: true } + | { + run: RunRecord; + result: { deleted: number; failed: number; errors: { error: string; count: number }[] }; + }; + function jsonResult( target: ClerkTarget, record: RunRecord, preview: UndoPreview, - extra: Record = {}, + extra: UndoOutcome, ) { return { target, import: record, run: null, preview, ...extra }; } @@ -348,13 +341,15 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 ? await withSpinner("Checking the imported users...", async (spinner) => inspectUsers({ users, since: record.startedAt, secretKey, schedule, spinner }), ) - : { present: [], gone: [], signedInSince: 0, signedIn: [] }; + : { present: [], gone: [], signedIn: [] }; const preview: UndoPreview = { toDelete: present.length, - signedInSince, + signedInSince: signedIn.length, alreadyGone: gone.length, alreadyDeleted, }; @@ -405,16 +400,22 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise { + if (!options.json || printed) return; + printed = true; + log.data(JSON.stringify(jsonResult(target, record, preview, extra), null, 2)); + }; // Rule 1: nothing is deleted without consent — `--yes`, or a yes at a // prompt. `--json` never prompts. if (!nothingLeft && !options.yes) { const canAsk = !options.json && isHuman() && !isAgent() && terminal.available(); if (!canAsk) { - if (options.json) { - log.data( - JSON.stringify(jsonResult(target, record, preview, { consent: "required" }), null, 2), - ); - } + printJsonPreview({ consent: "required" }); throwUsageError( `\`clerk migrate undo\` permanently deletes ${plural(present.length, "user")} and needs consent. Pass --yes to confirm.`, undefined, @@ -433,11 +434,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 && !options.includeSignedIn) { - if (options.json) { - log.data( - JSON.stringify(jsonResult(target, record, preview, { consent: "required" }), null, 2), - ); - } + printJsonPreview({ consent: "required" }); throwUsageError( `${plural(preview.signedInSince, "user")} the import created ${preview.signedInSince === 1 ? "has" : "have"} signed in since, and --yes alone does not delete them. ` + "Pass --include-signed-in to delete them too. Nothing was deleted.", @@ -447,17 +444,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise) => { - if (!options.json || printed) return; - printed = true; - log.data(JSON.stringify(jsonResult(target, record, preview, extra), null, 2)); - }; - const refuseChanged = (message: string, extra: Record = {}): never => { + const refuseChanged = (message: string, extra: { signedInSincePreview?: number } = {}): never => { printJsonPreview({ changed: true, ...extra }); throwUsageError(message); }; @@ -498,7 +485,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0) { refuseChanged( `${plural(more, "more user")} the import created signed in after the preview, and nothing agreed to delete them. Nothing was deleted.\n` + - `Run \`${withFlags(`clerk migrate undo ${record.id}`, carryFlags(options))}\` again to see them, or pass --include-signed-in to delete them too.`, + `Run \`${undoAgain}\` again to see them, or pass --include-signed-in to delete them too.`, { signedInSincePreview: more }, ); } @@ -597,9 +584,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise [ - `Run \`${migrateCommand(`runs ${runId}`, carry, true)}\` to see what happened to each user`, - `Run \`${migrateCommand(`undo ${runId}`, carry)}\` to delete the users it created`, + // Ready-made commands, with the flags that reach the same run and instance. + MIGRATE_DONE: (runsCommand: string, undoCommand: string) => [ + `Run \`${runsCommand}\` to see what happened to each user`, + `Run \`${undoCommand}\` to delete the users it created`, ], - MIGRATE_DONE_WITH_ERRORS: (runId: string, carry: string[] = []) => [ - `Run \`${migrateCommand(`runs ${runId}`, carry, true)}\` to see every user that failed and why`, - `Run \`${migrateCommand(`undo ${runId}`, carry)}\` to delete the users it created`, + MIGRATE_DONE_WITH_ERRORS: (runsCommand: string, undoCommand: string) => [ + `Run \`${runsCommand}\` to see every user that failed and why`, + `Run \`${undoCommand}\` to delete the users it created`, ], } as const; From 11aff604bc9f08401cd5592ec59d1f6155ce7d2b Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 14:21:52 -0400 Subject: [PATCH 37/63] fix(migrate): count attach-only users in the import's progress, and let undo's deletes share its scheduler From the Oct 15 slice 2 review: - A continue with only attaches left ran its bar at 0/0. The total now includes attach-only users, and each counts once its attaches are sent. - deleteUsers built a scheduler of its own beside the one undo's lookups use, so the two each had a full rate budget. It takes undo's. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/import-users.test.ts | 24 +++++++++++++++++++ .../src/commands/migrate/import-users.ts | 14 +++++++++-- packages/cli-core/src/commands/migrate/run.ts | 2 +- .../cli-core/src/commands/migrate/undo.ts | 10 ++++---- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index a3cb837836..f3bdc6a548 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -488,6 +488,30 @@ describe("importUsers", () => { expect(lines.at(-1)).toEqual({ sourceId: "u1", clerkId: "user_1", status: "created" }); }); + // A continue with only attaches left would otherwise read 0/0 throughout. + test("counts an attach-only user towards progress once its attaches are sent", async () => { + stub(() => ok("idn_1")); + const reported: number[] = []; + + await importUsers({ + users: [], + attachOnly: [ + { + sourceId: "u1", + clerkId: "user_1", + status: "created", + pending: [{ kind: "phone", value: "+15555550100", verified: false }], + }, + ], + secretKey: "sk_test_x", + limits: LIMITS, + record, + progress: ({ done }) => reported.push(done), + }); + + expect(reported.at(-1)).toBe(1); + }); + test("an adopted user is not created again; only its extras attach", async () => { stub(() => ok("idn_1")); diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index 70eccfb0a8..25afa138a8 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -461,7 +461,10 @@ export async function importUsers(options: ImportUsersOptions): Promise report?.({ done: processed, ok: successful, failed }); + // Attach-only users count towards the bar too: a continue with only those + // left would otherwise sit at 0/0. + let attached = 0; + const progress = () => report?.({ done: processed + attached, ok: successful, failed }); const recordFailure = ( userId: string, @@ -592,7 +595,14 @@ export async function importUsers(options: ImportUsersOptions): Promise settle(processUser(user))), - ...attachOnly.map(async (line) => settle(finishUser(line, line.pending ?? [], []))), + ...attachOnly.map(async (line) => + settle( + finishUser(line, line.pending ?? [], []).then(() => { + attached++; + progress(); + }), + ), + ), ]); if (storeFailure) throw storeFailure.error; diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 9e312bc14b..874ad78d41 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -883,7 +883,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { const summary: ImportSummary = checks.importable.length > 0 || attachOnly.length > 0 ? await withProgress( - { total: checks.importable.length, verb: "created" }, + { total: checks.importable.length + attachOnly.length, verb: "created" }, async (progress) => importUsers({ users: checks.importable, diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 2b9ce5ff44..05f6b22277 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -27,7 +27,7 @@ import { isAgent, isHuman } from "../../mode.ts"; import { failureOf, tallyError } from "./import-users.ts"; import { carryFlags, undoCommand } from "./lib/carry-flags.ts"; import { terminal } from "./lib/terminal.ts"; -import { resolveLimits, type ResolvedLimits } from "./lib/instance.ts"; +import { resolveLimits } from "./lib/instance.ts"; import { retryOn429 } from "./lib/retry.ts"; import { continueRun, @@ -211,12 +211,12 @@ export type UndoSummary = { async function deleteUsers(options: { users: UndoUser[]; secretKey: string; - limits: ResolvedLimits; + /** Undo's own scheduler, so its lookups and deletes share one rate budget. */ + schedule: ApiScheduler; run: Run; progress?: ProgressUpdate; }): Promise { - const { users, secretKey, limits, run, progress: report } = options; - const schedule = createApiScheduler(limits.concurrencyLimit, limits.rateLimit); + const { users, secretKey, schedule, run, progress: report } = options; const errorBreakdown = new Map(); let processed = 0; let deleted = 0; @@ -537,7 +537,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 ? await withProgress({ total: present.length, verb: "deleted" }, async (progress) => - deleteUsers({ users: present, secretKey, limits, run, progress }), + deleteUsers({ users: present, secretKey, schedule, run, progress }), ) : { deleted: 0, failed: 0, errorBreakdown: new Map() }; From 325d2ab405f04bdcab48fa853ade8e6f9fd22a82 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 15:07:00 -0400 Subject: [PATCH 38/63] fix(migrate): list the same runs folder in run hints, and suggest the import that continues a stopped run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `runs ` and `undo ` name `clerk migrate runs --runs-dir …` when the ID matches nothing, through one shared `noRunMessage`. - `runs ` on an interrupted or partial import suggests the import that continues it, before the undo. - An import that ends with failures suggests the same import to retry them, without `--new-run`. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/carry-flags.ts | 5 +++ .../src/commands/migrate/lib/run-store.ts | 7 +++- .../cli-core/src/commands/migrate/run.test.ts | 17 ++++++++++ packages/cli-core/src/commands/migrate/run.ts | 2 ++ .../src/commands/migrate/runs.test.ts | 26 +++++++++++++-- .../cli-core/src/commands/migrate/runs.ts | 32 ++++++++++++++++--- .../src/commands/migrate/undo.test.ts | 6 ++++ .../cli-core/src/commands/migrate/undo.ts | 21 ++++++------ packages/cli-core/src/lib/next-steps.ts | 3 +- 9 files changed, 101 insertions(+), 18 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts index da1f342c1a..f38dc0375d 100644 --- a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts +++ b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts @@ -21,6 +21,11 @@ export function carryFlags(options: { ]; } +/** `clerk migrate runs`, listing the runs folder these flags name. */ +export function runsListCommand(carry: string[]): string { + return withFlags("clerk migrate runs", carry); +} + /** `clerk migrate undo ` with the flags that reach its runs folder and instance. */ export function undoCommand(id: string, carry: string[]): string { return withFlags(`clerk migrate undo ${id}`, carry); 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 0101c9162d..fba4d2e820 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -25,7 +25,7 @@ import { resolveProfile } from "../../../lib/config.ts"; import { throwUsageError } from "../../../lib/errors.ts"; import { ensureGitignoreEntry, getGitRepoRoot } from "../../../lib/git.ts"; import { log } from "../../../lib/log.ts"; -import { undoCommand } from "./carry-flags.ts"; +import { runsListCommand, undoCommand } from "./carry-flags.ts"; /** Overrides the runs directory, below `--runs-dir`. */ export const RUNS_DIR_ENV = "CLERK_MIGRATE_DIR"; @@ -319,6 +319,11 @@ export function unfinishedUndoMessage(importId: string, undoId: string, carry: s ); } +/** Why a run ID matched nothing, and the listing of that same runs folder. */ +export function noRunMessage(runsDir: string, id: string, carry: string[]): string { + return `No run \`${id}\` in ${runsDir}. Run \`${runsListCommand(carry)}\` to list them.`; +} + /** Why a run another live process holds is refused, and how to clear a stale lock. */ export function inUseMessage(runsDir: string, id: string, doing: string, then: string): string { return ( diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index f9caa3b956..84df14bbf4 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -359,6 +359,23 @@ describe("run", () => { expect(listRuns(runsDir())[0]).toMatchObject({ status: "partial", counts: { failed: 1 } }); }); + // `runs` reads only a runs folder; the retry is the same import, which + // continues this run, so it drops --new-run. + test("after failures, names the runs listing and the import that retries them", async () => { + stubClerk({ failing: new Set(["u2"]) }); + const dir = path.join(workDir, "runs"); + + await run({ ...baseOptions, runsDir: dir, newRun: true }); + + const [record] = listRuns(dir); + const err = Bun.stripANSI(captured.err); + expect(err).toContain(`\`clerk migrate runs ${record!.id} --runs-dir ${dir}\``); + expect(err).toContain( + `\`clerk migrate import export.json --source clerk --secret-key --runs-dir ${dir} --yes\` to retry them`, + ); + expect(err).not.toContain("--new-run"); + }); + // 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 () => { diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 874ad78d41..91c3081698 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -970,6 +970,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { summary.failed > 0 ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS( runsCommand, + // The same import again continues this run; --new-run would not. + commandFor({ ...options, newRun: false }, options.yes ? ["--yes"] : []), undoCommand(record.id, carryFlags(options)), ) : NEXT_STEPS.MIGRATE_DONE(runsCommand, undoCommand(record.id, carryFlags(options))); diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 300fe22437..b0752e3cf1 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -117,7 +117,7 @@ describe("runs ", () => { await runs(record.id, { runsDir, json: true }); const { next } = JSON.parse(captured.out) as { next: string[] }; - expect(next[0]).toContain(`clerk migrate undo ${record.id} ${expected}`); + expect(next.at(-1)).toContain(`clerk migrate undo ${record.id} ${expected}`); }); // The suggested undo has to look in the same runs folder. @@ -127,10 +127,32 @@ describe("runs ", () => { await runs(record.id, { runsDir, json: true }); const { next } = JSON.parse(captured.out) as { next: string[] }; - expect(next[0]).toContain(`clerk migrate undo ${record.id} --runs-dir ${quoteArg(runsDir)}`); + expect(next.at(-1)).toContain( + `clerk migrate undo ${record.id} --runs-dir ${quoteArg(runsDir)}`, + ); + }); + + // A partial run is continued by importing the same file again. + test("a partial import suggests re-running it, then undoing it", async () => { + const record = partialImport(); + + await runs(record.id, { runsDir, json: true }); + + const { next } = JSON.parse(captured.out) as { next: string[] }; + expect(next).toHaveLength(2); + expect(next[0]).toContain( + `clerk migrate import /tmp/users.json --source clerk --runs-dir ${quoteArg(runsDir)}`, + ); + expect(next[0]).toContain("to retry the users that failed or were skipped"); }); test("an unknown ID is a usage error", async () => { await expect(runs("nope", { runsDir })).rejects.toThrow(/No run `nope`/); }); + + test("an unknown ID lists the same runs folder", async () => { + await expect(runs("nope", { runsDir })).rejects.toThrow( + `clerk migrate runs --runs-dir ${quoteArg(runsDir)}`, + ); + }); }); diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index 477627ed3c..bec5bea1a1 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { bold, dim, green, red, yellow } from "../../lib/color.ts"; import { throwUsageError } from "../../lib/errors.ts"; +import { quoteArg } from "../../lib/json-body.ts"; import { log } from "../../lib/log.ts"; import { tallyError } from "./import-users.ts"; import { @@ -23,8 +24,9 @@ import { type RunState, type UserLine, USERS_FILE, + noRunMessage, } from "./lib/run-store.ts"; -import { carryFlags, undoCommand } from "./lib/carry-flags.ts"; +import { carryFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; import { describeTarget } from "./lib/target.ts"; export type RunsOptions = { @@ -94,6 +96,18 @@ function errorBreakdown(lines: UserLine[]): { error: string; count: number }[] { return [...counts].map(([error, count]) => ({ error, count })).sort((a, b) => b.count - a.count); } +/** + * The import that continues this run: its file (or export run) and source, + * with the flags that reach the same runs folder and instance. + */ +function continueCommand(record: RunRecord, carry: string[]): string | undefined { + const input = record.fromExport ?? record.file?.path; + if (!input) return undefined; + const parts = ["clerk migrate import", quoteArg(input)]; + if (!record.fromExport && record.source) parts.push("--source", quoteArg(record.source)); + return withFlags(parts.join(" "), carry); +} + /** * What to run after reading about this run. `carry` is the runs folder this * listing read, and the target flags the record says the import used. @@ -101,7 +115,17 @@ function errorBreakdown(lines: UserLine[]): { error: string; count: number }[] { export function nextCommands(record: RunRecord, state: RunState, carry: string[] = []): string[] { if (state === "running") return []; if (record.kind === "import" && state !== "undone") { - return [`Run \`${undoCommand(record.id, carry)}\` to delete the users it created`]; + const undo = `Run \`${undoCommand(record.id, carry)}\` to delete the users it created`; + const again = continueCommand(record, carry); + if (!again || state === "complete") return [undo]; + // Continuing is what a stopped run is for: the same file, source and + // target pick it up where it left off. + return [ + state === "interrupted" + ? `Run \`${again}\` to continue it` + : `Run \`${again}\` to retry the users that failed or were skipped`, + undo, + ]; } if (record.kind === "undo" && state !== "complete" && record.undoes) { return [`Run \`${undoCommand(record.undoes, carry)}\` to retry the users that failed`]; @@ -239,9 +263,7 @@ export async function runs(id: string | undefined, options: RunsOptions = {}): P } const stored = readRun(runsDir, id); - if (!stored) { - throwUsageError(`No run \`${id}\` in ${runsDir}. Run \`clerk migrate runs\` to list them.`); - } + if (!stored) throwUsageError(noRunMessage(runsDir, id, carryFlags({ runsDir: options.runsDir }))); const record = withLiveCounts(runsDir, stored); // The record says where the import's key came from: `--secret-key` or diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index 591f7ac0dc..faf1629d21 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -143,6 +143,12 @@ describe("refusals, all exit 2 and delete nothing", () => { expect(await exitCodeOf(undo("nope", withDir({ yes: true })))).toBe(EXIT_CODE.USAGE); }); + test("an unknown run lists the same runs folder", async () => { + await expect(undo("nope", withDir({ yes: true }))).rejects.toThrow( + `clerk migrate runs --runs-dir ${quoteArg(runsDir)}`, + ); + }); + test("a run that is not an import", async () => { const run = startRun(runsDir, { kind: "export", target: { platform: "auth0" } }); run.finish(); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 05f6b22277..4bf6c09731 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -25,7 +25,7 @@ import { confirm } from "../../lib/prompts.ts"; import { withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; import { isAgent, isHuman } from "../../mode.ts"; import { failureOf, tallyError } from "./import-users.ts"; -import { carryFlags, undoCommand } from "./lib/carry-flags.ts"; +import { carryFlags, runsListCommand, undoCommand } from "./lib/carry-flags.ts"; import { terminal } from "./lib/terminal.ts"; import { resolveLimits } from "./lib/instance.ts"; import { retryOn429 } from "./lib/retry.ts"; @@ -43,6 +43,7 @@ import { type RunRecord, openUndoOf, inUseMessage, + noRunMessage, } from "./lib/run-store.ts"; import { withProgress, type ProgressUpdate } from "./lib/progress.ts"; import { createApiScheduler, type ApiScheduler } from "./lib/scheduler.ts"; @@ -83,18 +84,19 @@ export type UndoPreview = { alreadyDeleted: number; }; -/** Loads the import run, refusing anything that cannot be undone. */ -function readImportRun(runsDir: string, runId: string): RunRecord { +/** + * Loads the import run, refusing anything that cannot be undone. `runsCarry` + * is `--runs-dir`, so a suggested listing reads the same runs folder. + */ +function readImportRun(runsDir: string, runId: string, runsCarry: string[]): RunRecord { const record = readRun(runsDir, runId); - if (!record) { - throwUsageError(`No run \`${runId}\` in ${runsDir}. Run \`clerk migrate runs\` to list them.`); - } + if (!record) throwUsageError(noRunMessage(runsDir, runId, runsCarry)); if (record.kind !== "import") { throwUsageError( `Run ${runId} is an ${record.kind} run. Only an import run can be undone.`, undefined, undefined, - [{ command: "clerk migrate runs", description: "Find the import run to undo" }], + [{ command: runsListCommand(runsCarry), description: "Find the import run to undo" }], ); } if (record.status === "undone") { @@ -339,7 +341,8 @@ function jsonResult( export async function undo(runId: string, options: UndoOptions = {}): Promise { const runsDir = await resolveRunsDir(options.runsDir); - const record = readImportRun(runsDir, runId); + const runsCarry = carryFlags({ runsDir: options.runsDir }); + const record = readImportRun(runsDir, runId, runsCarry); const importLines = readUserLines(runsDir, record.id).length; // The command a hint suggests, reaching the same runs folder and instance. const undoAgain = undoCommand(record.id, carryFlags(options)); @@ -476,7 +479,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise [ + MIGRATE_DONE_WITH_ERRORS: (runsCommand: string, retryCommand: string, undoCommand: string) => [ `Run \`${runsCommand}\` to see every user that failed and why`, + `Run \`${retryCommand}\` to retry them, in the same run`, `Run \`${undoCommand}\` to delete the users it created`, ], } as const; From b3726b06c2b4058e81cb71e44bf06b080f1e7e6f Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 15:12:32 -0400 Subject: [PATCH 39/63] refactor(migrate): one JSON printer for undo, one scan in findResume, and the failure helpers in lib Behaviour is unchanged. - undo prints every JSON outcome through one `printJson`, builds its `--yes` command with `undoCommand`, and asks `canPrompt` (now in `lib/terminal.ts`) whether it can prompt. - `findResume` lists the runs once and filters them once. - `failureOf`, `tallyError` and `normalizeErrorMessage` move to `lib/failures.ts`, so `runs` and `undo` no longer import the importer. - `checkImport` uses `adopted()` everywhere; `run` builds its carry flags once; doc comments sit above their own functions again. - Smaller cleanups: unused exports, one-use helpers inlined, `plural()`, a named counter for attach-only users, the patched run's path in its debug line, and a prompts mock that keeps the module's other exports. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/import-users.test.ts | 2 +- .../src/commands/migrate/import-users.ts | 84 ++---------- .../src/commands/migrate/lib/checks.ts | 18 +-- .../src/commands/migrate/lib/failures.ts | 70 ++++++++++ .../src/commands/migrate/lib/run-store.ts | 4 +- .../src/commands/migrate/lib/target.ts | 10 +- .../src/commands/migrate/lib/terminal.ts | 6 + packages/cli-core/src/commands/migrate/run.ts | 59 ++++---- .../cli-core/src/commands/migrate/runs.ts | 14 +- .../commands/migrate/undo-interactive.test.ts | 3 + .../src/commands/migrate/undo.test.ts | 8 +- .../cli-core/src/commands/migrate/undo.ts | 129 +++++++----------- 12 files changed, 197 insertions(+), 210 deletions(-) create mode 100644 packages/cli-core/src/commands/migrate/lib/failures.ts diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index f3bdc6a548..1b277f1877 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -5,10 +5,10 @@ import { useCaptureLog } from "../../test/lib/stubs.ts"; import { buildCreateUserBody, importUsers, - normalizeErrorMessage, readRetryAfter, splitIdentifiers, } from "./import-users.ts"; +import { normalizeErrorMessage } from "./lib/failures.ts"; import type { ResolvedLimits } from "./lib/instance.ts"; import type { UserLine } from "./lib/run-store.ts"; import type { User } from "./types.ts"; diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index 25afa138a8..bc640124ee 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -21,6 +21,7 @@ import { BapiError } from "../../lib/errors.ts"; import { interruptSignal } from "../../lib/signals.ts"; import type { ResolvedLimits } from "./lib/instance.ts"; import type { ProgressUpdate } from "./lib/progress.ts"; +import { failureOf, normalizeErrorMessage, tallyError } from "./lib/failures.ts"; import { RateLimitExceededError, retryOn429 } from "./lib/retry.ts"; import type { PendingIdentifier, UserLine } from "./lib/run-store.ts"; import { RUN_MARKER_KEY } from "./lib/user-lookup.ts"; @@ -30,69 +31,6 @@ import type { ImportSummary, User } from "./types.ts"; // Re-exported for the tests and callers that grew up against this module. export { readRetryAfter } from "./lib/retry.ts"; -/** - * Groups error messages that differ only in field ordering, so the summary - * reports "12 users: [\"first_name\" \"last_name\"] ..." once instead of twice. - */ -/** A failed request's message and code, as a run line records them. */ -export function failureOf(error: unknown): { message: string; code: string } { - if (error instanceof RateLimitExceededError) return { message: error.message, code: "429" }; - const apiError = error as BapiError; - return { - message: apiError.longMessage ?? apiError.message ?? "Unknown error", - code: String(apiError.status ?? "unknown"), - }; -} - -/** Counts a failure under its normalized message, the way every summary groups them. */ -export function tallyError(breakdown: Map, message: string): void { - const key = normalizeErrorMessage(message); - breakdown.set(key, (breakdown.get(key) ?? 0) + 1); -} - -export function normalizeErrorMessage(errorMessage: string): string { - let normalized = ""; - let lastCopiedIndex = 0; - let arrayStartIndex = -1; - - for (let i = 0; i < errorMessage.length; i++) { - const char = errorMessage[i]; - - if (arrayStartIndex === -1) { - if (char === "[") arrayStartIndex = i; - continue; - } - if (char !== "]") continue; - - normalized += errorMessage.slice(lastCopiedIndex, arrayStartIndex); - normalized += normalizeFieldArray(errorMessage.slice(arrayStartIndex + 1, i)); - lastCopiedIndex = i + 1; - arrayStartIndex = -1; - } - - return normalized + errorMessage.slice(lastCopiedIndex); -} - -function normalizeFieldArray(fields: string): string { - const fieldNames: string[] = []; - let current = ""; - - for (const char of fields) { - if (char === '"' || char === "'" || char.trim() === "") { - if (current.length > 0) { - fieldNames.push(current); - current = ""; - } - continue; - } - current += char; - } - if (current.length > 0) fieldNames.push(current); - - fieldNames.sort(); - return `[${fieldNames.map((name) => `"${name}"`).join(" ")}]`; -} - function toArray(value: string | string[] | undefined): string[] { if (!value) return []; return Array.isArray(value) ? value : [value]; @@ -458,13 +396,13 @@ export async function importUsers(options: ImportUsersOptions): Promise report?.({ done: processed + attached, ok: successful, failed }); + let attachedUsers = 0; + const progress = () => report?.({ done: processed + attachedUsers, ok: successful, failed }); const recordFailure = ( userId: string, @@ -588,6 +526,11 @@ export async function importUsers(options: ImportUsersOptions): Promise { + await finishUser(line, line.pending ?? [], []); + attachedUsers++; + progress(); + }; // A halt leaves its error to be rethrown below, once every user has settled. const settle = async (work: Promise) => work.catch((error: unknown) => { @@ -595,14 +538,7 @@ export async function importUsers(options: ImportUsersOptions): Promise settle(processUser(user))), - ...attachOnly.map(async (line) => - settle( - finishUser(line, line.pending ?? [], []).then(() => { - attached++; - progress(); - }), - ), - ), + ...attachOnly.map(async (line) => settle(attachOnlyUser(line))), ]); if (storeFailure) throw storeFailure.error; diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index 43bf16d075..d68c9dc5c9 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -986,6 +986,8 @@ export async function checkImport(input: CheckInput): Promise { })); const disabledProviders = findDisabledProviderRejects(input); + // Users an earlier attempt already created, which this one only attaches. + const adopted = (user: User) => input.adoptedSourceIds?.has(user.userId) ?? false; // Users that pass every per-user check. Only these claim identifiers in // the file: a rejected record must not cost a later one its email. Each is @@ -1010,8 +1012,7 @@ export async function checkImport(input: CheckInput): Promise { if (phoned.dropped) loosePhoneUsers.add(original.userId); const user = phoned.user; const sent = dropDisabledIdentifiers(user, input.settings); - const adopted = input.adoptedSourceIds?.has(original.userId) ?? false; - const reason = adopted + const reason = adopted(original) ? undefined : (original.skipReason ?? (refused.length > 0 && phoned.dropped && !hasAnyIdentifier(user) @@ -1063,7 +1064,6 @@ export async function checkImport(input: CheckInput): Promise { // An adopted user already holds its email, phone and username in Clerk, so // it claims them first, wherever it sits in the file: a fresh record sharing // one is the create Clerk would refuse, and is not kept in its place. - const adopted = (user: User) => input.adoptedSourceIds?.has(user.userId) ?? false; const { reasons: fileDuplicates, keptBy } = findFileDuplicates([ ...passed.filter(adopted), ...passed.filter((user) => !adopted(user)), @@ -1085,10 +1085,7 @@ export async function checkImport(input: CheckInput): Promise { // email costs it nothing. A second record with its source ID would be // created as the same user, so that one is still rejected. const duplicate = fileDuplicates.get(user); - const reason = - input.adoptedSourceIds?.has(user.userId) && duplicate !== DUPLICATE_SOURCE_ID - ? undefined - : duplicate; + const reason = adopted(user) && duplicate !== DUPLICATE_SOURCE_ID ? undefined : duplicate; if (reason) { rejects.push({ sourceId: user.userId, reason, ...(kept ? { keptSourceId: kept } : {}) }); duplicates.add(originals.get(user) ?? user); @@ -1101,9 +1098,7 @@ export async function checkImport(input: CheckInput): Promise { : new Map(); const unique: User[] = []; for (const user of candidates) { - const reason = input.adoptedSourceIds?.has(user.userId) - ? undefined - : instanceDuplicates.get(user.userId); + const reason = adopted(user) ? undefined : instanceDuplicates.get(user.userId); if (reason) { rejects.push({ sourceId: user.userId, reason }); duplicates.add(originals.get(user) ?? user); @@ -1120,8 +1115,7 @@ export async function checkImport(input: CheckInput): Promise { // An adopted user is in the instance's count already, and is never // created: only the new users take headroom, in file order. const headroom = Math.max(0, limit - (input.existingUsers ?? 0)); - const isNew = (user: User) => !input.adoptedSourceIds?.has(user.userId); - const fresh = candidates.filter(isNew); + const fresh = candidates.filter((user) => !adopted(user)); const over = Math.max(0, fresh.length - headroom); quota = { existing: input.existingUsers ?? null, limit, headroom, over }; if (over > 0) { diff --git a/packages/cli-core/src/commands/migrate/lib/failures.ts b/packages/cli-core/src/commands/migrate/lib/failures.ts new file mode 100644 index 0000000000..2d483d6a34 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/lib/failures.ts @@ -0,0 +1,70 @@ +/** + * How a failed request reads in a run's lines and summaries, shared by import, + * undo and `runs`. + */ + +import type { BapiError } from "../../../lib/errors.ts"; +import { RateLimitExceededError } from "./retry.ts"; + +/** A failed request's message and code, as a run line records them. */ +export function failureOf(error: unknown): { message: string; code: string } { + if (error instanceof RateLimitExceededError) return { message: error.message, code: "429" }; + const apiError = error as BapiError; + return { + message: apiError.longMessage ?? apiError.message ?? "Unknown error", + code: String(apiError.status ?? "unknown"), + }; +} + +/** Counts a failure under its normalized message, the way every summary groups them. */ +export function tallyError(breakdown: Map, message: string): void { + const key = normalizeErrorMessage(message); + breakdown.set(key, (breakdown.get(key) ?? 0) + 1); +} + +/** + * Groups error messages that differ only in field ordering, so the summary + * reports "12 users: [\"first_name\" \"last_name\"] ..." once instead of twice. + */ +export function normalizeErrorMessage(errorMessage: string): string { + let normalized = ""; + let lastCopiedIndex = 0; + let arrayStartIndex = -1; + + for (let i = 0; i < errorMessage.length; i++) { + const char = errorMessage[i]; + + if (arrayStartIndex === -1) { + if (char === "[") arrayStartIndex = i; + continue; + } + if (char !== "]") continue; + + normalized += errorMessage.slice(lastCopiedIndex, arrayStartIndex); + normalized += normalizeFieldArray(errorMessage.slice(arrayStartIndex + 1, i)); + lastCopiedIndex = i + 1; + arrayStartIndex = -1; + } + + return normalized + errorMessage.slice(lastCopiedIndex); +} + +function normalizeFieldArray(fields: string): string { + const fieldNames: string[] = []; + let current = ""; + + for (const char of fields) { + if (char === '"' || char === "'" || char.trim() === "") { + if (current.length > 0) { + fieldNames.push(current); + current = ""; + } + continue; + } + current += char; + } + if (current.length > 0) fieldNames.push(current); + + fieldNames.sort(); + return `[${fieldNames.map((name) => `"${name}"`).join(" ")}]`; +} 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 fba4d2e820..9ccdacb832 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -550,7 +550,9 @@ export function patchRun( const record = readRun(runsDir, id); if (!record) return undefined; const patched = { ...record, ...patch }; - log.debug(`migrate: patching run ${id}: ${Object.keys(patch).join(", ")}`); + log.debug( + `migrate: patching run ${id} (${path.join(runDir(runsDir, id), RUN_FILE)}): ${Object.keys(patch).join(", ")}`, + ); writeRecord(runsDir, patched); return patched; } diff --git a/packages/cli-core/src/commands/migrate/lib/target.ts b/packages/cli-core/src/commands/migrate/lib/target.ts index ec72fa97c8..d5dd0894f8 100644 --- a/packages/cli-core/src/commands/migrate/lib/target.ts +++ b/packages/cli-core/src/commands/migrate/lib/target.ts @@ -98,11 +98,6 @@ export async function fetchInstanceIdentity( return { instanceId: keyInstanceId(secretKey), env: fallbackEnv }; } -/** - * The stand-in ID for a key whose instance Clerk did not name: a hash of the - * key. A run recorded under it is matched by the same key later, once - * `GET /v1/instance` answers again. - */ /** A stand-in for an instance Clerk did not name: {@link keyInstanceId}'s `key_` hash. */ export function isStandInInstanceId(id: string | undefined): boolean { return id?.startsWith("key_") === true; @@ -113,6 +108,11 @@ export function isClerkInstanceId(id: string | undefined): boolean { return id?.startsWith("ins_") === true; } +/** + * The stand-in ID for a key whose instance Clerk did not name: a hash of the + * key. A run recorded under it is matched by the same key later, once + * `GET /v1/instance` answers again. + */ export function keyInstanceId(secretKey: string): string { return `key_${createHash("sha256").update(secretKey).digest("hex").slice(0, 16)}`; } diff --git a/packages/cli-core/src/commands/migrate/lib/terminal.ts b/packages/cli-core/src/commands/migrate/lib/terminal.ts index 6df83dbadd..38606fe1a6 100644 --- a/packages/cli-core/src/commands/migrate/lib/terminal.ts +++ b/packages/cli-core/src/commands/migrate/lib/terminal.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { isAgent, isHuman } from "../../../mode.ts"; /** * Whether a prompt has a terminal to read: stdin, or `/dev/tty` behind a piped @@ -16,3 +17,8 @@ export const terminal = { } }, }; + +/** Whether someone can answer a prompt: not an agent, a non-TTY run, or `--json`. */ +export function canPrompt(options: { json?: boolean }): boolean { + return !options.json && isHuman() && !isAgent() && terminal.available(); +} diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 91c3081698..d8d417606b 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -31,13 +31,12 @@ import { resolveKeylessTarget } from "../../lib/keyless-target.ts"; import { quoteArg } from "../../lib/json-body.ts"; import { carryFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; import { plural } from "./lib/plural.ts"; -import { terminal } from "./lib/terminal.ts"; +import { canPrompt } from "./lib/terminal.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"; import { ALREADY_IMPORTED, checkImport, type ImportChecks } from "./lib/checks.ts"; import { fetchInstanceSettings, fetchUserCount } from "./lib/clerk-config.ts"; @@ -55,7 +54,6 @@ import { type Run, type RunRecord, type UserLine, - openUndoOf, unfinishedUndoMessage, inUseMessage, } from "./lib/run-store.ts"; @@ -110,11 +108,6 @@ export type MigrateRunOptions = { runsDir?: string; }; -/** Nobody can answer a prompt: an agent, a non-TTY run, or `--json`. */ -function canPrompt(options: MigrateRunOptions): boolean { - return !options.json && isHuman() && !isAgent() && terminal.available(); -} - /** * Validates what a run needs before anything is read or sent. * @@ -300,7 +293,7 @@ async function applySource(options: MigrateRunOptions): Promise record.kind === "import" && record.file?.sha256 === match.sha256 && - record.source === match.source && - (record.target.instanceId === match.instanceId || - record.target.instanceId === match.keyInstanceId), + record.source === match.source, + ); + const latest = ofFile.find( + (record) => + record.target.instanceId === match.instanceId || + record.target.instanceId === match.keyInstanceId, ); if (!latest) { // Clerk did not name the instance (GET /v1/instance failed), so a run of // this file recorded under a real ID can be neither matched nor ruled out. // Starting over would reject every user it created and leave it unfinished. if (isStandInInstanceId(match.instanceId)) { - const unconfirmed = listRuns(runsDir).find( - (record) => - record.kind === "import" && - record.file?.sha256 === match.sha256 && - record.source === match.source && - isClerkInstanceId(record.target.instanceId), - ); + const unconfirmed = ofFile.find((record) => isClerkInstanceId(record.target.instanceId)); if (unconfirmed) { throwUsageError( `Clerk did not confirm which instance this key addresses, so this import cannot tell whether run ${unconfirmed.id} is for it. ` + @@ -368,12 +359,11 @@ export function findResume( } if (state === "undone") return { kind: "new" }; - const openUndo = openUndoOf(runsDir, latest.id); + const undos = runs.filter((record) => record.kind === "undo" && record.undoes === latest.id); + const openUndo = undos.find((record) => runState(runsDir, record) !== "complete"); if (openUndo) throwUsageError(unfinishedUndoMessage(latest.id, openUndo.id, carry)); // Undone in full, though the import run was never marked so. - if (listRuns(runsDir).some((record) => record.kind === "undo" && record.undoes === latest.id)) { - return { kind: "new" }; - } + if (undos.length > 0) return { kind: "new" }; if (state === "complete") return { kind: "complete", record: latest }; return { @@ -587,6 +577,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { } const { source, file } = validateRunOptions(options); + // The flags every suggested command needs to reach this runs folder and instance. + const carry = carryFlags(options); await withGutter( "Migrating users to Clerk", @@ -609,7 +601,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { instanceId: target.instanceId, keyInstanceId: keyInstanceId(secretKey), }, - carryFlags(options), + carry, ); if (resume.kind === "complete") { @@ -789,7 +781,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { if (refused) { if (options.json) preview({ refused: true }); const earlier = earlierRunHolding(runsDir, source, target.instanceId, checks); - const undoEarlier = earlier ? undoCommand(earlier.id, carryFlags(options)) : undefined; + const undoEarlier = earlier ? undoCommand(earlier.id, carry) : undefined; throwUsageError( `${plural(checks.rejects.length, "user")} would be rejected, so nothing was imported.` + `${rejectSummary(checks, options.json)} ` + @@ -820,7 +812,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { // Settled, so a continued run is finished rather than left interrupted, // and the JSON reports it as finished, as run.json now does. const finished = continued - ? continueRun(runsDir, continued, plannedLines, carryFlags(options)).finish() + ? continueRun(runsDir, continued, plannedLines, carry).finish() : undefined; if (options.json) preview({ nothingToImport: true, ...(finished ? { run: finished } : {}) }); @@ -860,7 +852,7 @@ 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, plannedLines, carryFlags(options)) + ? continueRun(runsDir, continued, plannedLines, carry) : startRun(runsDir, { kind: "import", target, @@ -966,15 +958,16 @@ export async function run(rawOptions: MigrateRunOptions): Promise { `clerk migrate runs ${record.id}`, carryFlags({ runsDir: options.runsDir }), ); + const undo = undoCommand(record.id, carry); const steps = summary.failed > 0 ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS( runsCommand, // The same import again continues this run; --new-run would not. commandFor({ ...options, newRun: false }, options.yes ? ["--yes"] : []), - undoCommand(record.id, carryFlags(options)), + undo, ) - : NEXT_STEPS.MIGRATE_DONE(runsCommand, undoCommand(record.id, carryFlags(options))); + : NEXT_STEPS.MIGRATE_DONE(runsCommand, undo); setNextSteps(steps); printAgentNextSteps(steps); }, diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index bec5bea1a1..7b044c6c7a 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -11,7 +11,7 @@ import { bold, dim, green, red, yellow } from "../../lib/color.ts"; import { throwUsageError } from "../../lib/errors.ts"; import { quoteArg } from "../../lib/json-body.ts"; import { log } from "../../lib/log.ts"; -import { tallyError } from "./import-users.ts"; +import { tallyError } from "./lib/failures.ts"; import { countLines, latestUserLines, @@ -27,6 +27,7 @@ import { noRunMessage, } from "./lib/run-store.ts"; import { carryFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; +import { plural } from "./lib/plural.ts"; import { describeTarget } from "./lib/target.ts"; export type RunsOptions = { @@ -51,7 +52,7 @@ function colorState(state: RunState): string { } /** The counts that matter for a run's kind, in the order they are read. */ -export function formatCounts(record: RunRecord): string { +function formatCounts(record: RunRecord): string { const counts = record.counts; const order = record.kind === "export" @@ -112,7 +113,7 @@ function continueCommand(record: RunRecord, carry: string[]): string | undefined * What to run after reading about this run. `carry` is the runs folder this * listing read, and the target flags the record says the import used. */ -export function nextCommands(record: RunRecord, state: RunState, carry: string[] = []): string[] { +function nextCommands(record: RunRecord, state: RunState, carry: string[]): string[] { if (state === "running") return []; if (record.kind === "import" && state !== "undone") { const undo = `Run \`${undoCommand(record.id, carry)}\` to delete the users it created`; @@ -163,15 +164,16 @@ function printList(runsDir: string, records: RunRecord[]): void { ]; for (const line of pad(rows)) log.info(line); log.blank(); - log.info(dim(`${records.length} run${records.length === 1 ? "" : "s"}`)); + log.info(dim(plural(records.length, "run"))); } function showJson(runsDir: string, record: RunRecord, carry: string[]) { const lines = [...latestUserLines(runsDir, record.id).values()]; + const state = runState(runsDir, record); return { runsDir, - run: { ...record, state: runState(runsDir, record) }, - next: nextCommands(record, runState(runsDir, record), carry), + run: { ...record, state }, + next: nextCommands(record, state, carry), errors: errorBreakdown(lines), failed: lines.filter((line) => line.status === "failed"), skipped: lines.filter((line) => line.status === "skipped"), diff --git a/packages/cli-core/src/commands/migrate/undo-interactive.test.ts b/packages/cli-core/src/commands/migrate/undo-interactive.test.ts index 6029a3d16c..d2eafce392 100644 --- a/packages/cli-core/src/commands/migrate/undo-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/undo-interactive.test.ts @@ -8,9 +8,12 @@ import { afterAll, afterEach, beforeAll, beforeEach, expect, mock, spyOn, test } import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { libPromptsStubs } from "../../test/lib/stubs.ts"; +// The whole module, so a file sharing this process still finds `text` and the rest. let confirmed: () => void = () => {}; mock.module("../../lib/prompts.ts", () => ({ + ...libPromptsStubs, confirm: async () => { confirmed(); return true; diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index faf1629d21..c2b18b1f83 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -129,8 +129,12 @@ function importRun(overrides: Partial = {}): RunRecord { } // `user_b` has signed in since the import; the guard on that has its own tests. -const options = { secretKey: "sk_test_x", runsDir: "", includeSignedIn: true }; -const withDir = (extra: Record = {}) => ({ ...options, runsDir, ...extra }); +const withDir = (extra: Record = {}) => ({ + secretKey: "sk_test_x", + includeSignedIn: true, + runsDir, + ...extra, +}); const deletes = () => requests.filter((request) => request.method === "DELETE"); describe("refusals, all exit 2 and delete nothing", () => { diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 4bf6c09731..cfdfa8edc3 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -23,10 +23,9 @@ import { log } from "../../lib/log.ts"; import { interruptedExitCode } from "../../lib/signals.ts"; import { confirm } from "../../lib/prompts.ts"; import { withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; -import { isAgent, isHuman } from "../../mode.ts"; -import { failureOf, tallyError } from "./import-users.ts"; +import { failureOf, tallyError } from "./lib/failures.ts"; import { carryFlags, runsListCommand, undoCommand } from "./lib/carry-flags.ts"; -import { terminal } from "./lib/terminal.ts"; +import { canPrompt } from "./lib/terminal.ts"; import { resolveLimits } from "./lib/instance.ts"; import { retryOn429 } from "./lib/retry.ts"; import { @@ -70,10 +69,7 @@ export type UndoOptions = { runsDir?: string; }; -/** One user the import created, still to be deleted. */ -export type UndoUser = SourceUser; - -export type UndoPreview = { +type UndoPreview = { /** Users that will be deleted. */ toDelete: number; /** Of those, how many have signed in since the import started. */ @@ -143,7 +139,7 @@ function usersToDelete( runsDir: string, record: RunRecord, openUndo: RunRecord | undefined, -): { users: UndoUser[]; unconfirmed: string[]; alreadyDeleted: number } { +): { users: SourceUser[]; unconfirmed: string[]; alreadyDeleted: number } { const deleted = new Set(); if (openUndo) { for (const line of latestUserLines(runsDir, openUndo.id).values()) { @@ -151,7 +147,7 @@ function usersToDelete( } } - const users: UndoUser[] = []; + const users: SourceUser[] = []; const unconfirmed: string[] = []; for (const line of latestUserLines(runsDir, record.id).values()) { if (deleted.has(line.sourceId)) continue; @@ -169,14 +165,14 @@ function usersToDelete( * the import started — the ones whose deletion someone will notice. */ async function inspectUsers(options: { - users: UndoUser[]; + users: SourceUser[]; since: string; secretKey: string; schedule: ApiScheduler; spinner?: SpinnerControls; }): Promise<{ - present: UndoUser[]; - gone: UndoUser[]; + present: SourceUser[]; + gone: SourceUser[]; /** The Clerk IDs of the users who signed in since the import. */ signedIn: string[]; }> { @@ -203,7 +199,7 @@ async function inspectUsers(options: { return { present, gone, signedIn }; } -export type UndoSummary = { +type UndoSummary = { deleted: number; failed: number; errorBreakdown: Map; @@ -211,7 +207,7 @@ export type UndoSummary = { /** Deletes each user, rate-limited and 429-retried exactly as the import is. */ async function deleteUsers(options: { - users: UndoUser[]; + users: SourceUser[]; secretKey: string; /** Undo's own scheduler, so its lookups and deletes share one rate budget. */ schedule: ApiScheduler; @@ -220,15 +216,14 @@ async function deleteUsers(options: { }): Promise { const { users, secretKey, schedule, run, progress: report } = options; const errorBreakdown = new Map(); - let processed = 0; let deleted = 0; let failed = 0; - const progress = () => report?.({ done: processed, ok: deleted, failed }); + const progress = () => report?.({ done: deleted + failed, ok: deleted, failed }); // A failure on one user must not stop the rest: a half-undone import with // no record of which half is far worse than a reported failure. - const deleteOne = async (user: UndoUser): Promise => { + const deleteOne = async (user: SourceUser): Promise => { const retries: string[] = []; try { await retryOn429( @@ -274,7 +269,6 @@ async function deleteUsers(options: { }); } } - processed++; progress(); }; @@ -284,7 +278,6 @@ async function deleteUsers(options: { } function printPreview(record: RunRecord, preview: UndoPreview): void { - const are = (count: number) => (count === 1 ? "is" : "are"); log.blank(); log.info( `${bold(`Will delete ${plural(preview.toDelete, "user")}`)} created by import run ${record.id}.`, @@ -297,7 +290,7 @@ function printPreview(record: RunRecord, preview: UndoPreview): void { if (preview.alreadyGone > 0) { log.info( dim( - `${plural(preview.alreadyGone, "user")} the import created ${are(preview.alreadyGone)} already gone.`, + `${plural(preview.alreadyGone, "user")} the import created ${preview.alreadyGone === 1 ? "is" : "are"} already gone.`, ), ); } @@ -310,10 +303,6 @@ function printPreview(record: RunRecord, preview: UndoPreview): void { } } -/** - * The JSON every outcome prints. `import` is the run being undone; `run` is the - * undo's own run, null until one is written, as an import's is. - */ /** * What the JSON adds to the preview: why undo stopped before deleting, or, * once it did, its run and result. One of these, never two. @@ -330,34 +319,25 @@ type UndoOutcome = result: { deleted: number; failed: number; errors: { error: string; count: number }[] }; }; -function jsonResult( - target: ClerkTarget, - record: RunRecord, - preview: UndoPreview, - extra: UndoOutcome, -) { - return { target, import: record, run: null, preview, ...extra }; -} - export async function undo(runId: string, options: UndoOptions = {}): Promise { const runsDir = await resolveRunsDir(options.runsDir); const runsCarry = carryFlags({ runsDir: options.runsDir }); const record = readImportRun(runsDir, runId, runsCarry); const importLines = readUserLines(runsDir, record.id).length; // The command a hint suggests, reaching the same runs folder and instance. - const undoAgain = undoCommand(record.id, carryFlags(options)); + const carry = carryFlags(options); + const undoAgain = undoCommand(record.id, carry); const { secretKey, target } = await resolveClerkTarget(options); if (!options.json) printTarget(target); assertSameInstance(record, target, secretKey); - const limits = resolveLimits(secretKey); + const { concurrencyLimit, rateLimit } = resolveLimits(secretKey); const openUndo = openUndoOf(runsDir, record.id); const undoLines = openUndo ? readUserLines(runsDir, openUndo.id).length : 0; const recorded = usersToDelete(runsDir, record, openUndo); - const { alreadyDeleted } = recorded; - const schedule = createApiScheduler(limits.concurrencyLimit, limits.rateLimit); + const schedule = createApiScheduler(concurrencyLimit, rateLimit); const users = [ ...recorded.users, ...(await findInFlight({ @@ -378,47 +358,48 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 ? ["--include-signed-in"] : []), - ...carryFlags(options), + ...carry, ...(options.json ? ["--json"] : []), - ].join(" "); + ]); const nothingLeft = present.length === 0 && gone.length === 0; + // After the preview, --json prints its object exactly once, whatever + // happens: the dry run, the result, a refusal with its marker, or, for + // anything else that stops the undo, the preview marked `writeFailed` (the + // file system refused) or `stopped`. The reason goes to stderr as for any + // error. `import` is the run being undone; `run` is the undo's own run, null + // until one is written, as an import's is. + let printed = false; + const printJson = (extra: UndoOutcome, imported = record) => { + if (!options.json || printed) return; + printed = true; + log.data(JSON.stringify({ target, import: imported, run: null, preview, ...extra }, null, 2)); + }; + if (options.dryRun) { - if (options.json) { - log.data(JSON.stringify(jsonResult(target, record, preview, { dryRun: true }), null, 2)); - } else { + printJson({ dryRun: true }); + if (!options.json) { log.blank(); log.info(dim(`Dry run: nothing was deleted. Run \`${command}\` to delete them.`)); } return; } - // After the preview, --json prints its object exactly once, whatever - // happens: the result, a refusal with its marker, or, for anything else - // that stops the undo, the preview marked `writeFailed` (the file system - // refused) or `stopped`. The reason goes to stderr as for any error. - let printed = false; - const printJsonPreview = (extra: UndoOutcome) => { - if (!options.json || printed) return; - printed = true; - log.data(JSON.stringify(jsonResult(target, record, preview, extra), null, 2)); - }; // Rule 1: nothing is deleted without consent — `--yes`, or a yes at a // prompt. `--json` never prompts. if (!nothingLeft && !options.yes) { - const canAsk = !options.json && isHuman() && !isAgent() && terminal.available(); - if (!canAsk) { - printJsonPreview({ consent: "required" }); + if (!canPrompt(options)) { + printJson({ consent: "required" }); throwUsageError( `\`clerk migrate undo\` permanently deletes ${plural(present.length, "user")} and needs consent. Pass --yes to confirm.`, undefined, @@ -437,7 +418,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 && !options.includeSignedIn) { - printJsonPreview({ consent: "required" }); + printJson({ consent: "required" }); throwUsageError( `${plural(preview.signedInSince, "user")} the import created ${preview.signedInSince === 1 ? "has" : "have"} signed in since, and --yes alone does not delete them. ` + "Pass --include-signed-in to delete them too. Nothing was deleted.", @@ -448,14 +429,14 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise { - printJsonPreview({ changed: true, ...extra }); + printJson({ changed: true, ...extra }); throwUsageError(message); }; try { await deleteAfterPreview(); } catch (error) { - printJsonPreview(isFileSystemError(error) ? { writeFailed: true } : { stopped: true }); + printJson(isFileSystemError(error) ? { writeFailed: true } : { stopped: true }); throw error; } @@ -513,7 +494,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise ({ error, count })), - }, - }), - null, - 2, - ), + printJson( + { + run: undoRecord, + result: { + deleted: summary.deleted, + failed: summary.failed, + errors: [...summary.errorBreakdown].map(([error, count]) => ({ error, count })), + }, + }, + importRecord, ); } else { log.blank(); From 632ac20fca64e8626d0770b2ed10340874ba5140 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 15:14:05 -0400 Subject: [PATCH 40/63] docs(migrate): list --include-signed-in in the synopsis, keep the continue list in one piece, and say what runs suggests next Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 6a207f6ab2..6bbbffa769 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -17,7 +17,7 @@ Clerk instance. ``` 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 undo [--dry-run] [--yes] [--include-signed-in] [--json] clerk migrate help ``` @@ -288,6 +288,9 @@ A continued run also finishes what the last one left open: record as already in the instance. Only when the lookup finds no user at all is it created. +- A user whose `created` line has `pending` identifiers gets just those + attaches. + Every create sends the run's ID in the user's private metadata, as `clerkMigrateRun`, merged with any private metadata the source carries. It replaces a `clerkMigrateRun` the source already has, from a run that imported @@ -295,9 +298,6 @@ the user into the source instance. It is how a cut-off create is told apart from a user an app or another tool made with the same `external_id`, and it stays on the user. A run recorded before the marker existed adopts nothing. -- A user whose `created` line has `pending` identifiers gets just those - attaches. - When an import completes, it names its run folder, which only `undo` needs and which holds user data, with the `rm -rf` to remove it. @@ -476,8 +476,9 @@ clerk migrate runs --json 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. +users that failed or were skipped, with the path to the full record, then what +to run next: for an interrupted or partial import, the import that continues +it, and the undo. An unknown ID exits 2. ### `clerk migrate undo` From bb0adf45797e059ed61f04b8a6f5e1c9721aedd6 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 15:43:56 -0400 Subject: [PATCH 41/63] fix(migrate): suggest a run's continue only when importing its file again reaches it, with the flags it ran with - An import records its `--allow-partial`, `--require-password` and `--skip-legal-checks` on the run, and `runs `'s continue repeats them. Left out, `--require-password` no longer held back the users it skipped. - `runs ` suggests the continue only when the run is the newest import of its file, source and instance, the file is unchanged, and no undo of it is left unfinished. Otherwise the import would start another run or refuse. - The comment on users without a password says they are recorded as skipped. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 9 ++- .../src/commands/migrate/lib/carry-flags.ts | 17 ++++++ .../src/commands/migrate/lib/run-store.ts | 2 + .../cli-core/src/commands/migrate/run.test.ts | 2 + packages/cli-core/src/commands/migrate/run.ts | 12 ++-- .../src/commands/migrate/runs.test.ts | 60 +++++++++++++++++-- .../cli-core/src/commands/migrate/runs.ts | 41 +++++++++++-- 7 files changed, 126 insertions(+), 17 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 6bbbffa769..f0dea63ce8 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -137,7 +137,7 @@ 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 | +| `run.json` | Kind, status, start and finish times, the target, the source, the file and its sha256, an import's flags, and the counts | | `users.ndjson` | One line per user outcome: `sourceId`, `clerkId`, `status`, and `reason`, `error`, `code`, `pending` or `passwordDropped` when present | | `lock` | The PID of the process writing the run, while it runs | @@ -477,8 +477,11 @@ clerk migrate runs --json 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, then what -to run next: for an interrupted or partial import, the import that continues -it, and the undo. An unknown ID exits 2. +to run next: the undo, and for an interrupted or partial import, the import +that continues it, with the flags it ran with. The continue is left out when +importing the file again would not reach this run: a newer run of the same +file, a file changed since, or an undo of it left unfinished. An unknown ID +exits 2. ### `clerk migrate undo` diff --git a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts index f38dc0375d..43cbc5cad9 100644 --- a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts +++ b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts @@ -21,6 +21,23 @@ export function carryFlags(options: { ]; } +/** + * The import flags that choose which users go in. The run records them, so + * the command that continues it, wherever it is suggested, repeats them: + * left out, `--require-password` no longer holds back the users it skipped. + */ +export function importFlags(options: { + allowPartial?: boolean; + requirePassword?: boolean; + skipLegalChecks?: boolean; +}): string[] { + return [ + ...(options.allowPartial ? ["--allow-partial"] : []), + ...(options.requirePassword ? ["--require-password"] : []), + ...(options.skipLegalChecks ? ["--skip-legal-checks"] : []), + ]; +} + /** `clerk migrate runs`, listing the runs folder these flags name. */ export function runsListCommand(carry: string[]): string { return withFlags("clerk migrate runs", carry); 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 9ccdacb832..48c0fc9a53 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -77,6 +77,8 @@ export type RunRecord = { file?: RunFile; /** The export run an import read its file from. */ fromExport?: string; + /** An import's `importFlags`, from its latest attempt. */ + flags?: string[]; /** The import run an undo reverses. */ undoes?: string; /** The undo run that reversed this one. */ diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 84df14bbf4..2fc799f3c0 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -317,6 +317,8 @@ describe("run", () => { reason: "no password (--require-password)", }); expect(record?.status).toBe("partial"); + // So `runs ` suggests a continue that still leaves them out. + expect(record?.flags).toEqual(["--require-password"]); }); // They never reach the checks, so the preview has to count them itself. diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index d8d417606b..114d814ea3 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -29,7 +29,7 @@ import { } from "../../lib/errors.ts"; import { resolveKeylessTarget } from "../../lib/keyless-target.ts"; import { quoteArg } from "../../lib/json-body.ts"; -import { carryFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; +import { carryFlags, importFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; import { plural } from "./lib/plural.ts"; import { canPrompt } from "./lib/terminal.ts"; import { log } from "../../lib/log.ts"; @@ -500,10 +500,8 @@ function commandFor(options: MigrateRunOptions, extra: string[]) { const input = options.input ?? options.file ?? ""; const parts = ["clerk migrate import", quoteArg(input)]; 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"); + parts.push(...importFlags(options)); parts.push(...carryFlags(options)); if (options.json) parts.push("--json"); return [...parts, ...extra].join(" "); @@ -672,7 +670,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { 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. + // password are left out of the checks and recorded as skipped. let withoutPassword: User[] = []; if (options.requirePassword) { withoutPassword = users.filter((user) => !user.password && !adopted.has(user.userId)); @@ -851,6 +849,7 @@ 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 flags = importFlags(options); const run = continued ? continueRun(runsDir, continued, plannedLines, carry) : startRun(runsDir, { @@ -858,7 +857,10 @@ export async function run(rawOptions: MigrateRunOptions): Promise { target, source, file: { path: filePath, sha256 }, + flags, }); + // The flags this attempt ran with, which the next continue repeats. + if (continued) run.update({ flags }); // 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. diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index b0752e3cf1..1a0dad7f1c 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { quoteArg } from "../../lib/json-body.ts"; import { useCaptureLog } from "../../test/lib/stubs.ts"; -import { startRun } from "./lib/run-store.ts"; +import { sha256File, startRun, type StartRunInit } from "./lib/run-store.ts"; import { runs } from "./runs.ts"; const captured = useCaptureLog(); @@ -19,12 +19,17 @@ afterEach(() => { fs.rmSync(runsDir, { recursive: true, force: true }); }); -function partialImport() { +/** The file the fixture's import read, so a continue can find it unchanged. */ +const usersFile = () => path.join(runsDir, "users.json"); + +function partialImport(init: Partial = {}) { + fs.writeFileSync(usersFile(), "[]"); const run = startRun(runsDir, { kind: "import", target: { appLabel: "My App", env: "development", instanceId: "ins_1" }, source: "clerk", - file: { path: "/tmp/users.json", sha256: "abc" }, + file: { path: usersFile(), sha256: sha256File(usersFile()) }, + ...init, }); run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); run.append({ sourceId: "b", status: "failed", error: "That email address is taken." }); @@ -141,11 +146,58 @@ describe("runs ", () => { const { next } = JSON.parse(captured.out) as { next: string[] }; expect(next).toHaveLength(2); expect(next[0]).toContain( - `clerk migrate import /tmp/users.json --source clerk --runs-dir ${quoteArg(runsDir)}`, + `clerk migrate import ${quoteArg(usersFile())} --source clerk --runs-dir ${quoteArg(runsDir)}`, ); expect(next[0]).toContain("to retry the users that failed or were skipped"); }); + // Left out, the continue creates the users --require-password held back. + test("the continue repeats the flags the import ran with", async () => { + const record = partialImport({ flags: ["--require-password"] }); + + await runs(record.id, { runsDir, json: true }); + + const { next } = JSON.parse(captured.out) as { next: string[] }; + expect(next[0]).toContain("--source clerk --require-password --runs-dir"); + }); + + // Each of these would start another run, or refuse, rather than continue it. + describe("offers only the undo when importing the file again would not continue the run", () => { + const onlyUndo = async (id: string) => { + await runs(id, { runsDir, json: true }); + const { next } = JSON.parse(captured.out) as { next: string[] }; + expect(next).toEqual([expect.stringContaining(`clerk migrate undo ${id}`)]); + }; + + test("a newer run of the same file", async () => { + const record = partialImport(); + const newer = startRun(runsDir, { + kind: "import", + target: record.target, + source: "clerk", + file: record.file, + }); + newer.update({ startedAt: new Date(Date.parse(record.startedAt) + 1000).toISOString() }); + newer.finish(); + + await onlyUndo(record.id); + }); + + test("a file changed since", async () => { + const record = partialImport(); + fs.writeFileSync(usersFile(), "[{}]"); + + await onlyUndo(record.id); + }); + + test("an undo of it left unfinished", async () => { + const record = partialImport(); + startRun(runsDir, { kind: "undo", target: record.target, undoes: record.id }).release(); + + await onlyUndo(record.id); + }); + }); + test("an unknown ID is a usage error", async () => { await expect(runs("nope", { runsDir })).rejects.toThrow(/No run `nope`/); }); diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index 7b044c6c7a..8874390305 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -16,10 +16,12 @@ import { countLines, latestUserLines, listRuns, + openUndoOf, readRun, resolveRunsDir, runDir, runState, + sha256File, type RunRecord, type RunState, type UserLine, @@ -106,19 +108,48 @@ function continueCommand(record: RunRecord, carry: string[]): string | undefined if (!input) return undefined; const parts = ["clerk migrate import", quoteArg(input)]; if (!record.fromExport && record.source) parts.push("--source", quoteArg(record.source)); - return withFlags(parts.join(" "), carry); + return withFlags(parts.join(" "), [...(record.flags ?? []), ...carry]); +} + +/** + * Whether importing the record's file again continues this run, rather than + * starting another or refusing: it is the newest import of that file, source + * and instance, which is the one an import picks; the file is unchanged; and + * no undo of it is left unfinished, which an import refuses to continue. + */ +function continuesThisRun(runsDir: string, record: RunRecord): boolean { + const file = record.file; + if (!file) return false; + const newest = listRuns(runsDir).find( + (other) => + other.kind === "import" && + other.file?.sha256 === file.sha256 && + other.source === record.source && + other.target.instanceId === record.target.instanceId, + ); + if (newest?.id !== record.id || openUndoOf(runsDir, record.id)) return false; + try { + return sha256File(file.path) === file.sha256; + } catch { + return false; + } } /** * What to run after reading about this run. `carry` is the runs folder this * listing read, and the target flags the record says the import used. */ -function nextCommands(record: RunRecord, state: RunState, carry: string[]): string[] { +function nextCommands( + runsDir: string, + record: RunRecord, + state: RunState, + carry: string[], +): string[] { if (state === "running") return []; if (record.kind === "import" && state !== "undone") { const undo = `Run \`${undoCommand(record.id, carry)}\` to delete the users it created`; const again = continueCommand(record, carry); - if (!again || state === "complete") return [undo]; + if (!again || state === "complete" || !continuesThisRun(runsDir, record)) return [undo]; // Continuing is what a stopped run is for: the same file, source and // target pick it up where it left off. return [ @@ -173,7 +204,7 @@ function showJson(runsDir: string, record: RunRecord, carry: string[]) { return { runsDir, run: { ...record, state }, - next: nextCommands(record, state, carry), + next: nextCommands(runsDir, record, state, carry), errors: errorBreakdown(lines), failed: lines.filter((line) => line.status === "failed"), skipped: lines.filter((line) => line.status === "skipped"), @@ -242,7 +273,7 @@ function printRun(runsDir: string, record: RunRecord, carry: string[]): void { log.blank(); log.info(dim(`Every outcome: ${usersFile}`)); - for (const step of nextCommands(record, state, carry)) log.info(` → ${step}`); + for (const step of nextCommands(runsDir, record, state, carry)) log.info(` → ${step}`); } /** From 98be418c373b11ada8bce9e7206e3a2900638838 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 15:44:51 -0400 Subject: [PATCH 42/63] refactor(migrate): one runs-command builder, findResume through openUndoOf, and a regex error normalizer Behaviour is unchanged. - `runsCommand(carry, id?)` replaces `runsListCommand` and the hand-built `clerk migrate runs ` after an import. - `openUndoOf` takes the caller's runs, so `findResume` keeps its single scan without restating the rule. - `normalizeErrorMessage` is one regex replace (checked against the old scanner on 300,000 random strings). - `nextCommands` builds its continue message in one line. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/carry-flags.ts | 6 +-- .../src/commands/migrate/lib/failures.ts | 49 +++---------------- .../src/commands/migrate/lib/run-store.ts | 17 +++++-- packages/cli-core/src/commands/migrate/run.ts | 19 ++++--- .../cli-core/src/commands/migrate/runs.ts | 14 +++--- .../cli-core/src/commands/migrate/undo.ts | 4 +- 6 files changed, 40 insertions(+), 69 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts index 43cbc5cad9..7527751acd 100644 --- a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts +++ b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts @@ -38,9 +38,9 @@ export function importFlags(options: { ]; } -/** `clerk migrate runs`, listing the runs folder these flags name. */ -export function runsListCommand(carry: string[]): string { - return withFlags("clerk migrate runs", carry); +/** `clerk migrate runs [id]`, reading the runs folder these flags name. */ +export function runsCommand(carry: string[], id?: string): string { + return withFlags(id ? `clerk migrate runs ${id}` : "clerk migrate runs", carry); } /** `clerk migrate undo ` with the flags that reach its runs folder and instance. */ diff --git a/packages/cli-core/src/commands/migrate/lib/failures.ts b/packages/cli-core/src/commands/migrate/lib/failures.ts index 2d483d6a34..c6dbbf4ff5 100644 --- a/packages/cli-core/src/commands/migrate/lib/failures.ts +++ b/packages/cli-core/src/commands/migrate/lib/failures.ts @@ -26,45 +26,12 @@ export function tallyError(breakdown: Map, message: string): voi * Groups error messages that differ only in field ordering, so the summary * reports "12 users: [\"first_name\" \"last_name\"] ..." once instead of twice. */ -export function normalizeErrorMessage(errorMessage: string): string { - let normalized = ""; - let lastCopiedIndex = 0; - let arrayStartIndex = -1; - - for (let i = 0; i < errorMessage.length; i++) { - const char = errorMessage[i]; - - if (arrayStartIndex === -1) { - if (char === "[") arrayStartIndex = i; - continue; - } - if (char !== "]") continue; - - normalized += errorMessage.slice(lastCopiedIndex, arrayStartIndex); - normalized += normalizeFieldArray(errorMessage.slice(arrayStartIndex + 1, i)); - lastCopiedIndex = i + 1; - arrayStartIndex = -1; - } - - return normalized + errorMessage.slice(lastCopiedIndex); -} - -function normalizeFieldArray(fields: string): string { - const fieldNames: string[] = []; - let current = ""; - - for (const char of fields) { - if (char === '"' || char === "'" || char.trim() === "") { - if (current.length > 0) { - fieldNames.push(current); - current = ""; - } - continue; - } - current += char; - } - if (current.length > 0) fieldNames.push(current); - - fieldNames.sort(); - return `[${fieldNames.map((name) => `"${name}"`).join(" ")}]`; +export function normalizeErrorMessage(message: string): string { + return message.replace(/\[([^\]]*)\]/g, (_, fields: string) => { + const names = fields + .split(/["'\s]+/) + .filter(Boolean) + .sort(); + return `[${names.map((name) => `"${name}"`).join(" ")}]`; + }); } 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 48c0fc9a53..08b4c9d58a 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -25,7 +25,7 @@ import { resolveProfile } from "../../../lib/config.ts"; import { throwUsageError } from "../../../lib/errors.ts"; import { ensureGitignoreEntry, getGitRepoRoot } from "../../../lib/git.ts"; import { log } from "../../../lib/log.ts"; -import { runsListCommand, undoCommand } from "./carry-flags.ts"; +import { runsCommand, undoCommand } from "./carry-flags.ts"; /** Overrides the runs directory, below `--runs-dir`. */ export const RUNS_DIR_ENV = "CLERK_MIGRATE_DIR"; @@ -300,9 +300,16 @@ export function runState(runsDir: string, record: RunRecord): RunState { return liveLockPid(runsDir, record.id) === undefined ? "interrupted" : "running"; } -/** The undo of this import that has not finished, if any. */ -export function openUndoOf(runsDir: string, importId: string): RunRecord | undefined { - return listRuns(runsDir).find( +/** + * The undo of this import that has not finished, if any. `runs` is the + * caller's `listRuns`, when it has one already. + */ +export function openUndoOf( + runsDir: string, + importId: string, + runs = listRuns(runsDir), +): RunRecord | undefined { + return runs.find( (candidate) => candidate.kind === "undo" && candidate.undoes === importId && @@ -323,7 +330,7 @@ export function unfinishedUndoMessage(importId: string, undoId: string, carry: s /** Why a run ID matched nothing, and the listing of that same runs folder. */ export function noRunMessage(runsDir: string, id: string, carry: string[]): string { - return `No run \`${id}\` in ${runsDir}. Run \`${runsListCommand(carry)}\` to list them.`; + return `No run \`${id}\` in ${runsDir}. Run \`${runsCommand(carry)}\` to list them.`; } /** Why a run another live process holds is refused, and how to clear a stale lock. */ diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 114d814ea3..bcf66551e2 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -29,7 +29,7 @@ import { } from "../../lib/errors.ts"; import { resolveKeylessTarget } from "../../lib/keyless-target.ts"; import { quoteArg } from "../../lib/json-body.ts"; -import { carryFlags, importFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; +import { carryFlags, importFlags, runsCommand, undoCommand } from "./lib/carry-flags.ts"; import { plural } from "./lib/plural.ts"; import { canPrompt } from "./lib/terminal.ts"; import { log } from "../../lib/log.ts"; @@ -56,6 +56,7 @@ import { type UserLine, unfinishedUndoMessage, inUseMessage, + openUndoOf, } from "./lib/run-store.ts"; import { withProgress } from "./lib/progress.ts"; import { createApiScheduler } from "./lib/scheduler.ts"; @@ -359,11 +360,12 @@ function findResume( } if (state === "undone") return { kind: "new" }; - const undos = runs.filter((record) => record.kind === "undo" && record.undoes === latest.id); - const openUndo = undos.find((record) => runState(runsDir, record) !== "complete"); + const openUndo = openUndoOf(runsDir, latest.id, runs); if (openUndo) throwUsageError(unfinishedUndoMessage(latest.id, openUndo.id, carry)); // Undone in full, though the import run was never marked so. - if (undos.length > 0) return { kind: "new" }; + if (runs.some((record) => record.kind === "undo" && record.undoes === latest.id)) { + return { kind: "new" }; + } if (state === "complete") return { kind: "complete", record: latest }; return { @@ -956,20 +958,17 @@ export async function run(rawOptions: MigrateRunOptions): Promise { } // `runs` takes only --runs-dir. - const runsCommand = withFlags( - `clerk migrate runs ${record.id}`, - carryFlags({ runsDir: options.runsDir }), - ); + const runs = runsCommand(carryFlags({ runsDir: options.runsDir }), record.id); const undo = undoCommand(record.id, carry); const steps = summary.failed > 0 ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS( - runsCommand, + runs, // The same import again continues this run; --new-run would not. commandFor({ ...options, newRun: false }, options.yes ? ["--yes"] : []), undo, ) - : NEXT_STEPS.MIGRATE_DONE(runsCommand, undo); + : NEXT_STEPS.MIGRATE_DONE(runs, undo); setNextSteps(steps); printAgentNextSteps(steps); }, diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index 8874390305..bff9f3fb33 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -120,14 +120,15 @@ function continueCommand(record: RunRecord, carry: string[]): string | undefined function continuesThisRun(runsDir: string, record: RunRecord): boolean { const file = record.file; if (!file) return false; - const newest = listRuns(runsDir).find( + const runs = listRuns(runsDir); + const newest = runs.find( (other) => other.kind === "import" && other.file?.sha256 === file.sha256 && other.source === record.source && other.target.instanceId === record.target.instanceId, ); - if (newest?.id !== record.id || openUndoOf(runsDir, record.id)) return false; + if (newest?.id !== record.id || openUndoOf(runsDir, record.id, runs)) return false; try { return sha256File(file.path) === file.sha256; } catch { @@ -152,12 +153,9 @@ function nextCommands( if (!again || state === "complete" || !continuesThisRun(runsDir, record)) return [undo]; // Continuing is what a stopped run is for: the same file, source and // target pick it up where it left off. - return [ - state === "interrupted" - ? `Run \`${again}\` to continue it` - : `Run \`${again}\` to retry the users that failed or were skipped`, - undo, - ]; + const why = + state === "interrupted" ? "continue it" : "retry the users that failed or were skipped"; + return [`Run \`${again}\` to ${why}`, undo]; } if (record.kind === "undo" && state !== "complete" && record.undoes) { return [`Run \`${undoCommand(record.undoes, carry)}\` to retry the users that failed`]; diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index cfdfa8edc3..d35beda62f 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -24,7 +24,7 @@ import { interruptedExitCode } from "../../lib/signals.ts"; import { confirm } from "../../lib/prompts.ts"; import { withSpinner, type SpinnerControls } from "../../lib/spinner.ts"; import { failureOf, tallyError } from "./lib/failures.ts"; -import { carryFlags, runsListCommand, undoCommand } from "./lib/carry-flags.ts"; +import { carryFlags, runsCommand, undoCommand } from "./lib/carry-flags.ts"; import { canPrompt } from "./lib/terminal.ts"; import { resolveLimits } from "./lib/instance.ts"; import { retryOn429 } from "./lib/retry.ts"; @@ -92,7 +92,7 @@ function readImportRun(runsDir: string, runId: string, runsCarry: string[]): Run `Run ${runId} is an ${record.kind} run. Only an import run can be undone.`, undefined, undefined, - [{ command: runsListCommand(runsCarry), description: "Find the import run to undo" }], + [{ command: runsCommand(runsCarry), description: "Find the import run to undo" }], ); } if (record.status === "undone") { From dba20fc4f841771520d6b8a0cc93d270722a9303 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 16:17:34 -0400 Subject: [PATCH 43/63] fix(migrate): share findResume's file filter with runs , and leave out its continue when a stand-in or any undo means the import would not reach the run - `importsOfFile` picks the imports of a file and source for both `findResume` and `runs `. - `runs ` has no key, so a newer run counts as possibly the same instance when either side is recorded under a key's stand-in ID. - Any undo of the run leaves out the continue: a finished one that never marked the run makes an import start a new run. - A file that can't be read is logged with its path. - The run records its flags as values, which `importFlags` spells. - `runsStep` names the `runs` command after an import; the README lists the continue before the undo, as `runs ` does. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 10 +++--- .../src/commands/migrate/lib/carry-flags.ts | 6 ++-- .../src/commands/migrate/lib/run-store.ts | 25 +++++++++++-- .../cli-core/src/commands/migrate/run.test.ts | 3 +- packages/cli-core/src/commands/migrate/run.ts | 28 +++++++++------ .../src/commands/migrate/runs.test.ts | 28 ++++++++++++++- .../cli-core/src/commands/migrate/runs.ts | 36 +++++++++++-------- 7 files changed, 98 insertions(+), 38 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index f0dea63ce8..0fc6d89331 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -477,11 +477,11 @@ clerk migrate runs --json 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, then what -to run next: the undo, and for an interrupted or partial import, the import -that continues it, with the flags it ran with. The continue is left out when -importing the file again would not reach this run: a newer run of the same -file, a file changed since, or an undo of it left unfinished. An unknown ID -exits 2. +to run next: for an interrupted or partial import, the import that continues +it, with the flags it ran with, then the undo. The continue is left out when +importing the file again might not reach this run: a newer run of the same +file that may be for the same instance (one recorded under a key's stand-in ID +may be any), a file changed since, or any undo of it. An unknown ID exits 2. ### `clerk migrate undo` diff --git a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts index 7527751acd..563f33997b 100644 --- a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts +++ b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts @@ -26,11 +26,13 @@ export function carryFlags(options: { * the command that continues it, wherever it is suggested, repeats them: * left out, `--require-password` no longer holds back the users it skipped. */ -export function importFlags(options: { +export type ImportFlags = { allowPartial?: boolean; requirePassword?: boolean; skipLegalChecks?: boolean; -}): string[] { +}; + +export function importFlags(options: ImportFlags): string[] { return [ ...(options.allowPartial ? ["--allow-partial"] : []), ...(options.requirePassword ? ["--require-password"] : []), 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 08b4c9d58a..0efe2b4531 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -25,7 +25,7 @@ import { resolveProfile } from "../../../lib/config.ts"; import { throwUsageError } from "../../../lib/errors.ts"; import { ensureGitignoreEntry, getGitRepoRoot } from "../../../lib/git.ts"; import { log } from "../../../lib/log.ts"; -import { runsCommand, undoCommand } from "./carry-flags.ts"; +import { type ImportFlags, runsCommand, undoCommand } from "./carry-flags.ts"; /** Overrides the runs directory, below `--runs-dir`. */ export const RUNS_DIR_ENV = "CLERK_MIGRATE_DIR"; @@ -77,8 +77,11 @@ export type RunRecord = { file?: RunFile; /** The export run an import read its file from. */ fromExport?: string; - /** An import's `importFlags`, from its latest attempt. */ - flags?: string[]; + /** + * The flags an import's latest attempt ran with, as values: a record + * outlives the CLI that wrote it, and `importFlags` spells them for this one. + */ + flags?: ImportFlags; /** The import run an undo reverses. */ undoes?: string; /** The undo run that reversed this one. */ @@ -300,6 +303,22 @@ export function runState(runsDir: string, record: RunRecord): RunState { return liveLockPid(runsDir, record.id) === undefined ? "interrupted" : "running"; } +/** + * The imports of this file with this source, newest first: the runs a + * re-import of it chooses among, before it looks at the instance. + */ +export function importsOfFile( + runs: RunRecord[], + file: { sha256: string; source?: string }, +): RunRecord[] { + return runs.filter( + (record) => + record.kind === "import" && + record.file?.sha256 === file.sha256 && + record.source === file.source, + ); +} + /** * The undo of this import that has not finished, if any. `runs` is the * caller's `listRuns`, when it has one already. diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 2fc799f3c0..342483ff8d 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 { importFlags } from "./lib/carry-flags.ts"; import { latestUserLines, listRuns, readRun, startRun } from "./lib/run-store.ts"; import { explainErrors, run, validateRunOptions } from "./run.ts"; // After run.ts: imported first, signals.ts loads version.ts ahead of its Bun @@ -318,7 +319,7 @@ describe("run", () => { }); expect(record?.status).toBe("partial"); // So `runs ` suggests a continue that still leaves them out. - expect(record?.flags).toEqual(["--require-password"]); + expect(importFlags(record?.flags ?? {})).toEqual(["--require-password"]); }); // They never reach the checks, so the preview has to count them itself. diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index bcf66551e2..8cebea08f3 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -29,7 +29,13 @@ import { } from "../../lib/errors.ts"; import { resolveKeylessTarget } from "../../lib/keyless-target.ts"; import { quoteArg } from "../../lib/json-body.ts"; -import { carryFlags, importFlags, runsCommand, undoCommand } from "./lib/carry-flags.ts"; +import { + carryFlags, + type ImportFlags, + importFlags, + runsCommand, + undoCommand, +} from "./lib/carry-flags.ts"; import { plural } from "./lib/plural.ts"; import { canPrompt } from "./lib/terminal.ts"; import { log } from "../../lib/log.ts"; @@ -57,6 +63,7 @@ import { unfinishedUndoMessage, inUseMessage, openUndoOf, + importsOfFile, } from "./lib/run-store.ts"; import { withProgress } from "./lib/progress.ts"; import { createApiScheduler } from "./lib/scheduler.ts"; @@ -325,12 +332,7 @@ function findResume( carry: string[], ): ResumeCase { const runs = listRuns(runsDir); - const ofFile = runs.filter( - (record) => - record.kind === "import" && - record.file?.sha256 === match.sha256 && - record.source === match.source, - ); + const ofFile = importsOfFile(runs, match); const latest = ofFile.find( (record) => record.target.instanceId === match.instanceId || @@ -851,7 +853,11 @@ 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 flags = importFlags(options); + const flags: ImportFlags = { + allowPartial: options.allowPartial, + requirePassword: options.requirePassword, + skipLegalChecks: options.skipLegalChecks, + }; const run = continued ? continueRun(runsDir, continued, plannedLines, carry) : startRun(runsDir, { @@ -958,17 +964,17 @@ export async function run(rawOptions: MigrateRunOptions): Promise { } // `runs` takes only --runs-dir. - const runs = runsCommand(carryFlags({ runsDir: options.runsDir }), record.id); + const runsStep = runsCommand(carryFlags({ runsDir: options.runsDir }), record.id); const undo = undoCommand(record.id, carry); const steps = summary.failed > 0 ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS( - runs, + runsStep, // The same import again continues this run; --new-run would not. commandFor({ ...options, newRun: false }, options.yes ? ["--yes"] : []), undo, ) - : NEXT_STEPS.MIGRATE_DONE(runs, undo); + : NEXT_STEPS.MIGRATE_DONE(runsStep, undo); setNextSteps(steps); printAgentNextSteps(steps); }, diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 1a0dad7f1c..1a8b041335 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -153,7 +153,7 @@ describe("runs ", () => { // Left out, the continue creates the users --require-password held back. test("the continue repeats the flags the import ran with", async () => { - const record = partialImport({ flags: ["--require-password"] }); + const record = partialImport({ flags: { requirePassword: true } }); await runs(record.id, { runsDir, json: true }); @@ -196,6 +196,32 @@ describe("runs ", () => { await onlyUndo(record.id); }); + + // Cut off before it marked the import undone: an import starts a new run. + test("an undo of it that finished", async () => { + const record = partialImport(); + startRun(runsDir, { kind: "undo", target: record.target, undoes: record.id }).finish(); + + await onlyUndo(record.id); + }); + + // `runs` has no key, so it can't tell whether the stand-in is `ins_1`. An + // import with that key would take the newer run. + test("a newer run under a real ID, when this one has a key's stand-in", async () => { + const record = partialImport({ + target: { appLabel: "My App", env: "development", instanceId: "key_abc" }, + }); + const newer = startRun(runsDir, { + kind: "import", + target: { appLabel: "My App", env: "development", instanceId: "ins_1" }, + source: "clerk", + file: record.file, + }); + newer.update({ startedAt: new Date(Date.parse(record.startedAt) + 1000).toISOString() }); + newer.finish(); + + await onlyUndo(record.id); + }); }); test("an unknown ID is a usage error", async () => { diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index bff9f3fb33..a7255e4073 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -16,7 +16,7 @@ import { countLines, latestUserLines, listRuns, - openUndoOf, + importsOfFile, readRun, resolveRunsDir, runDir, @@ -28,9 +28,9 @@ import { USERS_FILE, noRunMessage, } from "./lib/run-store.ts"; -import { carryFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; +import { carryFlags, importFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; import { plural } from "./lib/plural.ts"; -import { describeTarget } from "./lib/target.ts"; +import { describeTarget, isStandInInstanceId } from "./lib/target.ts"; export type RunsOptions = { json?: boolean; @@ -108,30 +108,36 @@ function continueCommand(record: RunRecord, carry: string[]): string | undefined if (!input) return undefined; const parts = ["clerk migrate import", quoteArg(input)]; if (!record.fromExport && record.source) parts.push("--source", quoteArg(record.source)); - return withFlags(parts.join(" "), [...(record.flags ?? []), ...carry]); + return withFlags(parts.join(" "), [...importFlags(record.flags ?? {}), ...carry]); } /** * Whether importing the record's file again continues this run, rather than - * starting another or refusing: it is the newest import of that file, source - * and instance, which is the one an import picks; the file is unchanged; and - * no undo of it is left unfinished, which an import refuses to continue. + * starting another or refusing: no newer import of that file and source may + * be for the same instance, the file is unchanged, and no undo of it exists. + * An unfinished undo makes an import refuse; a finished one that never marked + * the run makes it start a new one. */ function continuesThisRun(runsDir: string, record: RunRecord): boolean { const file = record.file; if (!file) return false; const runs = listRuns(runsDir); - const newest = runs.find( - (other) => - other.kind === "import" && - other.file?.sha256 === file.sha256 && - other.source === record.source && - other.target.instanceId === record.target.instanceId, + // An import also takes a run recorded under its key's stand-in ID. Without + // the key, a stand-in on either side may be the same instance: offering the + // continue then could send the user to a newer run instead. + const mayBeSameInstance = (id: string | undefined) => + id === record.target.instanceId || + isStandInInstanceId(id) || + isStandInInstanceId(record.target.instanceId); + const newest = importsOfFile(runs, { sha256: file.sha256, source: record.source }).find((other) => + mayBeSameInstance(other.target.instanceId), ); - if (newest?.id !== record.id || openUndoOf(runsDir, record.id, runs)) return false; + if (newest?.id !== record.id) return false; + if (runs.some((other) => other.kind === "undo" && other.undoes === record.id)) return false; try { return sha256File(file.path) === file.sha256; - } catch { + } catch (error) { + log.debug(`migrate: could not read ${file.path} to check it is unchanged: ${String(error)}`); return false; } } From 731e04c01df578bec48f6a283c6aac1b83c4c196 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 16:39:39 -0400 Subject: [PATCH 44/63] fix(migrate): carry the environment for a stand-in --app run, and share the undo lookup and the recorded flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `runs ` for an `--app` run recorded under a key's stand-in suggests `--instance development` or `production`: `--app` can't resolve `key_…`, so both suggested commands failed. - `undosOf` is the one "undo of this import" test, for `findResume`, `runs ` and `openUndoOf`. - `pickImportFlags` picks what the run records; its return type names every key, so a new flag fails to compile until it's added. - Tests for both `mayBeSameInstance` branches that had none, and "same file and source" in the README. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 2 +- .../src/commands/migrate/lib/carry-flags.ts | 14 +++++++ .../src/commands/migrate/lib/run-store.ts | 12 +++--- packages/cli-core/src/commands/migrate/run.ts | 13 ++---- .../src/commands/migrate/runs.test.ts | 40 +++++++++++++++++++ .../cli-core/src/commands/migrate/runs.ts | 9 ++++- 6 files changed, 72 insertions(+), 18 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 0fc6d89331..f73a6cf04f 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -480,7 +480,7 @@ users that failed or were skipped, with the path to the full record, then what to run next: for an interrupted or partial import, the import that continues it, with the flags it ran with, then the undo. The continue is left out when importing the file again might not reach this run: a newer run of the same -file that may be for the same instance (one recorded under a key's stand-in ID +file and source that may be for the same instance (one recorded under a key's stand-in ID may be any), a file changed since, or any undo of it. An unknown ID exits 2. ### `clerk migrate undo` diff --git a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts index 563f33997b..3038b16c5f 100644 --- a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts +++ b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts @@ -40,6 +40,20 @@ export function importFlags(options: ImportFlags): string[] { ]; } +/** + * The `ImportFlags` fields of wider options, for the run to record. The return + * type names every key, so a new flag fails to compile until it is picked here. + */ +export function pickImportFlags( + options: ImportFlags, +): Record { + return { + allowPartial: options.allowPartial, + requirePassword: options.requirePassword, + skipLegalChecks: options.skipLegalChecks, + }; +} + /** `clerk migrate runs [id]`, reading the runs folder these flags name. */ export function runsCommand(carry: string[], id?: string): string { return withFlags(id ? `clerk migrate runs ${id}` : "clerk migrate runs", carry); 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 0efe2b4531..7a6cce4198 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -319,6 +319,11 @@ export function importsOfFile( ); } +/** Every undo of this import, finished or not, newest first. */ +export function undosOf(runs: RunRecord[], importId: string): RunRecord[] { + return runs.filter((record) => record.kind === "undo" && record.undoes === importId); +} + /** * The undo of this import that has not finished, if any. `runs` is the * caller's `listRuns`, when it has one already. @@ -328,12 +333,7 @@ export function openUndoOf( importId: string, runs = listRuns(runsDir), ): RunRecord | undefined { - return runs.find( - (candidate) => - candidate.kind === "undo" && - candidate.undoes === importId && - runState(runsDir, candidate) !== "complete", - ); + return undosOf(runs, importId).find((undo) => runState(runsDir, undo) !== "complete"); } /** diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 8cebea08f3..981a7f56e7 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -31,8 +31,8 @@ import { resolveKeylessTarget } from "../../lib/keyless-target.ts"; import { quoteArg } from "../../lib/json-body.ts"; import { carryFlags, - type ImportFlags, importFlags, + pickImportFlags, runsCommand, undoCommand, } from "./lib/carry-flags.ts"; @@ -64,6 +64,7 @@ import { inUseMessage, openUndoOf, importsOfFile, + undosOf, } from "./lib/run-store.ts"; import { withProgress } from "./lib/progress.ts"; import { createApiScheduler } from "./lib/scheduler.ts"; @@ -365,9 +366,7 @@ function findResume( const openUndo = openUndoOf(runsDir, latest.id, runs); if (openUndo) throwUsageError(unfinishedUndoMessage(latest.id, openUndo.id, carry)); // Undone in full, though the import run was never marked so. - if (runs.some((record) => record.kind === "undo" && record.undoes === latest.id)) { - return { kind: "new" }; - } + if (undosOf(runs, latest.id).length > 0) return { kind: "new" }; if (state === "complete") return { kind: "complete", record: latest }; return { @@ -853,11 +852,7 @@ 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 flags: ImportFlags = { - allowPartial: options.allowPartial, - requirePassword: options.requirePassword, - skipLegalChecks: options.skipLegalChecks, - }; + const flags = pickImportFlags(options); const run = continued ? continueRun(runsDir, continued, plannedLines, carry) : startRun(runsDir, { diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 1a8b041335..cf127d4857 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -114,6 +114,11 @@ describe("runs ", () => { test.each([ [{ keySource: "--secret-key" }, "--secret-key "], [{ keySource: "--app", appId: "app_1" }, "--app app_1 --instance ins_1"], + // Recorded while Clerk could not name the instance: `--app` needs the alias. + [ + { keySource: "--app", appId: "app_1", instanceId: "key_abc" }, + "--app app_1 --instance development", + ], ])("the next command keeps the import's target from %o", async (target, expected) => { const record = partialImport(); const file = path.join(runsDir, record.id, "run.json"); @@ -205,6 +210,21 @@ describe("runs ", () => { await onlyUndo(record.id); }); + // Here the newer run's stand-in may be `ins_1` too. + test("a newer run under a key's stand-in, when this one has a real ID", async () => { + const record = partialImport(); + const newer = startRun(runsDir, { + kind: "import", + target: { appLabel: "My App", env: "development", instanceId: "key_abc" }, + source: "clerk", + file: record.file, + }); + newer.update({ startedAt: new Date(Date.parse(record.startedAt) + 1000).toISOString() }); + newer.finish(); + + await onlyUndo(record.id); + }); + // `runs` has no key, so it can't tell whether the stand-in is `ins_1`. An // import with that key would take the newer run. test("a newer run under a real ID, when this one has a key's stand-in", async () => { @@ -224,6 +244,26 @@ describe("runs ", () => { }); }); + // Dev first, then prod, from the same file: the dev run is still the one + // an import into dev continues. + test("a newer run on another instance keeps the continue", async () => { + const record = partialImport(); + const other = startRun(runsDir, { + kind: "import", + target: { appLabel: "My App", env: "production", instanceId: "ins_2" }, + source: "clerk", + file: record.file, + }); + other.update({ startedAt: new Date(Date.parse(record.startedAt) + 1000).toISOString() }); + other.finish(); + + await runs(record.id, { runsDir, json: true }); + + const { next } = JSON.parse(captured.out) as { next: string[] }; + expect(next).toHaveLength(2); + expect(next[0]).toContain("to retry the users that failed or were skipped"); + }); + test("an unknown ID is a usage error", async () => { await expect(runs("nope", { runsDir })).rejects.toThrow(/No run `nope`/); }); diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index a7255e4073..37f292972e 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -17,6 +17,7 @@ import { latestUserLines, listRuns, importsOfFile, + undosOf, readRun, resolveRunsDir, runDir, @@ -133,7 +134,7 @@ function continuesThisRun(runsDir: string, record: RunRecord): boolean { mayBeSameInstance(other.target.instanceId), ); if (newest?.id !== record.id) return false; - if (runs.some((other) => other.kind === "undo" && other.undoes === record.id)) return false; + if (undosOf(runs, record.id).length > 0) return false; try { return sha256File(file.path) === file.sha256; } catch (error) { @@ -309,7 +310,11 @@ export async function runs(id: string | undefined, options: RunsOptions = {}): P const carry = carryFlags({ runsDir: options.runsDir, ...(keySource === "--secret-key" ? { secretKey: "" } : {}), - ...(keySource === "--app" && appId ? { app: appId, instance: instanceId } : {}), + // A stand-in is no ID `--app` can resolve; its environment, always + // `development` or `production`, is an alias `--instance` takes. + ...(keySource === "--app" && appId + ? { app: appId, instance: isStandInInstanceId(instanceId) ? record.target.env : instanceId } + : {}), }); if (options.json) log.data(JSON.stringify(showJson(runsDir, record, carry), null, 2)); else printRun(runsDir, record, carry); From d3f4e241a3c3d3c55a7f84b58d620e4acf70b915 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 17:03:30 -0400 Subject: [PATCH 45/63] fix(migrate): record the instance --app or a linked profile names when GET /v1/instance fails The Platform API (or the profile) has already resolved the instance's real ID for these, so a run is recorded under it rather than a key's stand-in. `runs ` can then always pass that ID back with `--instance`, and v5's mapping of a stand-in to `development` or `production`, which picked the wrong instance for a staging one, is gone. The runs tests share a `newerImport` helper, and the three "newer run" cases are one `test.each`. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/target.test.ts | 47 ++++++++++- .../src/commands/migrate/lib/target.ts | 36 ++++++-- .../src/commands/migrate/runs.test.ts | 84 ++++++------------- .../cli-core/src/commands/migrate/runs.ts | 6 +- 4 files changed, 101 insertions(+), 72 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/target.test.ts b/packages/cli-core/src/commands/migrate/lib/target.test.ts index 78c98c89c3..8cb3a5c391 100644 --- a/packages/cli-core/src/commands/migrate/lib/target.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/target.test.ts @@ -1,4 +1,13 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + spyOn, + test, +} from "bun:test"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -14,6 +23,7 @@ import { } from "./target.ts"; const captured = useCaptureLog(); +const plapiModule = await import("../../../lib/plapi.ts"); describe("printTarget", () => { test.each([ @@ -238,3 +248,38 @@ describe("resolveClerkTarget key source", () => { }); }); }); + +// A stand-in is no ID `--app --instance` can resolve again, so a run recorded +// under one would suggest commands that fail, or reach another instance. +describe("resolveClerkTarget --app", () => { + let originalFetch: typeof globalThis.fetch; + let fetchApplication: ReturnType; + + beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response("nope", { status: 500 })) as unknown as typeof fetch; + fetchApplication = spyOn(plapiModule, "fetchApplication").mockResolvedValue({ + application_id: "app_123", + name: "My App", + instances: [ + { + instance_id: "ins_staging", + environment_type: "staging", + publishable_key: "pk_test_x", + secret_key: "sk_test_x", + }, + ], + } as never); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + fetchApplication.mockRestore(); + }); + + test("records the instance the Platform API named when GET /v1/instance fails", async () => { + const { target } = await resolveClerkTarget({ app: "app_123", instance: "ins_staging" }); + expect(target).toMatchObject({ instanceId: "ins_staging", env: "staging", keySource: "--app" }); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/lib/target.ts b/packages/cli-core/src/commands/migrate/lib/target.ts index d5dd0894f8..5c16a57040 100644 --- a/packages/cli-core/src/commands/migrate/lib/target.ts +++ b/packages/cli-core/src/commands/migrate/lib/target.ts @@ -37,20 +37,29 @@ export type ClerkTarget = RunTarget & { /** * Which rung of `resolveBapiSecretKey`'s chain supplies the key, and the app - * it belongs to when that rung knows one. + * it belongs to when that rung knows one. `--app` and a linked profile also + * know the instance, from the Platform API or the profile. * * Mirrors the chain's order exactly. An exported `CLERK_SECRET_KEY` wins over * a linked profile, so the profile's app is not named for it: that key may * belong to any app at all. */ -async function describeKeySource( - options: TargetOptions, -): Promise<{ keySource: string; appId?: string; appLabel?: string }> { +async function describeKeySource(options: TargetOptions): Promise<{ + keySource: string; + appId?: string; + appLabel?: string; + instance?: { instanceId: string; env: string }; +}> { if (options.secretKey) return { keySource: "--secret-key" }; if (options.app) { const ctx = await resolveAppContext({ app: options.app, instance: options.instance }); - return { keySource: "--app", appId: ctx.appId, appLabel: ctx.appLabel }; + return { + keySource: "--app", + appId: ctx.appId, + appLabel: ctx.appLabel, + instance: { instanceId: ctx.instanceId, env: ctx.instanceLabel }, + }; } if (process.env.CLERK_SECRET_KEY) return { keySource: "CLERK_SECRET_KEY env var" }; @@ -65,7 +74,12 @@ async function describeKeySource( } const ctx = await resolveAppContext({ instance: options.instance }); - return { keySource: "linked profile", appId: ctx.appId, appLabel: ctx.appLabel }; + return { + keySource: "linked profile", + appId: ctx.appId, + appLabel: ctx.appLabel, + instance: { instanceId: ctx.instanceId, env: ctx.instanceLabel }, + }; } /** @@ -156,8 +170,14 @@ export async function resolveClerkTarget( options: TargetOptions, ): Promise<{ secretKey: string; target: ClerkTarget }> { const secretKey = await resolveBapiSecretKey(options); - const source = await describeKeySource(options); - const { instanceId, env } = await fetchInstanceIdentity(secretKey); + const { instance: named, ...source } = await describeKeySource(options); + const fetched = await fetchInstanceIdentity(secretKey); + // The Platform API or the profile already named the instance: recorded + // instead of a stand-in, a run's commands can reach it with `--instance`. + const { instanceId, env } = + named && isStandInInstanceId(fetched.instanceId) && isClerkInstanceId(named.instanceId) + ? named + : fetched; assertInstanceFlagMatches(options, source.keySource, { instanceId, env }); return { diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index cf127d4857..70d86a2c01 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { quoteArg } from "../../lib/json-body.ts"; import { useCaptureLog } from "../../test/lib/stubs.ts"; -import { sha256File, startRun, type StartRunInit } from "./lib/run-store.ts"; +import { type RunRecord, sha256File, startRun, type StartRunInit } from "./lib/run-store.ts"; import { runs } from "./runs.ts"; const captured = useCaptureLog(); @@ -19,6 +19,8 @@ afterEach(() => { fs.rmSync(runsDir, { recursive: true, force: true }); }); +const TARGET = { appLabel: "My App", env: "development", instanceId: "ins_1" }; + /** The file the fixture's import read, so a continue can find it unchanged. */ const usersFile = () => path.join(runsDir, "users.json"); @@ -26,7 +28,7 @@ function partialImport(init: Partial = {}) { fs.writeFileSync(usersFile(), "[]"); const run = startRun(runsDir, { kind: "import", - target: { appLabel: "My App", env: "development", instanceId: "ins_1" }, + target: TARGET, source: "clerk", file: { path: usersFile(), sha256: sha256File(usersFile()) }, ...init, @@ -38,6 +40,18 @@ function partialImport(init: Partial = {}) { return run.finish(); } +/** A finished import of the same file and source, a second after `record`. */ +function newerImport(record: RunRecord, instanceId: string) { + const run = startRun(runsDir, { + kind: "import", + target: { ...record.target, instanceId }, + source: "clerk", + file: record.file, + }); + run.update({ startedAt: new Date(Date.parse(record.startedAt) + 1000).toISOString() }); + return run.finish(); +} + describe("runs", () => { test("names the runs folder, then lists each run", async () => { const record = partialImport(); @@ -114,11 +128,6 @@ describe("runs ", () => { test.each([ [{ keySource: "--secret-key" }, "--secret-key "], [{ keySource: "--app", appId: "app_1" }, "--app app_1 --instance ins_1"], - // Recorded while Clerk could not name the instance: `--app` needs the alias. - [ - { keySource: "--app", appId: "app_1", instanceId: "key_abc" }, - "--app app_1 --instance development", - ], ])("the next command keeps the import's target from %o", async (target, expected) => { const record = partialImport(); const file = path.join(runsDir, record.id, "run.json"); @@ -174,16 +183,15 @@ describe("runs ", () => { expect(next).toEqual([expect.stringContaining(`clerk migrate undo ${id}`)]); }; - test("a newer run of the same file", async () => { - const record = partialImport(); - const newer = startRun(runsDir, { - kind: "import", - target: record.target, - source: "clerk", - file: record.file, - }); - newer.update({ startedAt: new Date(Date.parse(record.startedAt) + 1000).toISOString() }); - newer.finish(); + // `runs` has no key, so it can't tell whether a stand-in is `ins_1`: an + // import with that key could take the newer run either way. + test.each([ + ["a newer run of the same file", "ins_1", "ins_1"], + ["a newer run under a key's stand-in, when this one has a real ID", "ins_1", "key_abc"], + ["a newer run under a real ID, when this one has a key's stand-in", "key_abc", "ins_1"], + ])("%s", async (_name, instanceId, newerInstanceId) => { + const record = partialImport({ target: { ...TARGET, instanceId } }); + newerImport(record, newerInstanceId); await onlyUndo(record.id); }); @@ -209,53 +217,13 @@ describe("runs ", () => { await onlyUndo(record.id); }); - - // Here the newer run's stand-in may be `ins_1` too. - test("a newer run under a key's stand-in, when this one has a real ID", async () => { - const record = partialImport(); - const newer = startRun(runsDir, { - kind: "import", - target: { appLabel: "My App", env: "development", instanceId: "key_abc" }, - source: "clerk", - file: record.file, - }); - newer.update({ startedAt: new Date(Date.parse(record.startedAt) + 1000).toISOString() }); - newer.finish(); - - await onlyUndo(record.id); - }); - - // `runs` has no key, so it can't tell whether the stand-in is `ins_1`. An - // import with that key would take the newer run. - test("a newer run under a real ID, when this one has a key's stand-in", async () => { - const record = partialImport({ - target: { appLabel: "My App", env: "development", instanceId: "key_abc" }, - }); - const newer = startRun(runsDir, { - kind: "import", - target: { appLabel: "My App", env: "development", instanceId: "ins_1" }, - source: "clerk", - file: record.file, - }); - newer.update({ startedAt: new Date(Date.parse(record.startedAt) + 1000).toISOString() }); - newer.finish(); - - await onlyUndo(record.id); - }); }); // Dev first, then prod, from the same file: the dev run is still the one // an import into dev continues. test("a newer run on another instance keeps the continue", async () => { const record = partialImport(); - const other = startRun(runsDir, { - kind: "import", - target: { appLabel: "My App", env: "production", instanceId: "ins_2" }, - source: "clerk", - file: record.file, - }); - other.update({ startedAt: new Date(Date.parse(record.startedAt) + 1000).toISOString() }); - other.finish(); + newerImport(record, "ins_2"); await runs(record.id, { runsDir, json: true }); diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index 37f292972e..a6e317bebe 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -310,11 +310,7 @@ export async function runs(id: string | undefined, options: RunsOptions = {}): P const carry = carryFlags({ runsDir: options.runsDir, ...(keySource === "--secret-key" ? { secretKey: "" } : {}), - // A stand-in is no ID `--app` can resolve; its environment, always - // `development` or `production`, is an alias `--instance` takes. - ...(keySource === "--app" && appId - ? { app: appId, instance: isStandInInstanceId(instanceId) ? record.target.env : instanceId } - : {}), + ...(keySource === "--app" && appId ? { app: appId, instance: instanceId } : {}), }); if (options.json) log.data(JSON.stringify(showJson(runsDir, record, carry), null, 2)); else printRun(runsDir, record, carry); From 6316d7718eedb6495fc78025d8ef1c59797a3e91 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 17:22:35 -0400 Subject: [PATCH 46/63] fix(migrate): carry --instance for a linked-profile run, and keep the environment when a profile labels an instance by ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `runs ` passes a linked-profile run's `ins_` ID back with `--instance`. Left out, the profile resolved the development instance: the suggested continue imported into dev and the suggested undo refused. - A literal `--instance ins_…` on a linked profile no longer records the ID as the environment when `GET /v1/instance` fails; the key's environment stands in. - Tests for the linked-profile path of the previous fix, which had none. - `fromAppContext` and `InstanceIdentity` in `lib/target.ts`, and two comments brought up to date. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/target.test.ts | 50 ++++++++++++++- .../src/commands/migrate/lib/target.ts | 64 +++++++++++-------- .../src/commands/migrate/runs.test.ts | 8 ++- .../cli-core/src/commands/migrate/runs.ts | 11 +++- 4 files changed, 100 insertions(+), 33 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/target.test.ts b/packages/cli-core/src/commands/migrate/lib/target.test.ts index 8cb3a5c391..3fd82f8bee 100644 --- a/packages/cli-core/src/commands/migrate/lib/target.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/target.test.ts @@ -11,7 +11,7 @@ import { import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { _setConfigDir } from "../../../lib/config.ts"; +import { _setConfigDir, setProfile } from "../../../lib/config.ts"; import { _resetMode, setMode } from "../../../mode.ts"; import { EXIT_CODE, type CliError } from "../../../lib/errors.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; @@ -247,6 +247,54 @@ describe("resolveClerkTarget key source", () => { appLabel: "accountless app", }); }); + + // The profile names the instance; a stand-in would leave `runs` nothing it + // can pass back with `--instance`. + describe("a linked profile while GET /v1/instance fails", () => { + let fetchApplication: ReturnType; + + beforeEach(async () => { + await setProfile(workDir, { + workspaceId: "org_1", + appId: "app_1", + appName: "My App", + instances: { development: "ins_dev", production: "ins_prod" }, + }); + fetchApplication = spyOn(plapiModule, "fetchApplication").mockResolvedValue({ + application_id: "app_1", + name: "My App", + instances: [ + { + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live_x", + secret_key: "sk_live_x", + }, + ], + } as never); + globalThis.fetch = (async () => + new Response("nope", { status: 500 })) as unknown as typeof fetch; + }); + + afterEach(() => { + fetchApplication.mockRestore(); + }); + + test("records the profile's instance", async () => { + const { target } = await resolveClerkTarget({ instance: "prod" }); + expect(target).toMatchObject({ + instanceId: "ins_prod", + env: "production", + keySource: "linked profile", + }); + }); + + // The profile labels a literal ID with itself, which is no environment. + test("names the key's environment for a literal --instance", async () => { + const { target } = await resolveClerkTarget({ instance: "ins_prod" }); + expect(target).toMatchObject({ instanceId: "ins_prod", env: "production" }); + }); + }); }); // A stand-in is no ID `--app --instance` can resolve again, so a run recorded diff --git a/packages/cli-core/src/commands/migrate/lib/target.ts b/packages/cli-core/src/commands/migrate/lib/target.ts index 5c16a57040..f03ab52dbc 100644 --- a/packages/cli-core/src/commands/migrate/lib/target.ts +++ b/packages/cli-core/src/commands/migrate/lib/target.ts @@ -3,9 +3,10 @@ * * `resolveBapiSecretKey` picks the key. This names what that key points at: * the instance ID and environment come from `GET /v1/instance`, so every key - * source yields the same identity. That identity is what the run store keys - * resumes and undos on, so an app label or an `--instance` alias is never - * enough on its own. + * source yields the same identity. When that call fails, `--app` and a linked + * profile use the instance the Platform API or the profile names. That + * identity is what the run store keys resumes and undos on, so an app label or + * an `--instance` alias is never enough on its own. */ import { createHash } from "node:crypto"; @@ -44,22 +45,36 @@ export type ClerkTarget = RunTarget & { * a linked profile, so the profile's app is not named for it: that key may * belong to any app at all. */ -async function describeKeySource(options: TargetOptions): Promise<{ +type InstanceIdentity = { instanceId: string; env: string }; + +type KeySource = { keySource: string; appId?: string; appLabel?: string; - instance?: { instanceId: string; env: string }; -}> { + instance?: InstanceIdentity; +}; + +/** The app and instance a resolved app context names, for `describeKeySource`. */ +function fromAppContext( + keySource: string, + ctx: Awaited>, +): KeySource { + return { + keySource, + appId: ctx.appId, + appLabel: ctx.appLabel, + instance: { instanceId: ctx.instanceId, env: ctx.instanceLabel }, + }; +} + +async function describeKeySource(options: TargetOptions): Promise { if (options.secretKey) return { keySource: "--secret-key" }; if (options.app) { - const ctx = await resolveAppContext({ app: options.app, instance: options.instance }); - return { - keySource: "--app", - appId: ctx.appId, - appLabel: ctx.appLabel, - instance: { instanceId: ctx.instanceId, env: ctx.instanceLabel }, - }; + return fromAppContext( + "--app", + await resolveAppContext({ app: options.app, instance: options.instance }), + ); } if (process.env.CLERK_SECRET_KEY) return { keySource: "CLERK_SECRET_KEY env var" }; @@ -73,13 +88,7 @@ async function describeKeySource(options: TargetOptions): Promise<{ : { keySource: keyless.source }; } - const ctx = await resolveAppContext({ instance: options.instance }); - return { - keySource: "linked profile", - appId: ctx.appId, - appLabel: ctx.appLabel, - instance: { instanceId: ctx.instanceId, env: ctx.instanceLabel }, - }; + return fromAppContext("linked profile", await resolveAppContext({ instance: options.instance })); } /** @@ -90,9 +99,7 @@ async function describeKeySource(options: TargetOptions): Promise<{ * instances apart, and nothing is sent anywhere. A 429 is retried first: it is * most likely straight after a large import, which is when `undo` runs. */ -export async function fetchInstanceIdentity( - secretKey: string, -): Promise<{ instanceId: string; env: string }> { +export async function fetchInstanceIdentity(secretKey: string): Promise { const fallbackEnv = detectInstanceType(secretKey) === "prod" ? "production" : "development"; try { const { body } = await retryOn429(async () => @@ -142,7 +149,7 @@ export function keyInstanceId(secretKey: string): string { function assertInstanceFlagMatches( options: TargetOptions, keySource: string, - identity: { instanceId: string; env: string }, + identity: InstanceIdentity, ): void { const flag = options.instance; if (!flag || (keySource !== "--secret-key" && !keySource.startsWith("CLERK_SECRET_KEY"))) return; @@ -173,10 +180,15 @@ export async function resolveClerkTarget( const { instance: named, ...source } = await describeKeySource(options); const fetched = await fetchInstanceIdentity(secretKey); // The Platform API or the profile already named the instance: recorded - // instead of a stand-in, a run's commands can reach it with `--instance`. + // instead of a stand-in, a run's commands can reach it with `--instance`. A + // profile labels a literal `--instance ins_…` with the ID itself, so the + // key's own environment stands in for that label. const { instanceId, env } = named && isStandInInstanceId(fetched.instanceId) && isClerkInstanceId(named.instanceId) - ? named + ? { + instanceId: named.instanceId, + env: isClerkInstanceId(named.env) ? fetched.env : named.env, + } : fetched; assertInstanceFlagMatches(options, source.keySource, { instanceId, env }); diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 70d86a2c01..11de07ef29 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -124,10 +124,12 @@ describe("runs ", () => { }); }); - // The record says where the key came from, which a linked profile may not reach. + // The record says where the key came from, and which instance to reach again. test.each([ [{ keySource: "--secret-key" }, "--secret-key "], [{ keySource: "--app", appId: "app_1" }, "--app app_1 --instance ins_1"], + // Left out, a linked profile resolves the development instance. + [{ keySource: "linked profile", appId: "app_1" }, "--instance ins_1"], ])("the next command keeps the import's target from %o", async (target, expected) => { const record = partialImport(); const file = path.join(runsDir, record.id, "run.json"); @@ -183,8 +185,8 @@ describe("runs ", () => { expect(next).toEqual([expect.stringContaining(`clerk migrate undo ${id}`)]); }; - // `runs` has no key, so it can't tell whether a stand-in is `ins_1`: an - // import with that key could take the newer run either way. + // In the stand-in rows, `runs` has no key, so it can't tell whether a + // stand-in is `ins_1`: an import with that key could take the newer run. test.each([ ["a newer run of the same file", "ins_1", "ins_1"], ["a newer run under a key's stand-in, when this one has a real ID", "ins_1", "key_abc"], diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index a6e317bebe..33880a0642 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -31,7 +31,7 @@ import { } from "./lib/run-store.ts"; import { carryFlags, importFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; import { plural } from "./lib/plural.ts"; -import { describeTarget, isStandInInstanceId } from "./lib/target.ts"; +import { describeTarget, isClerkInstanceId, isStandInInstanceId } from "./lib/target.ts"; export type RunsOptions = { json?: boolean; @@ -304,13 +304,18 @@ export async function runs(id: string | undefined, options: RunsOptions = {}): P if (!stored) throwUsageError(noRunMessage(runsDir, id, carryFlags({ runsDir: options.runsDir }))); const record = withLiveCounts(runsDir, stored); - // The record says where the import's key came from: `--secret-key` or - // `--app` is the instance to reach again, which a linked profile may not be. + // The record says where the import's key came from. `--secret-key` needs + // the key again; `--app` and a linked profile need the instance, which a + // profile would otherwise resolve to development. By ID, not alias: a + // staging instance has none. const { keySource, appId, instanceId } = record.target; const carry = carryFlags({ runsDir: options.runsDir, ...(keySource === "--secret-key" ? { secretKey: "" } : {}), ...(keySource === "--app" && appId ? { app: appId, instance: instanceId } : {}), + ...(keySource === "linked profile" && isClerkInstanceId(instanceId) + ? { instance: instanceId } + : {}), }); if (options.json) log.data(JSON.stringify(showJson(runsDir, record, carry), null, 2)); else printRun(runsDir, record, carry); From 363a18f65f208b87f077b4a27ad0e0bba6a9d836 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 17:45:51 -0400 Subject: [PATCH 47/63] fix(migrate): carry every key source's target in runs , from one table of named sources - `runs ` reaches a `CLERK_SECRET_KEY` run's instance again with `--instance`, and a linked-profile run's with `--app` and `--instance`, so its continue works outside the linked directory. `.env` and keyless runs carry nothing, since both refuse `--instance`. - `KEY_SOURCE` names the fixed key sources, and `targetFlags` maps each to its flags through a table every name must have an entry in, so a new one fails to compile until it says how. Only a real `ins_` ID is carried. - A profile's instance named by ID records its environment as `unknown`, not a guess from the key prefix. - `describeKeySource`'s doc sits above it again, `fromAppContext` resolves the context itself, and the attach-only step takes a `UserLine`. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 8 +- .../src/commands/migrate/import-users.ts | 2 +- .../src/commands/migrate/lib/target.test.ts | 7 +- .../src/commands/migrate/lib/target.ts | 105 ++++++++++++------ .../src/commands/migrate/runs.test.ts | 14 ++- .../cli-core/src/commands/migrate/runs.ts | 18 +-- 6 files changed, 100 insertions(+), 54 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index f73a6cf04f..b555df4358 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -480,8 +480,12 @@ users that failed or were skipped, with the path to the full record, then what to run next: for an interrupted or partial import, the import that continues it, with the flags it ran with, then the undo. The continue is left out when importing the file again might not reach this run: a newer run of the same -file and source that may be for the same instance (one recorded under a key's stand-in ID -may be any), a file changed since, or any undo of it. An unknown ID exits 2. +file and source that may be for the same instance (one recorded under a key's +stand-in ID may be any), a file changed since, or any undo of it. Both carry +what reaches the run's instance again: `--secret-key ` for a key passed +that way, `--app` and `--instance` for `--app` or a linked profile, and +`--instance` for `CLERK_SECRET_KEY`. A `.env` file or a keyless app gets +neither, because both refuse `--instance`. An unknown ID exits 2. ### `clerk migrate undo` diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index bc640124ee..fa93fb44a9 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -526,7 +526,7 @@ export async function importUsers(options: ImportUsersOptions): Promise { + const attachOnlyUser = async (line: UserLine) => { await finishUser(line, line.pending ?? [], []); attachedUsers++; progress(); diff --git a/packages/cli-core/src/commands/migrate/lib/target.test.ts b/packages/cli-core/src/commands/migrate/lib/target.test.ts index 3fd82f8bee..a13398caf7 100644 --- a/packages/cli-core/src/commands/migrate/lib/target.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/target.test.ts @@ -289,10 +289,11 @@ describe("resolveClerkTarget key source", () => { }); }); - // The profile labels a literal ID with itself, which is no environment. - test("names the key's environment for a literal --instance", async () => { + // The profile labels a literal ID with itself, which is no environment, + // and the key prefix would only guess one: a staging key is `sk_live_`. + test("records the environment as unknown for a literal --instance", async () => { const { target } = await resolveClerkTarget({ instance: "ins_prod" }); - expect(target).toMatchObject({ instanceId: "ins_prod", env: "production" }); + expect(target).toMatchObject({ instanceId: "ins_prod", env: "unknown" }); }); }); }); diff --git a/packages/cli-core/src/commands/migrate/lib/target.ts b/packages/cli-core/src/commands/migrate/lib/target.ts index f03ab52dbc..1fb2749741 100644 --- a/packages/cli-core/src/commands/migrate/lib/target.ts +++ b/packages/cli-core/src/commands/migrate/lib/target.ts @@ -37,47 +37,61 @@ export type ClerkTarget = RunTarget & { }; /** - * Which rung of `resolveBapiSecretKey`'s chain supplies the key, and the app - * it belongs to when that rung knows one. `--app` and a linked profile also - * know the instance, from the Platform API or the profile. - * - * Mirrors the chain's order exactly. An exported `CLERK_SECRET_KEY` wins over - * a linked profile, so the profile's app is not named for it: that key may - * belong to any app at all. + * The key sources a run records by name. A `.env` file or a keyless app is + * recorded by its file, so those stay plain strings. */ +export const KEY_SOURCE = { + secretKeyFlag: "--secret-key", + app: "--app", + envVar: "CLERK_SECRET_KEY env var", + linkedProfile: "linked profile", +} as const; + +type NamedKeySource = (typeof KEY_SOURCE)[keyof typeof KEY_SOURCE]; + type InstanceIdentity = { instanceId: string; env: string }; type KeySource = { keySource: string; appId?: string; appLabel?: string; - instance?: InstanceIdentity; + /** No `env` when the instance was named by ID, which is no environment. */ + instance?: { instanceId: string; env?: string }; }; -/** The app and instance a resolved app context names, for `describeKeySource`. */ -function fromAppContext( - keySource: string, - ctx: Awaited>, -): KeySource { +/** The app and instance `--app` or a linked profile names. */ +async function fromAppContext( + keySource: NamedKeySource, + options: TargetOptions, +): Promise { + const ctx = await resolveAppContext(options); return { keySource, appId: ctx.appId, appLabel: ctx.appLabel, - instance: { instanceId: ctx.instanceId, env: ctx.instanceLabel }, + instance: { + instanceId: ctx.instanceId, + // A profile labels a literal `--instance ins_…` with the ID itself. + ...(isClerkInstanceId(ctx.instanceLabel) ? {} : { env: ctx.instanceLabel }), + }, }; } +/** + * Which rung of `resolveBapiSecretKey`'s chain supplies the key, and the app + * it belongs to when that rung knows one. `--app` and a linked profile also + * know the instance, from the Platform API or the profile. + * + * Mirrors the chain's order exactly. An exported `CLERK_SECRET_KEY` wins over + * a linked profile, so the profile's app is not named for it: that key may + * belong to any app at all. + */ async function describeKeySource(options: TargetOptions): Promise { - if (options.secretKey) return { keySource: "--secret-key" }; + if (options.secretKey) return { keySource: KEY_SOURCE.secretKeyFlag }; - if (options.app) { - return fromAppContext( - "--app", - await resolveAppContext({ app: options.app, instance: options.instance }), - ); - } + if (options.app) return fromAppContext(KEY_SOURCE.app, options); - if (process.env.CLERK_SECRET_KEY) return { keySource: "CLERK_SECRET_KEY env var" }; + if (process.env.CLERK_SECRET_KEY) return { keySource: KEY_SOURCE.envVar }; // Only the SDK's own file says the app is accountless: a key in `.env` // may belong to any app, an account's included. @@ -88,7 +102,40 @@ async function describeKeySource(options: TargetOptions): Promise { : { keySource: keyless.source }; } - return fromAppContext("linked profile", await resolveAppContext({ instance: options.instance })); + return fromAppContext(KEY_SOURCE.linkedProfile, options); +} + +type TargetFlags = { secretKey?: string; app?: string; instance?: string }; + +/** + * For each named key source, the flags that reach a recorded target again. + * Every name needs an entry, so a new one fails to compile until it says how. + */ +const TARGET_FLAGS: Record TargetFlags> = + { + // The key again, as a placeholder. + [KEY_SOURCE.secretKeyFlag]: () => ({ secretKey: "" }), + // The app and the instance. A profile's own link resolves development, + // and only from a directory linked to that app. + [KEY_SOURCE.app]: (target, instance) => ({ app: target.appId, instance }), + [KEY_SOURCE.linkedProfile]: (target, instance) => ({ app: target.appId, instance }), + // The instance. A key still exported must address it, which the target + // check enforces; without one, the profile reaches it by ID. + [KEY_SOURCE.envVar]: (_target, instance) => ({ instance }), + }; + +/** + * The flags a command `runs ` suggests needs to reach the run's target. + * The instance goes by ID, not alias (a staging one has none), and only a + * real one: no `--instance` resolves a stand-in. A `.env` file or a keyless + * app gets none, because both refuse `--instance` and the file names the key. + */ +export function targetFlags(target: RunTarget): TargetFlags { + const instance = isClerkInstanceId(target.instanceId) ? target.instanceId : undefined; + const flags = (TARGET_FLAGS as Partial>)[ + target.keySource ?? "" + ]; + return flags ? flags(target, instance) : {}; } /** @@ -152,7 +199,7 @@ function assertInstanceFlagMatches( identity: InstanceIdentity, ): void { const flag = options.instance; - if (!flag || (keySource !== "--secret-key" && !keySource.startsWith("CLERK_SECRET_KEY"))) return; + if (!flag || (keySource !== KEY_SOURCE.secretKeyFlag && keySource !== KEY_SOURCE.envVar)) return; const wanted = INSTANCE_ALIASES[flag]; const matches = wanted ? identity.env === wanted : identity.instanceId === flag; if (matches) return; @@ -180,15 +227,11 @@ export async function resolveClerkTarget( const { instance: named, ...source } = await describeKeySource(options); const fetched = await fetchInstanceIdentity(secretKey); // The Platform API or the profile already named the instance: recorded - // instead of a stand-in, a run's commands can reach it with `--instance`. A - // profile labels a literal `--instance ins_…` with the ID itself, so the - // key's own environment stands in for that label. + // instead of a stand-in, a run's commands can reach it with `--instance`. + // Named by ID, its environment is unknown: the key prefix only guesses it. const { instanceId, env } = named && isStandInInstanceId(fetched.instanceId) && isClerkInstanceId(named.instanceId) - ? { - instanceId: named.instanceId, - env: isClerkInstanceId(named.env) ? fetched.env : named.env, - } + ? { instanceId: named.instanceId, env: named.env ?? "unknown" } : fetched; assertInstanceFlagMatches(options, source.keySource, { instanceId, env }); diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 11de07ef29..15a2005680 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -124,12 +124,20 @@ describe("runs ", () => { }); }); - // The record says where the key came from, and which instance to reach again. + // The record says where the key came from, and which instance to reach + // again. One row per key source, so none is left to drop its target. test.each([ [{ keySource: "--secret-key" }, "--secret-key "], [{ keySource: "--app", appId: "app_1" }, "--app app_1 --instance ins_1"], - // Left out, a linked profile resolves the development instance. - [{ keySource: "linked profile", appId: "app_1" }, "--instance ins_1"], + // Left out, a profile resolves development, and only from its own directory. + [{ keySource: "linked profile", appId: "app_1" }, "--app app_1 --instance ins_1"], + // Without the key exported again, the profile would pick the instance. + [{ keySource: "CLERK_SECRET_KEY env var" }, "--instance ins_1"], + // Both refuse `--instance`, and the file names the key. + [{ keySource: ".env" }, "--runs-dir"], + [{ keySource: "accountless app (.clerk/.tmp/keyless.json)" }, "--runs-dir"], + // No `--instance` resolves a stand-in. + [{ keySource: "--app", appId: "app_1", instanceId: "key_abc" }, "--app app_1 --runs-dir"], ])("the next command keeps the import's target from %o", async (target, expected) => { const record = partialImport(); const file = path.join(runsDir, record.id, "run.json"); diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index 33880a0642..9386c37b89 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -31,7 +31,7 @@ import { } from "./lib/run-store.ts"; import { carryFlags, importFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; import { plural } from "./lib/plural.ts"; -import { describeTarget, isClerkInstanceId, isStandInInstanceId } from "./lib/target.ts"; +import { describeTarget, isStandInInstanceId, targetFlags } from "./lib/target.ts"; export type RunsOptions = { json?: boolean; @@ -304,19 +304,9 @@ export async function runs(id: string | undefined, options: RunsOptions = {}): P if (!stored) throwUsageError(noRunMessage(runsDir, id, carryFlags({ runsDir: options.runsDir }))); const record = withLiveCounts(runsDir, stored); - // The record says where the import's key came from. `--secret-key` needs - // the key again; `--app` and a linked profile need the instance, which a - // profile would otherwise resolve to development. By ID, not alias: a - // staging instance has none. - const { keySource, appId, instanceId } = record.target; - const carry = carryFlags({ - runsDir: options.runsDir, - ...(keySource === "--secret-key" ? { secretKey: "" } : {}), - ...(keySource === "--app" && appId ? { app: appId, instance: instanceId } : {}), - ...(keySource === "linked profile" && isClerkInstanceId(instanceId) - ? { instance: instanceId } - : {}), - }); + // The record says where the import's key came from, and so which flags + // reach that instance again. + const carry = carryFlags({ runsDir: options.runsDir, ...targetFlags(record.target) }); if (options.json) log.data(JSON.stringify(showJson(runsDir, record, carry), null, 2)); else printRun(runsDir, record, carry); } From 6593740c6ed25c02a49467e1429e282f595ce2d5 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 18:07:40 -0400 Subject: [PATCH 48/63] fix(migrate): name the key for a CLERK_SECRET_KEY run, and build every later step's target the way runs does - A `CLERK_SECRET_KEY` run is reached again with `--secret-key `, not `--instance`: in an unlinked directory with a `.env`, the keyless rung refused `--instance` and told the user to claim the application. - The import's retry and undo steps, and undo's "run it again" hints, build their target from `targetCarry`, as `runs ` does. Before, an exported key's steps carried no target, so the retry could import into dev. - `targetFlags` is `targetCarry`, typed from `carryFlags`' `CarryOptions`, looked up through an `Object.hasOwn` guard, with its shared entries named. - The README names the framework key variables among the sources that carry nothing. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 9 ++- .../src/commands/migrate/lib/carry-flags.ts | 6 +- .../src/commands/migrate/lib/target.ts | 55 +++++++++++-------- .../cli-core/src/commands/migrate/run.test.ts | 19 +++++++ packages/cli-core/src/commands/migrate/run.ts | 17 ++++-- .../src/commands/migrate/runs.test.ts | 4 +- .../cli-core/src/commands/migrate/runs.ts | 4 +- .../cli-core/src/commands/migrate/undo.ts | 10 +++- 8 files changed, 85 insertions(+), 39 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index b555df4358..1bfaac04d3 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -483,9 +483,12 @@ importing the file again might not reach this run: a newer run of the same file and source that may be for the same instance (one recorded under a key's stand-in ID may be any), a file changed since, or any undo of it. Both carry what reaches the run's instance again: `--secret-key ` for a key passed -that way, `--app` and `--instance` for `--app` or a linked profile, and -`--instance` for `CLERK_SECRET_KEY`. A `.env` file or a keyless app gets -neither, because both refuse `--instance`. An unknown ID exits 2. +with `--secret-key` or exported as `CLERK_SECRET_KEY`, and `--app` and +`--instance` for `--app` or a linked profile. Any other source (a `.env` file, +a keyless app, or a framework's own key variable such as +`NUXT_CLERK_SECRET_KEY`) gets no target flags, because each refuses +`--instance` and names its own key. The import's own next steps, and an undo's, +carry the same. An unknown ID exits 2. ### `clerk migrate undo` diff --git a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts index 3038b16c5f..c076702919 100644 --- a/packages/cli-core/src/commands/migrate/lib/carry-flags.ts +++ b/packages/cli-core/src/commands/migrate/lib/carry-flags.ts @@ -7,12 +7,14 @@ import { quoteArg } from "../../../lib/json-body.ts"; * it loops on exit 2. Values are shell-quoted, and the secret key is a * placeholder. */ -export function carryFlags(options: { +export type CarryOptions = { secretKey?: string; app?: string; instance?: string; runsDir?: string; -}): string[] { +}; + +export function carryFlags(options: CarryOptions): string[] { return [ ...(options.secretKey ? ["--secret-key", ""] : []), ...(options.app ? ["--app", quoteArg(options.app)] : []), diff --git a/packages/cli-core/src/commands/migrate/lib/target.ts b/packages/cli-core/src/commands/migrate/lib/target.ts index 1fb2749741..a474b9e655 100644 --- a/packages/cli-core/src/commands/migrate/lib/target.ts +++ b/packages/cli-core/src/commands/migrate/lib/target.ts @@ -20,6 +20,7 @@ import { log } from "../../../lib/log.ts"; import { isHuman } from "../../../mode.ts"; import { detectInstanceType } from "./instance.ts"; import { retryOn429 } from "./retry.ts"; +import type { CarryOptions } from "./carry-flags.ts"; import type { RunTarget } from "./run-store.ts"; export type TargetOptions = { @@ -105,37 +106,43 @@ async function describeKeySource(options: TargetOptions): Promise { return fromAppContext(KEY_SOURCE.linkedProfile, options); } -type TargetFlags = { secretKey?: string; app?: string; instance?: string }; +type TargetCarry = Omit; +type CarryFor = (target: RunTarget, instance?: string) => TargetCarry; + +// The key again, as a placeholder: the key alone picks the instance, from +// any directory. Not `--instance`: in an unlinked directory with a `.env`, +// the keyless rung refuses it before a profile could resolve it. +const keyAgain: CarryFor = () => ({ secretKey: "" }); +// The app and the instance. A profile's own link resolves development, and +// only from a directory linked to that app. +const appAndInstance: CarryFor = (target, instance) => ({ app: target.appId, instance }); /** - * For each named key source, the flags that reach a recorded target again. - * Every name needs an entry, so a new one fails to compile until it says how. + * For each named key source, what reaches a recorded target again. Every + * name needs an entry, so a new one fails to compile until it says how. */ -const TARGET_FLAGS: Record TargetFlags> = - { - // The key again, as a placeholder. - [KEY_SOURCE.secretKeyFlag]: () => ({ secretKey: "" }), - // The app and the instance. A profile's own link resolves development, - // and only from a directory linked to that app. - [KEY_SOURCE.app]: (target, instance) => ({ app: target.appId, instance }), - [KEY_SOURCE.linkedProfile]: (target, instance) => ({ app: target.appId, instance }), - // The instance. A key still exported must address it, which the target - // check enforces; without one, the profile reaches it by ID. - [KEY_SOURCE.envVar]: (_target, instance) => ({ instance }), - }; +const TARGET_CARRY: Record = { + [KEY_SOURCE.secretKeyFlag]: keyAgain, + [KEY_SOURCE.envVar]: keyAgain, + [KEY_SOURCE.app]: appAndInstance, + [KEY_SOURCE.linkedProfile]: appAndInstance, +}; + +function isNamedKeySource(source: string | undefined): source is NamedKeySource { + return source !== undefined && Object.hasOwn(TARGET_CARRY, source); +} /** - * The flags a command `runs ` suggests needs to reach the run's target. - * The instance goes by ID, not alias (a staging one has none), and only a - * real one: no `--instance` resolves a stand-in. A `.env` file or a keyless - * app gets none, because both refuse `--instance` and the file names the key. + * The `carryFlags` options that reach a run's target again, for a command run + * later: `runs `'s, and an import's or undo's own next steps. The instance + * goes by ID, not alias (a staging one has none), and only a real one: no + * `--instance` resolves a stand-in. Any other source (a `.env` file, a + * keyless app, a framework's own key variable) gets none: each refuses + * `--instance`, and names its own key. */ -export function targetFlags(target: RunTarget): TargetFlags { +export function targetCarry(target: RunTarget): TargetCarry { const instance = isClerkInstanceId(target.instanceId) ? target.instanceId : undefined; - const flags = (TARGET_FLAGS as Partial>)[ - target.keySource ?? "" - ]; - return flags ? flags(target, instance) : {}; + return isNamedKeySource(target.keySource) ? TARGET_CARRY[target.keySource](target, instance) : {}; } /** diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 342483ff8d..aaed9290ab 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -362,6 +362,25 @@ describe("run", () => { expect(listRuns(runsDir())[0]).toMatchObject({ status: "partial", counts: { failed: 1 } }); }); + // Run later, the key may no longer be exported: the steps name it as a + // placeholder, as `runs ` does, rather than nothing. + test("after failures with an exported key, the retry and undo name the key", async () => { + stubClerk({ failing: new Set(["u2"]) }); + const previous = process.env.CLERK_SECRET_KEY; + process.env.CLERK_SECRET_KEY = "sk_test_x"; + try { + await run({ source: "clerk", input: "export.json", yes: true }); + } finally { + if (previous === undefined) delete process.env.CLERK_SECRET_KEY; + else process.env.CLERK_SECRET_KEY = previous; + } + + const [record] = listRuns(runsDir()); + const err = Bun.stripANSI(captured.err); + expect(err).toContain("--source clerk --secret-key --yes` to retry them"); + expect(err).toContain(`\`clerk migrate undo ${record!.id} --secret-key \``); + }); + // `runs` reads only a runs folder; the retry is the same import, which // continues this run, so it drops --new-run. test("after failures, names the runs listing and the import that retries them", async () => { diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 981a7f56e7..7df36f9b64 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -75,6 +75,7 @@ import { keyInstanceId, printTarget, resolveClerkTarget, + targetCarry, } from "./lib/target.ts"; import { findInFlight } from "./lib/user-lookup.ts"; import { @@ -499,13 +500,18 @@ function cleanupLines(runsDir: string, record: RunRecord): string[] { * 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, extra: string[]) { +function commandFor( + options: MigrateRunOptions, + extra: string[], + /** The target and runs-folder flags; a command for later passes the run's. */ + carry = carryFlags(options), +) { const input = options.input ?? options.file ?? ""; const parts = ["clerk migrate import", quoteArg(input)]; if (options.source) parts.push("--source", quoteArg(options.source)); if (options.newRun) parts.push("--new-run"); parts.push(...importFlags(options)); - parts.push(...carryFlags(options)); + parts.push(...carry); if (options.json) parts.push("--json"); return [...parts, ...extra].join(" "); } @@ -960,13 +966,16 @@ export async function run(rawOptions: MigrateRunOptions): Promise { // `runs` takes only --runs-dir. const runsStep = runsCommand(carryFlags({ runsDir: options.runsDir }), record.id); - const undo = undoCommand(record.id, carry); + // Run later, maybe without the key exported, so from the run's target, + // the way `runs ` builds them. + const later = carryFlags({ runsDir: options.runsDir, ...targetCarry(record.target) }); + const undo = undoCommand(record.id, later); const steps = summary.failed > 0 ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS( runsStep, // The same import again continues this run; --new-run would not. - commandFor({ ...options, newRun: false }, options.yes ? ["--yes"] : []), + commandFor({ ...options, newRun: false }, options.yes ? ["--yes"] : [], later), undo, ) : NEXT_STEPS.MIGRATE_DONE(runsStep, undo); diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 15a2005680..9c462453b2 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -131,8 +131,8 @@ describe("runs ", () => { [{ keySource: "--app", appId: "app_1" }, "--app app_1 --instance ins_1"], // Left out, a profile resolves development, and only from its own directory. [{ keySource: "linked profile", appId: "app_1" }, "--app app_1 --instance ins_1"], - // Without the key exported again, the profile would pick the instance. - [{ keySource: "CLERK_SECRET_KEY env var" }, "--instance ins_1"], + // Maybe no longer exported, and `--instance` is refused next to a `.env`. + [{ keySource: "CLERK_SECRET_KEY env var" }, "--secret-key "], // Both refuse `--instance`, and the file names the key. [{ keySource: ".env" }, "--runs-dir"], [{ keySource: "accountless app (.clerk/.tmp/keyless.json)" }, "--runs-dir"], diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index 9386c37b89..7b0cc52f2e 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -31,7 +31,7 @@ import { } from "./lib/run-store.ts"; import { carryFlags, importFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; import { plural } from "./lib/plural.ts"; -import { describeTarget, isStandInInstanceId, targetFlags } from "./lib/target.ts"; +import { describeTarget, isStandInInstanceId, targetCarry } from "./lib/target.ts"; export type RunsOptions = { json?: boolean; @@ -306,7 +306,7 @@ export async function runs(id: string | undefined, options: RunsOptions = {}): P // The record says where the import's key came from, and so which flags // reach that instance again. - const carry = carryFlags({ runsDir: options.runsDir, ...targetFlags(record.target) }); + const carry = carryFlags({ runsDir: options.runsDir, ...targetCarry(record.target) }); if (options.json) log.data(JSON.stringify(showJson(runsDir, record, carry), null, 2)); else printRun(runsDir, record, carry); } diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index d35beda62f..eed7b00982 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -53,6 +53,7 @@ import { keyInstanceId, printTarget, resolveClerkTarget, + targetCarry, } from "./lib/target.ts"; import { plural } from "./lib/plural.ts"; import { findInFlight, lookupUsers, type SourceUser } from "./lib/user-lookup.ts"; @@ -324,11 +325,16 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise` builds it. + const undoAgain = undoCommand( + record.id, + carryFlags({ runsDir: options.runsDir, ...targetCarry(target) }), + ); if (!options.json) printTarget(target); assertSameInstance(record, target, secretKey); From 77aa3d73fd30ebe84115d5f428fd9c7522f71aa9 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 15 Oct 2026 19:10:07 -0400 Subject: [PATCH 49/63] fix(migrate): carry the key a run's latest attempt used, including a framework's exported key variable - A continue records its own key source, app ID and app label on the run, as it does its flags, so the run's later commands (the import's steps and `runs `) reach the instance the way the latest attempt did. Before, a `.env` run continued with `--secret-key` suggested a bare retry. - An exported framework key variable (`NUXT_CLERK_SECRET_KEY env var`) gets `--secret-key `, like `CLERK_SECRET_KEY`: without the export, the retry would read `.env`'s key. `keyless-target.ts` exports the suffix it names those sources with. - Undo's two "Nothing was deleted" refusals, run again straight away, go back to the live flags; only the partial undo's hint, run later, uses the target's. - `laterCarry` builds every "for later" command's flags in one place, and `commandFor`'s doc and undo's two comments say what each is for. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 15 +++++----- .../src/commands/migrate/lib/target.ts | 25 ++++++++++++----- .../cli-core/src/commands/migrate/run.test.ts | 18 +++++++++++- packages/cli-core/src/commands/migrate/run.ts | 28 +++++++++++++------ .../src/commands/migrate/runs.test.ts | 3 ++ .../cli-core/src/commands/migrate/runs.ts | 4 +-- .../src/commands/migrate/undo.test.ts | 18 ++++++++++++ .../cli-core/src/commands/migrate/undo.ts | 19 ++++++------- packages/cli-core/src/lib/keyless-target.ts | 5 +++- 9 files changed, 98 insertions(+), 37 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 1bfaac04d3..5dcdb21a34 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -482,13 +482,14 @@ it, with the flags it ran with, then the undo. The continue is left out when importing the file again might not reach this run: a newer run of the same file and source that may be for the same instance (one recorded under a key's stand-in ID may be any), a file changed since, or any undo of it. Both carry -what reaches the run's instance again: `--secret-key ` for a key passed -with `--secret-key` or exported as `CLERK_SECRET_KEY`, and `--app` and -`--instance` for `--app` or a linked profile. Any other source (a `.env` file, -a keyless app, or a framework's own key variable such as -`NUXT_CLERK_SECRET_KEY`) gets no target flags, because each refuses -`--instance` and names its own key. The import's own next steps, and an undo's, -carry the same. An unknown ID exits 2. +what reaches the run's instance again, from the key its latest attempt used: +`--secret-key ` for a key passed with `--secret-key` or exported (as +`CLERK_SECRET_KEY`, or a framework's own variable such as +`NUXT_CLERK_SECRET_KEY`), and `--app` and `--instance` for `--app` or a linked +profile. A key file (`.env`, or a keyless app's) gets no target flags: it +refuses `--instance`, and run from the same project it names the key itself. +The import's own next steps, and a partial undo's, carry the same. An unknown +ID exits 2. ### `clerk migrate undo` diff --git a/packages/cli-core/src/commands/migrate/lib/target.ts b/packages/cli-core/src/commands/migrate/lib/target.ts index a474b9e655..b71d487730 100644 --- a/packages/cli-core/src/commands/migrate/lib/target.ts +++ b/packages/cli-core/src/commands/migrate/lib/target.ts @@ -15,12 +15,16 @@ import { dim } from "../../../lib/color.ts"; import { resolveBapiSecretKey } from "../../../lib/bapi-command.ts"; import { INSTANCE_ALIASES, resolveAppContext } from "../../../lib/config.ts"; import { throwUsageError } from "../../../lib/errors.ts"; -import { resolveKeylessTarget, SDK_KEYLESS_SOURCE } from "../../../lib/keyless-target.ts"; +import { + resolveKeylessTarget, + SDK_KEYLESS_SOURCE, + ENV_VAR_SOURCE_SUFFIX, +} from "../../../lib/keyless-target.ts"; import { log } from "../../../lib/log.ts"; import { isHuman } from "../../../mode.ts"; import { detectInstanceType } from "./instance.ts"; import { retryOn429 } from "./retry.ts"; -import type { CarryOptions } from "./carry-flags.ts"; +import { type CarryOptions, carryFlags } from "./carry-flags.ts"; import type { RunTarget } from "./run-store.ts"; export type TargetOptions = { @@ -136,13 +140,20 @@ function isNamedKeySource(source: string | undefined): source is NamedKeySource * The `carryFlags` options that reach a run's target again, for a command run * later: `runs `'s, and an import's or undo's own next steps. The instance * goes by ID, not alias (a staging one has none), and only a real one: no - * `--instance` resolves a stand-in. Any other source (a `.env` file, a - * keyless app, a framework's own key variable) gets none: each refuses - * `--instance`, and names its own key. + * `--instance` resolves a stand-in. A framework's own key variable, exported + * like `CLERK_SECRET_KEY`, gets the key again. A key file (`.env`, a keyless + * app's) gets nothing: it refuses `--instance`, and run from the same project + * it names the key itself. */ -export function targetCarry(target: RunTarget): TargetCarry { +function targetCarry(target: RunTarget): TargetCarry { const instance = isClerkInstanceId(target.instanceId) ? target.instanceId : undefined; - return isNamedKeySource(target.keySource) ? TARGET_CARRY[target.keySource](target, instance) : {}; + if (isNamedKeySource(target.keySource)) return TARGET_CARRY[target.keySource](target, instance); + return target.keySource?.endsWith(ENV_VAR_SOURCE_SUFFIX) ? keyAgain(target, instance) : {}; +} + +/** The flags a command meant for later needs to reach this target and runs folder again. */ +export function laterCarry(runsDir: string | undefined, target: RunTarget): string[] { + return carryFlags({ runsDir, ...targetCarry(target) }); } /** diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index aaed9290ab..281d80ef3d 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -10,7 +10,7 @@ import { credentialStoreStubs, useCaptureLog } from "../../test/lib/stubs.ts"; // signed-in check — except the one that asserts what happens without it. mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); import { importFlags } from "./lib/carry-flags.ts"; -import { latestUserLines, listRuns, readRun, startRun } from "./lib/run-store.ts"; +import { latestUserLines, listRuns, patchRun, readRun, startRun } from "./lib/run-store.ts"; import { explainErrors, run, validateRunOptions } from "./run.ts"; // After run.ts: imported first, signals.ts loads version.ts ahead of its Bun // macro here, and the file fails to load. @@ -877,6 +877,22 @@ describe("run", () => { expect(captured.err).not.toContain("Continuing run"); }); + // The steps are run later: they need the key this attempt used, not the + // first one's `.env`, which may not be there. + test("a continue records its own key source, which its next steps carry", async () => { + stubClerk({ failing: new Set(["u2"]) }); + await run(baseOptions); + const [first] = listRuns(runsDir()); + patchRun(runsDir(), first!.id, { target: { ...first!.target, keySource: ".env" } }); + + process.exitCode = 0; + captured.clear(); + await run(baseOptions); + + expect(readRun(runsDir(), first!.id)?.target.keySource).toBe("--secret-key"); + expect(Bun.stripANSI(captured.err)).toContain("--secret-key --yes` to retry them"); + }); + 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); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 7df36f9b64..32300d54c7 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -75,7 +75,7 @@ import { keyInstanceId, printTarget, resolveClerkTarget, - targetCarry, + laterCarry, } from "./lib/target.ts"; import { findInFlight } from "./lib/user-lookup.ts"; import { @@ -497,8 +497,10 @@ function cleanupLines(runsDir: string, record: RunRecord): string[] { // --- 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. + * The exact command that would carry on from here. The consent and refusal + * messages, run straight away, use the live flags; the retry step after a run, + * run later, passes `carry` built from the run's target. Every value is + * shell-quoted; secrets are placeholders. */ function commandFor( options: MigrateRunOptions, @@ -868,8 +870,19 @@ export async function run(rawOptions: MigrateRunOptions): Promise { file: { path: filePath, sha256 }, flags, }); - // The flags this attempt ran with, which the next continue repeats. - if (continued) run.update({ flags }); + // The flags and key this attempt ran with, which the run's later commands + // repeat. The instance ID stays: `findResume` and `undo` key on it. + if (continued) { + run.update({ + flags, + target: { + ...run.record.target, + keySource: target.keySource, + appId: target.appId, + appLabel: target.appLabel, + }, + }); + } // 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. @@ -966,9 +979,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { // `runs` takes only --runs-dir. const runsStep = runsCommand(carryFlags({ runsDir: options.runsDir }), record.id); - // Run later, maybe without the key exported, so from the run's target, - // the way `runs ` builds them. - const later = carryFlags({ runsDir: options.runsDir, ...targetCarry(record.target) }); + // Run later, maybe without the key exported: from the run's target. + const later = laterCarry(options.runsDir, record.target); const undo = undoCommand(record.id, later); const steps = summary.failed > 0 diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 9c462453b2..e954b4486d 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -133,6 +133,9 @@ describe("runs ", () => { [{ keySource: "linked profile", appId: "app_1" }, "--app app_1 --instance ins_1"], // Maybe no longer exported, and `--instance` is refused next to a `.env`. [{ keySource: "CLERK_SECRET_KEY env var" }, "--secret-key "], + // A framework's own variable, exported the same way: without it, `.env`'s + // key would be read instead. + [{ keySource: "NUXT_CLERK_SECRET_KEY env var" }, "--secret-key "], // Both refuse `--instance`, and the file names the key. [{ keySource: ".env" }, "--runs-dir"], [{ keySource: "accountless app (.clerk/.tmp/keyless.json)" }, "--runs-dir"], diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index 7b0cc52f2e..a67fdca8c2 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -31,7 +31,7 @@ import { } from "./lib/run-store.ts"; import { carryFlags, importFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; import { plural } from "./lib/plural.ts"; -import { describeTarget, isStandInInstanceId, targetCarry } from "./lib/target.ts"; +import { describeTarget, isStandInInstanceId, laterCarry } from "./lib/target.ts"; export type RunsOptions = { json?: boolean; @@ -306,7 +306,7 @@ export async function runs(id: string | undefined, options: RunsOptions = {}): P // The record says where the import's key came from, and so which flags // reach that instance again. - const carry = carryFlags({ runsDir: options.runsDir, ...targetCarry(record.target) }); + const carry = laterCarry(options.runsDir, record.target); if (options.json) log.data(JSON.stringify(showJson(runsDir, record, carry), null, 2)); else printRun(runsDir, record, carry); } diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index c2b18b1f83..73dab2121a 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -653,6 +653,24 @@ describe("deleting", () => { expect(readRun(runsDir, record.id)?.status).toBe("undone"); }); + // Run later, the exported key may be gone: the retry names it as a placeholder. + test("a failed delete with an exported key suggests the retry with the key", async () => { + failing.add("user_b"); + const record = importRun(); + const previous = process.env.CLERK_SECRET_KEY; + process.env.CLERK_SECRET_KEY = "sk_test_x"; + try { + await undo(record.id, withDir({ yes: true, secretKey: undefined })); + } finally { + if (previous === undefined) delete process.env.CLERK_SECRET_KEY; + else process.env.CLERK_SECRET_KEY = previous; + } + + expect(Bun.stripANSI(captured.err)).toContain( + `\`clerk migrate undo ${record.id} --secret-key --runs-dir ${quoteArg(runsDir)}\` again to retry the users that failed`, + ); + }); + test("a failed delete leaves the undo partial, exits 1, and a re-run continues it", async () => { failing.add("user_b"); const record = importRun(); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index eed7b00982..8901374892 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -53,7 +53,7 @@ import { keyInstanceId, printTarget, resolveClerkTarget, - targetCarry, + laterCarry, } from "./lib/target.ts"; import { plural } from "./lib/plural.ts"; import { findInFlight, lookupUsers, type SourceUser } from "./lib/user-lookup.ts"; @@ -325,16 +325,14 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise` builds it. - const undoAgain = undoCommand( - record.id, - carryFlags({ runsDir: options.runsDir, ...targetCarry(target) }), - ); + // After a partial undo, run later, maybe without the key exported: from the target. + const undoLater = undoCommand(record.id, laterCarry(options.runsDir, target)); if (!options.json) printTarget(target); assertSameInstance(record, target, secretKey); @@ -369,8 +367,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 ? ["--include-signed-in"] : []), @@ -570,7 +567,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise { for (const name of new Set(names)) { const value = process.env[name]; - if (value) return { value, source: `${name} env var` }; + if (value) return { value, source: `${name}${ENV_VAR_SOURCE_SUFFIX}` }; } // Priority is by name, not by position: the framework-specific name beats From 095e5297a3d91dda5f03e257718356ea568ffd20 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 02:22:51 -0400 Subject: [PATCH 50/63] fix(migrate): carry the key for a stand-in run continued with --app, and refresh the key on every continue - A run recorded under a key's stand-in ID and continued with `--app` (or a linked profile) now suggests `--secret-key `, not a bare `--app `: no `--instance` resolves a stand-in, and a bare `--app` resolves the development instance. A run with a real instance ID keeps `--app` and `--instance`. - `withLiveKey` records the attempt's key source, app ID and app label on the record each `continueRun` reopens: the import's continue, the import's "nothing left to import" finish, and a continued undo. Before, the latter two kept the first attempt's key for `runs `. - `KEY_SOURCE.envVar` is built from `ENV_VAR_SOURCE_SUFFIX`, `targetCarry` is folded into `laterCarry`, and three import lists are sorted again. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 6 +- .../src/commands/migrate/lib/run-store.ts | 9 +++ .../src/commands/migrate/lib/target.ts | 39 ++++++------ .../cli-core/src/commands/migrate/run.test.ts | 63 ++++++++++++++++++- packages/cli-core/src/commands/migrate/run.ts | 22 ++----- .../src/commands/migrate/runs.test.ts | 11 +++- .../src/commands/migrate/undo.test.ts | 15 +++++ .../cli-core/src/commands/migrate/undo.ts | 5 +- 8 files changed, 128 insertions(+), 42 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 5dcdb21a34..3ea9448edc 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -486,8 +486,10 @@ what reaches the run's instance again, from the key its latest attempt used: `--secret-key ` for a key passed with `--secret-key` or exported (as `CLERK_SECRET_KEY`, or a framework's own variable such as `NUXT_CLERK_SECRET_KEY`), and `--app` and `--instance` for `--app` or a linked -profile. A key file (`.env`, or a keyless app's) gets no target flags: it -refuses `--instance`, and run from the same project it names the key itself. +profile (`--secret-key ` when the run is under a stand-in ID: no +`--instance` resolves one, and a bare `--app` resolves development). A key +file (`.env`, or a keyless app's) gets no target flags: it refuses +`--instance`, and run from the same project it names the key itself. The import's own next steps, and a partial undo's, carry the same. An unknown ID exits 2. 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 7a6cce4198..a26257e65e 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -498,6 +498,15 @@ export function startRun(runsDir: string, init: StartRunInit): Run { return openRun(runsDir, record); } +/** + * A run's record with the key this attempt used, which its later commands + * repeat. The instance ID stays: `findResume` and `undo` key on it. + */ +export function withLiveKey(record: RunRecord, live: RunTarget): RunRecord { + const { keySource, appId, appLabel } = live; + return { ...record, target: { ...record.target, keySource, appId, appLabel } }; +} + /** * Reopens a finished or interrupted run to keep working on it. * diff --git a/packages/cli-core/src/commands/migrate/lib/target.ts b/packages/cli-core/src/commands/migrate/lib/target.ts index b71d487730..01a8798e44 100644 --- a/packages/cli-core/src/commands/migrate/lib/target.ts +++ b/packages/cli-core/src/commands/migrate/lib/target.ts @@ -16,9 +16,9 @@ import { resolveBapiSecretKey } from "../../../lib/bapi-command.ts"; import { INSTANCE_ALIASES, resolveAppContext } from "../../../lib/config.ts"; import { throwUsageError } from "../../../lib/errors.ts"; import { + ENV_VAR_SOURCE_SUFFIX, resolveKeylessTarget, SDK_KEYLESS_SOURCE, - ENV_VAR_SOURCE_SUFFIX, } from "../../../lib/keyless-target.ts"; import { log } from "../../../lib/log.ts"; import { isHuman } from "../../../mode.ts"; @@ -48,7 +48,7 @@ export type ClerkTarget = RunTarget & { export const KEY_SOURCE = { secretKeyFlag: "--secret-key", app: "--app", - envVar: "CLERK_SECRET_KEY env var", + envVar: `CLERK_SECRET_KEY${ENV_VAR_SOURCE_SUFFIX}`, linkedProfile: "linked profile", } as const; @@ -118,8 +118,10 @@ type CarryFor = (target: RunTarget, instance?: string) => TargetCarry; // the keyless rung refuses it before a profile could resolve it. const keyAgain: CarryFor = () => ({ secretKey: "" }); // The app and the instance. A profile's own link resolves development, and -// only from a directory linked to that app. -const appAndInstance: CarryFor = (target, instance) => ({ app: target.appId, instance }); +// only from a directory linked to that app. Without a real instance, the key: +// a bare `--app` resolves development too. +const appAndInstance: CarryFor = (target, instance) => + instance ? { app: target.appId, instance } : keyAgain(target); /** * For each named key source, what reaches a recorded target again. Every @@ -137,23 +139,22 @@ function isNamedKeySource(source: string | undefined): source is NamedKeySource } /** - * The `carryFlags` options that reach a run's target again, for a command run - * later: `runs `'s, and an import's or undo's own next steps. The instance - * goes by ID, not alias (a staging one has none), and only a real one: no - * `--instance` resolves a stand-in. A framework's own key variable, exported - * like `CLERK_SECRET_KEY`, gets the key again. A key file (`.env`, a keyless - * app's) gets nothing: it refuses `--instance`, and run from the same project - * it names the key itself. + * The flags a command meant for later needs to reach a run's target and runs + * folder again: `runs `'s, and an import's or undo's own next steps. The + * instance goes by ID, not alias (a staging one has none), and only a real + * one: no `--instance` resolves a stand-in. A framework's own key variable, + * exported like `CLERK_SECRET_KEY`, gets the key again. A key file (`.env`, a + * keyless app's) gets nothing: it refuses `--instance`, and run from the same + * project it names the key itself. */ -function targetCarry(target: RunTarget): TargetCarry { - const instance = isClerkInstanceId(target.instanceId) ? target.instanceId : undefined; - if (isNamedKeySource(target.keySource)) return TARGET_CARRY[target.keySource](target, instance); - return target.keySource?.endsWith(ENV_VAR_SOURCE_SUFFIX) ? keyAgain(target, instance) : {}; -} - -/** The flags a command meant for later needs to reach this target and runs folder again. */ export function laterCarry(runsDir: string | undefined, target: RunTarget): string[] { - return carryFlags({ runsDir, ...targetCarry(target) }); + const instance = isClerkInstanceId(target.instanceId) ? target.instanceId : undefined; + const carry = isNamedKeySource(target.keySource) + ? TARGET_CARRY[target.keySource](target, instance) + : target.keySource?.endsWith(ENV_VAR_SOURCE_SUFFIX) + ? keyAgain(target, instance) + : {}; + return carryFlags({ runsDir, ...carry }); } /** diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 281d80ef3d..95a856c58e 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -1,9 +1,20 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + mock, + spyOn, + 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 * as plapi from "../../lib/plapi.ts"; import { credentialStoreStubs, useCaptureLog } from "../../test/lib/stubs.ts"; // Every test below names its own `--secret-key`, which short-circuits the @@ -893,6 +904,44 @@ describe("run", () => { expect(Bun.stripANSI(captured.err)).toContain("--secret-key --yes` to retry them"); }); + // The run stays under the first attempt's stand-in, which no `--instance` + // resolves, and a bare `--app` resolves development: the key reaches it. + test("a stand-in run continued with --app carries the key, not a bare --app", async () => { + const application = spyOn(plapi, "fetchApplication").mockResolvedValue({ + application_id: "app_1", + instances: [ + { instance_id: "ins_dev", environment_type: "development", secret_key: "sk_test_dev" }, + { instance_id: "ins_1", environment_type: "production", secret_key: "sk_live_x" }, + ], + } as never); + process.env.CLERK_PLATFORM_API_KEY = "ak_x"; + try { + stubClerk({ failing: new Set(["u2"]), instanceDown: true }); + await run({ ...baseOptions, secretKey: "sk_live_x" }); + const [first] = listRuns(runsDir()); + + process.exitCode = 0; + captured.clear(); + stubClerk({ failing: new Set(["u2"]) }); + await run({ + source: "clerk", + input: "export.json", + yes: true, + app: "app_1", + instance: "prod", + }); + + expect(listRuns(runsDir())).toHaveLength(1); + expect(readRun(runsDir(), first!.id)?.target.keySource).toBe("--app"); + const err = Bun.stripANSI(captured.err); + expect(err).toContain("--secret-key --yes` to retry them"); + expect(err).not.toContain("--app app_1"); + } finally { + application.mockRestore(); + delete process.env.CLERK_PLATFORM_API_KEY; + } + }); + 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); @@ -1060,6 +1109,18 @@ describe("run", () => { expect(readRun(runsDir(), first!.id)?.finishedAt).toBeDefined(); }); + // `runs `'s undo is built from the key the settling attempt used. + test("a continued run with nothing left to do records its own key source", async () => { + await run(baseOptions); + const [first] = listRuns(runsDir()); + interrupt(first!.id); + patchRun(runsDir(), first!.id, { target: { ...first!.target, keySource: ".env" } }); + + await run(baseOptions); + + expect(readRun(runsDir(), first!.id)?.target.keySource).toBe("--secret-key"); + }); + // Automation reads the status from the JSON, not run.json. test("--json reports a continued run with nothing left as finished", 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 32300d54c7..20a59cb1f3 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -65,6 +65,7 @@ import { openUndoOf, importsOfFile, undosOf, + withLiveKey, } from "./lib/run-store.ts"; import { withProgress } from "./lib/progress.ts"; import { createApiScheduler } from "./lib/scheduler.ts"; @@ -73,9 +74,9 @@ import { isClerkInstanceId, isStandInInstanceId, keyInstanceId, + laterCarry, printTarget, resolveClerkTarget, - laterCarry, } from "./lib/target.ts"; import { findInFlight } from "./lib/user-lookup.ts"; import { @@ -821,7 +822,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { // Settled, so a continued run is finished rather than left interrupted, // and the JSON reports it as finished, as run.json now does. const finished = continued - ? continueRun(runsDir, continued, plannedLines, carry).finish() + ? continueRun(runsDir, withLiveKey(continued, target), plannedLines, carry).finish() : undefined; if (options.json) preview({ nothingToImport: true, ...(finished ? { run: finished } : {}) }); @@ -862,7 +863,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { await resolveRunsDir(options.runsDir, { write: true }); const flags = pickImportFlags(options); const run = continued - ? continueRun(runsDir, continued, plannedLines, carry) + ? continueRun(runsDir, withLiveKey(continued, target), plannedLines, carry) : startRun(runsDir, { kind: "import", target, @@ -870,19 +871,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { file: { path: filePath, sha256 }, flags, }); - // The flags and key this attempt ran with, which the run's later commands - // repeat. The instance ID stays: `findResume` and `undo` key on it. - if (continued) { - run.update({ - flags, - target: { - ...run.record.target, - keySource: target.keySource, - appId: target.appId, - appLabel: target.appLabel, - }, - }); - } + // The flags this attempt ran with, which the run's later commands repeat. + if (continued) run.update({ flags }); // 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. diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index e954b4486d..7ce943ea22 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -139,8 +139,15 @@ describe("runs ", () => { // Both refuse `--instance`, and the file names the key. [{ keySource: ".env" }, "--runs-dir"], [{ keySource: "accountless app (.clerk/.tmp/keyless.json)" }, "--runs-dir"], - // No `--instance` resolves a stand-in. - [{ keySource: "--app", appId: "app_1", instanceId: "key_abc" }, "--app app_1 --runs-dir"], + // No `--instance` resolves a stand-in, and a bare `--app` resolves development: the key. + [ + { keySource: "--app", appId: "app_1", instanceId: "key_abc" }, + "--secret-key --runs-dir", + ], + [ + { keySource: "linked profile", appId: "app_1", instanceId: "key_abc" }, + "--secret-key --runs-dir", + ], ])("the next command keeps the import's target from %o", async (target, expected) => { const record = partialImport(); const file = path.join(runsDir, record.id, "run.json"); diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index 73dab2121a..c52eb5ebac 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -696,6 +696,21 @@ describe("deleting", () => { expect(readRun(runsDir, record.id)?.status).toBe("undone"); }); + // `runs ` builds the undo's retry from its record: the key this + // attempt used, not the first one's `.env`, which may not be there. + test("a continued undo records its own key source", async () => { + failing.add("user_b"); + const record = importRun(); + await undo(record.id, withDir({ yes: true })); + const [partial] = listRuns(runsDir).filter((candidate) => candidate.kind === "undo"); + patchRun(runsDir, partial!.id, { target: { ...partial!.target, keySource: ".env" } }); + + process.exitCode = 0; + await undo(record.id, withDir({ yes: true })); + + expect(readRun(runsDir, partial!.id)?.target.keySource).toBe("--secret-key"); + }); + test("--json --yes returns the undo run and the result", async () => { const record = importRun(); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 8901374892..f130b38e22 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -43,6 +43,7 @@ import { openUndoOf, inUseMessage, noRunMessage, + withLiveKey, } from "./lib/run-store.ts"; import { withProgress, type ProgressUpdate } from "./lib/progress.ts"; import { createApiScheduler, type ApiScheduler } from "./lib/scheduler.ts"; @@ -51,9 +52,9 @@ import { describeTarget, isStandInInstanceId, keyInstanceId, + laterCarry, printTarget, resolveClerkTarget, - laterCarry, } from "./lib/target.ts"; import { plural } from "./lib/plural.ts"; import { findInFlight, lookupUsers, type SourceUser } from "./lib/user-lookup.ts"; @@ -512,7 +513,7 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise Date: Fri, 16 Oct 2026 02:49:14 -0400 Subject: [PATCH 51/63] fix(migrate): show unconfirmed creates and identifiers left to attach, and name the undo run a stop left behind - A create no answer confirmed writes a second `creating` line with its error. `runs ` lists those users under "Not confirmed", counts them, and puts their errors in the breakdown (`unconfirmed` in `--json`). Before, a user the import counted as failed didn't appear anywhere in `runs `. - `finish()` records `counts.pending`, the created users with an email or phone left to attach. The import prints "Identifiers left to attach", returns `result.pending`, and offers the continue whenever a continue can finish something (failed, not sent or pending), not only after a failure. - Undo's `--json` names the undo run a Ctrl-C or a write failure left on disk, instead of `run: null`. With every user already gone, its consent asks to mark the run undone rather than "delete 0 users". - `withAttempt` (was `withLiveKey`) also sets an import's flags, so every continue records them. Lock releases go through one `releaseLock`, which logs. `listRuns` and `readRun` log any failure other than a missing file. - `printRun` renders `showJson`'s object. README, next-steps comment and e2e test name updated. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 52 +++++++++-------- .../src/commands/migrate/import-users.test.ts | 14 ++++- .../src/commands/migrate/import-users.ts | 13 +++-- .../src/commands/migrate/lib/run-store.ts | 58 ++++++++++++------- .../cli-core/src/commands/migrate/run.test.ts | 23 ++++++++ packages/cli-core/src/commands/migrate/run.ts | 23 ++++++-- .../src/commands/migrate/runs.test.ts | 39 ++++++++++++- .../cli-core/src/commands/migrate/runs.ts | 46 ++++++++------- .../src/commands/migrate/undo.test.ts | 34 +++++++++++ .../cli-core/src/commands/migrate/undo.ts | 30 +++++++--- packages/cli-core/src/lib/next-steps.ts | 6 +- test/e2e/migrate.test.ts | 4 +- 12 files changed, 249 insertions(+), 93 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 3ea9448edc..e8669349fb 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -146,19 +146,22 @@ for each `sourceId` wins. A `429` retry, an extra email or phone that did not attach, a first phone Clerk refused (which the summary also counts), and a validation failure all land in the line's `error` 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 -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, is still `creating` or -was never sent (`counts.notSent`), and `complete` otherwise. A run whose process -died, or that never recorded a finish time, lists as `interrupted`. A Ctrl-C -leaves a run that way. Its run ID and folder are printed as the run starts, so -they are on screen however it ends. 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. +`creating` is written as a user's `POST /v1/users` goes out. It stays the latest +status when no answer says whether the create landed: an abort, a network error, +or a 5xx. The import counts a network error or a 5xx as failed, and a second +`creating` line records its `error`. A `created` line with `pending` lists the +extra emails and phones not yet attached, and `counts.pending` (`result.pending` +in the import's `--json`) counts those users. + +A run is `partial` when any user failed, was skipped, is still `creating`, has +identifiers still `pending`, or was never sent (`counts.notSent`), and +`complete` otherwise. A run whose process died, or that never recorded a finish +time, lists as `interrupted`. A Ctrl-C leaves a run that way. Its run ID and +folder are printed as the run starts, so they are on screen however it ends. 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`), because they hold user data. @@ -268,7 +271,7 @@ decides what happens: | ----------------------------------------- | ---------------------------------------------------------------------- | | 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 | +| `partial` | continues the same run, retrying the users that did not finish earlier | | `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 | @@ -476,22 +479,22 @@ clerk migrate runs --json 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, then what -to run next: for an interrupted or partial import, the import that continues -it, with the flags it ran with, then the undo. The continue is left out when -importing the file again might not reach this run: a newer run of the same -file and source that may be for the same instance (one recorded under a key's +users that failed, were skipped, or are `creating` with no answer to confirm the +create (`unconfirmed` in `--json`), with the path to the full record, then what +to run next: for an interrupted or partial import, the import that continues it, +with the flags it ran with, then the undo. The continue is left out when +importing the file again might not reach this run: a newer run of the same file +and source that may be for the same instance (one recorded under a key's stand-in ID may be any), a file changed since, or any undo of it. Both carry what reaches the run's instance again, from the key its latest attempt used: `--secret-key ` for a key passed with `--secret-key` or exported (as `CLERK_SECRET_KEY`, or a framework's own variable such as `NUXT_CLERK_SECRET_KEY`), and `--app` and `--instance` for `--app` or a linked profile (`--secret-key ` when the run is under a stand-in ID: no -`--instance` resolves one, and a bare `--app` resolves development). A key -file (`.env`, or a keyless app's) gets no target flags: it refuses -`--instance`, and run from the same project it names the key itself. -The import's own next steps, and a partial undo's, carry the same. An unknown -ID exits 2. +`--instance` resolves one, and a bare `--app` resolves development). A key file +(`.env`, or a keyless app's) gets no target flags: it refuses `--instance`, and +run from the same project it names the key itself. The import's own next steps, +and a partial undo's, carry the same. An unknown ID exits 2. ### `clerk migrate undo` @@ -716,6 +719,7 @@ stamping every user with today's. | `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/users?external_id=…` | `migrate import` — a continue looks up users whose create was in flight | | `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 | diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index 1b277f1877..94e830da0b 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -711,7 +711,9 @@ describe("importUsers", () => { }); expect(summary.failed).toBe(1); - expect(allLines.map((line) => line.status)).toEqual(["creating"]); + // A second `creating` line, so `runs ` can say why. + expect(allLines.map((line) => line.status)).toEqual(["creating", "creating"]); + expect(allLines[1]?.error).toContain("a re-run checks"); expect([...summary.errorBreakdown.keys()][0]).toContain("a re-run checks"); }); @@ -726,7 +728,15 @@ describe("importUsers", () => { }); expect(summary).toMatchObject({ successful: 0, failed: 1 }); - expect(allLines).toEqual([{ sourceId: "u1", status: "creating" }]); + expect(allLines).toEqual([ + { sourceId: "u1", status: "creating" }, + { + sourceId: "u1", + status: "creating", + error: expect.stringContaining("without a user ID"), + code: "unknown", + }, + ]); }); // Every create after the first refusal would be refused too, so none is diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index fa93fb44a9..e4af51aca5 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -414,11 +414,14 @@ export async function importUsers(options: ImportUsersOptions): Promise> & { total: number; notSent?: number }; +export type RunCounts = Partial> & { + total: number; + notSent?: number; + /** Created users with extra identifiers left to attach. */ + pending?: number; +}; export type RunRecord = { id: string; @@ -217,6 +222,7 @@ export function lockFile(runsDir: string, id: string): string { } const isExists = (error: unknown) => (error as NodeJS.ErrnoException).code === "EEXIST"; +const isMissing = (error: unknown) => (error as NodeJS.ErrnoException).code === "ENOENT"; /** * Takes the run's lock. Created exclusively (`wx`), so two processes can't @@ -252,18 +258,24 @@ function acquireLock(runsDir: string, id: string): void { } } +function releaseLock(runsDir: string, id: string): void { + log.debug(`migrate: released run ${id}`); + fs.rmSync(lockFile(runsDir, id), { force: true }); +} + // --- Reading --------------------------------------------------------------- /** One run's record, or `undefined` when the folder holds no readable run. */ export function readRun(runsDir: string, id: string): RunRecord | undefined { + const file = path.join(runDir(runsDir, id), RUN_FILE); try { - const parsed = JSON.parse( - fs.readFileSync(path.join(runDir(runsDir, id), RUN_FILE), "utf-8"), - ) as RunRecord; - return parsed && typeof parsed === "object" && parsed.id === id ? parsed : undefined; - } catch { - return undefined; + const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as RunRecord; + if (parsed && typeof parsed === "object" && parsed.id === id) return parsed; + log.debug(`migrate: ${file} is not run ${id}'s record`); + } catch (error) { + if (!isMissing(error)) log.debug(`migrate: could not read ${file}: ${String(error)}`); } + return undefined; } /** Every line of a run's `users.ndjson`, in the order written. */ @@ -365,7 +377,8 @@ export function listRuns(runsDir: string): RunRecord[] { let entries: fs.Dirent[]; try { entries = fs.readdirSync(runsDir, { withFileTypes: true }); - } catch { + } catch (error) { + if (!isMissing(error)) log.debug(`migrate: could not read ${runsDir}: ${String(error)}`); return []; } return entries @@ -442,15 +455,16 @@ function openRun(runsDir: string, record: RunRecord): Run { 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( + const pending = lines.filter( (line) => line.status === "created" && (line.pending?.length ?? 0) > 0, ).length; + if (pending) counts.pending = pending; const unfinished = (counts.failed ?? 0) + (counts.skipped ?? 0) + (counts.creating ?? 0) + (counts.notSent ?? 0) + - pendingAttaches; + pending; run.update({ counts, status: unfinished > 0 ? "partial" : "complete", @@ -460,7 +474,7 @@ function openRun(runsDir: string, record: RunRecord): Run { return run.record; }, release() { - fs.rmSync(path.join(dir, LOCK_FILE), { force: true }); + releaseLock(runsDir, record.id); }, }; return run; @@ -499,12 +513,17 @@ export function startRun(runsDir: string, init: StartRunInit): Run { } /** - * A run's record with the key this attempt used, which its later commands - * repeat. The instance ID stays: `findResume` and `undo` key on it. + * A run's record with what this attempt ran with, which its later commands + * repeat: where its key came from (never the key), and an import's flags. The + * instance ID stays: `findResume` and `undo` key on it. */ -export function withLiveKey(record: RunRecord, live: RunTarget): RunRecord { +export function withAttempt(record: RunRecord, live: RunTarget, flags?: ImportFlags): RunRecord { const { keySource, appId, appLabel } = live; - return { ...record, target: { ...record.target, keySource, appId, appLabel } }; + return { + ...record, + target: { ...record.target, keySource, appId, appLabel }, + ...(flags ? { flags } : {}), + }; } /** @@ -533,7 +552,7 @@ export function continueRun( fresh.undoneBy !== record.undoneBy || readUserLines(runsDir, record.id).length !== plannedLines; if (changed) { - fs.rmSync(lockFile(runsDir, record.id), { force: true }); + releaseLock(runsDir, record.id); throwUsageError( `Run ${record.id} changed while this import was waiting, so its plan is out of date. ` + "Nothing was imported. Run the command again.", @@ -545,7 +564,7 @@ export function continueRun( // import's status and lines as they were. const undoRun = record.kind === "import" ? openUndoOf(runsDir, record.id) : undefined; if (undoRun) { - fs.rmSync(lockFile(runsDir, record.id), { force: true }); + releaseLock(runsDir, record.id); throwUsageError(unfinishedUndoMessage(record.id, undoRun.id, carry)); } // A crash mid-write leaves a torn last line; end it so the next append @@ -569,10 +588,7 @@ export function continueRun( */ export function lockRun(runsDir: string, id: string): () => void { acquireLock(runsDir, id); - return () => { - log.debug(`migrate: released run ${id}`); - fs.rmSync(lockFile(runsDir, id), { force: true }); - }; + return () => releaseLock(runsDir, id); } /** diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 95a856c58e..ccec3d8424 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -942,6 +942,29 @@ describe("run", () => { } }); + // Created, but its second email never attached: the run is partial, and + // the import says so and offers the continue that attaches it. + test("an attach with no answer is reported, with the continue", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([ + { id: "u1", primary_email_address: "a@x.dev", unverified_email_addresses: "b@x.dev" }, + ]), + ); + const clerk = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => + new URL(input.toString()).pathname === "/v1/email_addresses" + ? new Response("boom", { status: 503 }) + : clerk(input, init)) as typeof fetch; + + await run(baseOptions); + + expect(listRuns(runsDir())[0]).toMatchObject({ status: "partial", counts: { pending: 1 } }); + const err = Bun.stripANSI(captured.err); + expect(err).toContain("Identifiers left to attach: 1 user"); + expect(err).toContain("--secret-key --yes` to retry them"); + }); + 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); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 20a59cb1f3..01a951405a 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -65,7 +65,7 @@ import { openUndoOf, importsOfFile, undosOf, - withLiveKey, + withAttempt, } from "./lib/run-store.ts"; import { withProgress } from "./lib/progress.ts"; import { createApiScheduler } from "./lib/scheduler.ts"; @@ -455,6 +455,10 @@ 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}`); + // Created, but an email or phone did not attach: a re-run attaches it. + const pending = record.counts.pending ?? 0; + if (pending > 0) + lines.push(`${yellow("Identifiers left to attach:")} ${plural(pending, "user")}`); // Printed here, after the progress bar, which would redraw over it mid-run. if (summary.stopReason) { lines.push( @@ -822,7 +826,12 @@ export async function run(rawOptions: MigrateRunOptions): Promise { // Settled, so a continued run is finished rather than left interrupted, // and the JSON reports it as finished, as run.json now does. const finished = continued - ? continueRun(runsDir, withLiveKey(continued, target), plannedLines, carry).finish() + ? continueRun( + runsDir, + withAttempt(continued, target, pickImportFlags(options)), + plannedLines, + carry, + ).finish() : undefined; if (options.json) preview({ nothingToImport: true, ...(finished ? { run: finished } : {}) }); @@ -863,7 +872,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { await resolveRunsDir(options.runsDir, { write: true }); const flags = pickImportFlags(options); const run = continued - ? continueRun(runsDir, withLiveKey(continued, target), plannedLines, carry) + ? continueRun(runsDir, withAttempt(continued, target, flags), plannedLines, carry) : startRun(runsDir, { kind: "import", target, @@ -871,8 +880,6 @@ export async function run(rawOptions: MigrateRunOptions): Promise { file: { path: filePath, sha256 }, flags, }); - // The flags this attempt ran with, which the run's later commands repeat. - if (continued) run.update({ flags }); // 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. @@ -936,6 +943,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { created: summary.successful, failed: summary.failed, notSent: summary.notSent, + pending: record.counts.pending ?? 0, ...(summary.stopReason ? { stopReason: summary.stopReason } : {}), skipped: checks.rejects.length + withoutPassword.length, errors: [...summary.errorBreakdown].map(([error, count]) => ({ error, count })), @@ -972,8 +980,11 @@ export async function run(rawOptions: MigrateRunOptions): Promise { // Run later, maybe without the key exported: from the run's target. const later = laterCarry(options.runsDir, record.target); const undo = undoCommand(record.id, later); + // Anything a continue can finish. Not skips alone: the same file and + // flags skip them again. + const unfinished = summary.failed + summary.notSent + (record.counts.pending ?? 0); const steps = - summary.failed > 0 + unfinished > 0 ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS( runsStep, // The same import again continues this run; --new-run would not. diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 7ce943ea22..6ba7ab8f95 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -124,6 +124,41 @@ describe("runs ", () => { }); }); + // The import counted these as failed, so `runs` lists them and why: a + // create no answer confirmed, and a user with an email left to attach. + test("shows creates no answer confirmed, and identifiers left to attach", async () => { + fs.writeFileSync(usersFile(), "[]"); + const run = startRun(runsDir, { + kind: "import", + target: TARGET, + source: "clerk", + file: { path: usersFile(), sha256: sha256File(usersFile()) }, + }); + run.append({ sourceId: "a", status: "creating" }); + run.append({ sourceId: "a", status: "creating", error: "bad gateway (a re-run checks)" }); + run.append({ + sourceId: "b", + status: "created", + clerkId: "user_b", + pending: [{ kind: "email", value: "b2@x.dev", verified: true }], + }); + const record = run.finish(); + + await runs(record.id, { runsDir, json: true }); + expect(JSON.parse(captured.out)).toMatchObject({ + run: { state: "partial", counts: { creating: 1, pending: 1 } }, + errors: [{ error: "bad gateway (a re-run checks)", count: 1 }], + unconfirmed: [{ sourceId: "a" }], + }); + + captured.clear(); + await runs(record.id, { runsDir }); + const err = Bun.stripANSI(captured.err); + expect(err).toContain("1 created, 1 left to attach, 1 not confirmed"); + expect(err).toContain("Not confirmed (Clerk may have created them; a continue checks) (1)"); + expect(err).toContain("1 user: bad gateway (a re-run checks)"); + }); + // The record says where the key came from, and which instance to reach // again. One row per key source, so none is left to drop its target. test.each([ @@ -182,7 +217,7 @@ describe("runs ", () => { expect(next[0]).toContain( `clerk migrate import ${quoteArg(usersFile())} --source clerk --runs-dir ${quoteArg(runsDir)}`, ); - expect(next[0]).toContain("to retry the users that failed or were skipped"); + expect(next[0]).toContain("to retry the users it didn't finish"); }); // Left out, the continue creates the users --require-password held back. @@ -249,7 +284,7 @@ describe("runs ", () => { const { next } = JSON.parse(captured.out) as { next: string[] }; expect(next).toHaveLength(2); - expect(next[0]).toContain("to retry the users that failed or were skipped"); + expect(next[0]).toContain("to retry the users it didn't finish"); }); test("an unknown ID is a usage error", async () => { diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index a67fdca8c2..8aa7976b08 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -23,6 +23,7 @@ import { runDir, runState, sha256File, + type RunCounts, type RunRecord, type RunState, type UserLine, @@ -54,6 +55,12 @@ function colorState(state: RunState): string { return state; } +// `creating`: a create no answer confirmed. Clerk may hold the user. +const COUNT_LABELS: Partial> = { + creating: "not confirmed", + pending: "left to attach", +}; + /** The counts that matter for a run's kind, in the order they are read. */ function formatCounts(record: RunRecord): string { const counts = record.counts; @@ -62,10 +69,10 @@ function formatCounts(record: RunRecord): string { ? (["exported", "skipped"] as const) : record.kind === "undo" ? (["deleted", "failed"] as const) - : (["created", "failed", "skipped"] as const); + : (["created", "pending", "creating", "failed", "skipped"] as const); const parts = order .filter((status) => (counts[status] ?? 0) > 0 || status === order[0]) - .map((status) => `${counts[status] ?? 0} ${status}`); + .map((status) => `${counts[status] ?? 0} ${COUNT_LABELS[status] ?? status}`); return parts.join(", "); } @@ -95,7 +102,9 @@ function pad(rows: string[][]): string[] { function errorBreakdown(lines: UserLine[]): { error: string; count: number }[] { const counts = new Map(); for (const line of lines) { - if (line.status === "failed" && line.error) tallyError(counts, line.error); + if ((line.status === "failed" || line.status === "creating") && line.error) { + tallyError(counts, line.error); + } } return [...counts].map(([error, count]) => ({ error, count })).sort((a, b) => b.count - a.count); } @@ -160,8 +169,7 @@ function nextCommands( if (!again || state === "complete" || !continuesThisRun(runsDir, record)) return [undo]; // Continuing is what a stopped run is for: the same file, source and // target pick it up where it left off. - const why = - state === "interrupted" ? "continue it" : "retry the users that failed or were skipped"; + const why = state === "interrupted" ? "continue it" : "retry the users it didn't finish"; return [`Run \`${again}\` to ${why}`, undo]; } if (record.kind === "undo" && state !== "complete" && record.undoes) { @@ -203,6 +211,7 @@ function printList(runsDir: string, records: RunRecord[]): void { log.info(dim(plural(records.length, "run"))); } +/** One run in full: `--json` prints it, and `printRun` renders it. */ function showJson(runsDir: string, record: RunRecord, carry: string[]) { const lines = [...latestUserLines(runsDir, record.id).values()]; const state = runState(runsDir, record); @@ -213,6 +222,7 @@ function showJson(runsDir: string, record: RunRecord, carry: string[]) { errors: errorBreakdown(lines), failed: lines.filter((line) => line.status === "failed"), skipped: lines.filter((line) => line.status === "skipped"), + unconfirmed: lines.filter((line) => line.status === "creating"), }; } @@ -230,17 +240,15 @@ function printUsers(title: string, lines: UserLine[], usersFile: string): void { } function printRun(runsDir: string, record: RunRecord, carry: string[]): void { - const state = runState(runsDir, record); - const dir = runDir(runsDir, record.id); - const usersFile = path.join(dir, USERS_FILE); - const lines = [...latestUserLines(runsDir, record.id).values()]; + const view = showJson(runsDir, record, carry); + const usersFile = path.join(runDir(runsDir, record.id), USERS_FILE); log.info(`Runs folder: ${runsDir}`); log.blank(); const header: [string, string | undefined][] = [ ["Run", record.id], ["Kind", record.kind], - ["Status", colorState(state)], + ["Status", colorState(view.run.state)], ["Started", formatDate(record.startedAt)], ["Finished", record.finishedAt ? formatDate(record.finishedAt) : undefined], ["Target", describeTarget(record.target)], @@ -256,29 +264,25 @@ function printRun(runsDir: string, record: RunRecord, carry: string[]): void { const width = Math.max(...shown.map(([label]) => label.length)) + 2; for (const [label, value] of shown) log.info(`${bold(label.padEnd(width))}${value}`); - const errors = errorBreakdown(lines); - if (errors.length > 0) { + if (view.errors.length > 0) { log.blank(); log.info(bold("Error breakdown")); - for (const { error, count } of errors) { + for (const { error, count } of view.errors) { log.info(` ${red(String(count))} ${count === 1 ? "user" : "users"}: ${error}`); } } + printUsers("Failed", view.failed, usersFile); printUsers( - "Failed", - lines.filter((line) => line.status === "failed"), - usersFile, - ); - printUsers( - "Skipped", - lines.filter((line) => line.status === "skipped"), + "Not confirmed (Clerk may have created them; a continue checks)", + view.unconfirmed, usersFile, ); + printUsers("Skipped", view.skipped, usersFile); log.blank(); log.info(dim(`Every outcome: ${usersFile}`)); - for (const step of nextCommands(runsDir, record, state, carry)) log.info(` → ${step}`); + for (const step of view.next) log.info(` → ${step}`); } /** diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index c52eb5ebac..d21e048eb3 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -212,6 +212,17 @@ describe("refusals, all exit 2 and delete nothing", () => { expect(deletes()).toHaveLength(0); }); + // Nothing is left to delete, but the import is still marked undone. + test("no consent with every user already gone names the mark, not 0 deletes", async () => { + instanceUsers.clear(); + const record = importRun(); + + const error = (await undo(record.id, withDir()).catch((caught: unknown) => caught)) as CliError; + + expect(error.message).toContain(`marks run ${record.id} undone and needs consent`); + expect(error.message).not.toContain("0 users"); + }); + // Another process continues the import while the undo previews it. describe("the run changing during the preview", () => { const duringPreview = (act: () => void) => { @@ -463,6 +474,29 @@ describe("stopped part-way", () => { expect(fs.existsSync(path.join(runsDir, undoRun?.id ?? "", "lock"))).toBe(false); expect(readRun(runsDir, record.id)?.status).not.toBe("undone"); }); + + // A script reads `run` to find the undo's folder. + test("--json names the undo run a Ctrl-C left on disk", async () => { + const record = importRun(); + const clerk = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const response = await clerk(input, init); + if (init?.method === "DELETE") { + beginInterrupt(); + abortInFlight(); + } + return response; + }) as typeof fetch; + try { + await expect(undo(record.id, withDir({ yes: true, json: true }))).rejects.toThrow(); + } finally { + _resetInterruptState(); + globalThis.fetch = clerk; + } + + const undoRun = listRuns(runsDir).find((r) => r.kind === "undo"); + expect(JSON.parse(captured.out)).toMatchObject({ stopped: true, run: { id: undoRun?.id } }); + }); }); // `CLERK_MODE=human` with no terminal: the prompt would fail to open one and spin. diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index f130b38e22..385223b14f 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -43,7 +43,7 @@ import { openUndoOf, inUseMessage, noRunMessage, - withLiveKey, + withAttempt, } from "./lib/run-store.ts"; import { withProgress, type ProgressUpdate } from "./lib/progress.ts"; import { createApiScheduler, type ApiScheduler } from "./lib/scheduler.ts"; @@ -314,8 +314,8 @@ type UndoOutcome = | { consent: "required" } | { changed: true; signedInSincePreview?: number } | { recheckFailed: true } - | { writeFailed: true } - | { stopped: true } + | { writeFailed: true; run: RunRecord | null } + | { stopped: true; run: RunRecord | null } | { run: RunRecord; result: { deleted: number; failed: number; errors: { error: string; count: number }[] }; @@ -402,17 +402,25 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 + ? `permanently deletes ${plural(present.length, "user")}` + : `marks run ${record.id} undone`; if (!canPrompt(options)) { printJson({ consent: "required" }); throwUsageError( - `\`clerk migrate undo\` permanently deletes ${plural(present.length, "user")} and needs consent. Pass --yes to confirm.`, + `\`clerk migrate undo\` ${what} and needs consent. Pass --yes to confirm.`, undefined, undefined, [{ command, description: "Delete the users this import created" }], ); } const proceed = await confirm({ - message: `Permanently delete ${plural(present.length, "user")}?`, + message: + present.length > 0 + ? `Permanently delete ${plural(present.length, "user")}?` + : `Mark run ${record.id} undone? Its users are already gone.`, default: false, }); if (!proceed) throwUserAbort(); @@ -437,10 +445,17 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise [ - `Run \`${runsCommand}\` to see every user that failed and why`, + `Run \`${runsCommand}\` to see every user that didn't finish, and why`, `Run \`${retryCommand}\` to retry them, in the same run`, `Run \`${undoCommand}\` to delete the users it created`, ], @@ -105,8 +105,8 @@ 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 + * not: they name the command that shows each user's outcome, and the one 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 { diff --git a/test/e2e/migrate.test.ts b/test/e2e/migrate.test.ts index c536afeeb8..9b7c15ddbc 100644 --- a/test/e2e/migrate.test.ts +++ b/test/e2e/migrate.test.ts @@ -1,5 +1,5 @@ /** - * Live-BAPI tests for `clerk migrate import`, covering what only a real + * Live-BAPI tests for `clerk migrate import` and `undo`, covering what only a real * instance can answer: * * - A Supabase export round-trips: the dry run counts it without writing, an @@ -93,7 +93,7 @@ function writeExport(users: Record[]): string { return file; } -test("a Supabase export dry-runs, imports, and its passwords verify", async () => { +test("a Supabase export dry-runs, imports, its passwords verify, and undo deletes it", async () => { const users = await Promise.all( [0, 1].map(async () => { const hex = randomBytes(6).toString("hex"); From c962ad5c28f822b358f10e33e3025a3f5778fe0a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 03:23:29 -0400 Subject: [PATCH 52/63] fix(migrate): offer the continue for any partial import, and count a stopped undo from its lines - The import offers the continue whenever the run ends `partial`, as `runs ` does. A skip the checks named a fix for imports once the setting changes; one that can't be fixed is skipped again. - `countLines` counts `pending`, so an interrupted run shows "left to attach" as a finished one does, and `finish()` uses that count. - A stopped undo's `--json` prints its run counted from its lines, with its `state`, instead of the record as it opened. One `run` variable replaces the `undoRun` mirror. - The consent refusal's suggested command says "Mark run undone" when every user is already gone. Two comments no longer say "failed or skipped". Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 4 +++ .../src/commands/migrate/lib/run-store.ts | 18 +++++------ .../cli-core/src/commands/migrate/run.test.ts | 28 +++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 9 +++--- .../src/commands/migrate/runs.test.ts | 26 +++++++++++++++- .../cli-core/src/commands/migrate/runs.ts | 2 +- .../src/commands/migrate/undo.test.ts | 10 ++++++- .../cli-core/src/commands/migrate/undo.ts | 30 ++++++++++++++----- 8 files changed, 103 insertions(+), 24 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index e8669349fb..a37556880a 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -589,6 +589,10 @@ before that, the object says why: - `"writeFailed": true`: the file system refused a write to the runs folder. - `"stopped": true`: anything else stopped it, such as a Ctrl-C. +With `writeFailed` or `stopped`, `run` is the undo run as it stands on disk, +once one was written: its counts come from its lines, and `state` says how it +stopped, as in `clerk migrate runs`. + ### `clerk migrate help` `clerk migrate help` and `clerk migrate --help` print the help for the 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 7654d12005..40e6968439 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -398,12 +398,18 @@ function writeRecord(runsDir: string, record: RunRecord): void { fs.renameSync(temp, file); } -/** Totals by each source ID's latest status. */ +/** + * Totals by each source ID's latest status, and `pending`: the created users + * whose extra identifiers never attached, left for a continue. + */ export function countLines(latest: Iterable): RunCounts { const counts: RunCounts = { total: 0 }; for (const line of latest) { counts.total++; counts[line.status] = (counts[line.status] ?? 0) + 1; + if (line.status === "created" && (line.pending?.length ?? 0) > 0) { + counts.pending = (counts.pending ?? 0) + 1; + } } return counts; } @@ -451,20 +457,14 @@ function openRun(runsDir: string, record: RunRecord): Run { writeRecord(runsDir, run.record); }, finish(options) { - const lines = [...latestUserLines(runsDir, run.record.id).values()]; - const counts = countLines(lines); + const counts = countLines(latestUserLines(runsDir, run.record.id).values()); if (options?.notSent) counts.notSent = options.notSent; - // A created user whose extra identifiers never attached is left for a continue. - const pending = lines.filter( - (line) => line.status === "created" && (line.pending?.length ?? 0) > 0, - ).length; - if (pending) counts.pending = pending; const unfinished = (counts.failed ?? 0) + (counts.skipped ?? 0) + (counts.creating ?? 0) + (counts.notSent ?? 0) + - pending; + (counts.pending ?? 0); run.update({ counts, status: unfinished > 0 ? "partial" : "complete", diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index ccec3d8424..1f80562e4d 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -965,6 +965,34 @@ describe("run", () => { expect(err).toContain("--secret-key --yes` to retry them"); }); + // A skip the checks named a fix for imports once the setting changes: the + // import offers the same continue `runs ` does, and it finishes the run. + test("a run left partial only by skips offers the continue, which finishes it", async () => { + const settings = (required: boolean) => ({ + attributes: { email_address: { enabled: true, required } }, + enterprise_sso: { enabled: true }, + }); + stubClerk({ settings: settings(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, allowPartial: true }); + const [first] = listRuns(runsDir()); + expect(first).toMatchObject({ status: "partial", counts: { created: 1, skipped: 1 } }); + expect(Bun.stripANSI(captured.err)).toContain("--secret-key --yes` to retry them"); + + requests = []; + stubClerk({ settings: settings(false) }); + await run({ ...baseOptions, allowPartial: true }); + + expect(created()).toEqual(["u2"]); + expect(readRun(runsDir(), first!.id)?.status).toBe("complete"); + }); + 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); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 01a951405a..b74d6c1874 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -315,7 +315,7 @@ type ResumeCase = * * - 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 + * - partial: continue it, retrying the users it didn't finish * - complete: nothing; the file is already in * * @throws UsageError when that run is still running in another process, or @@ -980,11 +980,10 @@ export async function run(rawOptions: MigrateRunOptions): Promise { // Run later, maybe without the key exported: from the run's target. const later = laterCarry(options.runsDir, record.target); const undo = undoCommand(record.id, later); - // Anything a continue can finish. Not skips alone: the same file and - // flags skip them again. - const unfinished = summary.failed + summary.notSent + (record.counts.pending ?? 0); + // A partial run continues, as `runs ` says: a skip fixed since (a + // setting the checks named) imports then, and one that isn't is skipped again. const steps = - unfinished > 0 + record.status === "partial" ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS( runsStep, // The same import again continues this run; --new-run would not. diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 6ba7ab8f95..5eec4c1617 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -4,7 +4,13 @@ import os from "node:os"; import path from "node:path"; import { quoteArg } from "../../lib/json-body.ts"; import { useCaptureLog } from "../../test/lib/stubs.ts"; -import { type RunRecord, sha256File, startRun, type StartRunInit } from "./lib/run-store.ts"; +import { + continueRun, + type RunRecord, + sha256File, + startRun, + type StartRunInit, +} from "./lib/run-store.ts"; import { runs } from "./runs.ts"; const captured = useCaptureLog(); @@ -159,6 +165,24 @@ describe("runs ", () => { expect(err).toContain("1 user: bad gateway (a re-run checks)"); }); + // An unfinished run is counted from its lines, `pending` included. + test("counts identifiers left to attach on a run that never finished", async () => { + const record = partialImport(); + const run = continueRun(runsDir, record); + run.append({ + sourceId: "e", + status: "created", + clerkId: "user_e", + pending: [{ kind: "email", value: "e2@x.dev", verified: true }], + }); + run.release(); + + await runs(record.id, { runsDir }); + + expect(Bun.stripANSI(captured.err)).toContain("interrupted"); + expect(Bun.stripANSI(captured.err)).toContain("2 created, 1 left to attach"); + }); + // The record says where the key came from, and which instance to reach // again. One row per key source, so none is left to drop its target. test.each([ diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index 8aa7976b08..d895ab33f3 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -39,7 +39,7 @@ export type RunsOptions = { runsDir?: string; }; -/** How many failed or skipped users `runs ` prints before pointing at the file. */ +/** How many users of each list `runs ` prints before pointing at the file. */ const USER_LIST_LIMIT = 20; function formatDate(iso: string): string { diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index d21e048eb3..711242c73d 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -221,6 +221,7 @@ describe("refusals, all exit 2 and delete nothing", () => { expect(error.message).toContain(`marks run ${record.id} undone and needs consent`); expect(error.message).not.toContain("0 users"); + expect(error.examples?.[0]?.description).toBe(`Mark run ${record.id} undone`); }); // Another process continues the import while the undo previews it. @@ -495,7 +496,14 @@ describe("stopped part-way", () => { } const undoRun = listRuns(runsDir).find((r) => r.kind === "undo"); - expect(JSON.parse(captured.out)).toMatchObject({ stopped: true, run: { id: undoRun?.id } }); + // As it stands on disk, not as it opened: counted from its lines. + const printed = JSON.parse(captured.out) as { run: RunRecord & { state: string } }; + expect(printed).toMatchObject({ + stopped: true, + run: { id: undoRun?.id, state: "interrupted" }, + }); + expect(printed.run.counts.deleted).toBe(latestUserLines(runsDir, undoRun!.id).size); + expect(printed.run.counts.deleted).toBeGreaterThan(0); }); }); diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 385223b14f..7c8d872675 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -30,6 +30,7 @@ import { resolveLimits } from "./lib/instance.ts"; import { retryOn429 } from "./lib/retry.ts"; import { continueRun, + countLines, latestUserLines, lockRun, patchRun, @@ -413,7 +414,15 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 + ? "Delete the users this import created" + : `Mark run ${record.id} undone`, + }, + ], ); } const proceed = await confirm({ @@ -445,12 +454,20 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 ? await withProgress({ total: present.length, verb: "deleted" }, async (progress) => - deleteUsers({ users: present, secretKey, schedule, run, progress }), + // Set above; a callback doesn't keep the narrowing of an outer `let`. + deleteUsers({ users: present, secretKey, schedule, run: run!, progress }), ) : { deleted: 0, failed: 0, errorBreakdown: new Map() }; From 95cad7f12dbdc3896d17fe6f204789e7717a1e3c Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 03:41:01 -0400 Subject: [PATCH 53/63] fix(migrate): offer no continue when only --require-password skips are left - `continueFinishes` decides whether a continue could finish anything: a user never sent, or a line it retries. A `--require-password` skip doesn't count: the continue carries the flag and leaves that user out again, so suggesting it would print the same suggestion forever. The import's steps and `runs ` both use it, and the skip's reason is one constant. - The README says so under `--require-password`, with the way to import those users: `--new-run` without the flag. - `withLiveCounts` moves to `run-store.ts`, and a stopped undo's `--json` uses it. `UndoOutcome` types that run's `state`. - `MIGRATE_DONE_WITH_ERRORS` is `MIGRATE_PARTIAL`, after the rule that picks it. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 25 ++++++++++-------- .../src/commands/migrate/lib/run-store.ts | 26 +++++++++++++++++++ .../cli-core/src/commands/migrate/run.test.ts | 6 ++++- packages/cli-core/src/commands/migrate/run.ts | 11 +++++--- .../src/commands/migrate/runs.test.ts | 21 +++++++++++++++ .../cli-core/src/commands/migrate/runs.ts | 17 +++++------- .../src/commands/migrate/undo.test.ts | 2 +- .../cli-core/src/commands/migrate/undo.ts | 18 +++++++------ packages/cli-core/src/lib/next-steps.ts | 2 +- 9 files changed, 91 insertions(+), 37 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index a37556880a..cb31f7ac0b 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -252,7 +252,9 @@ times before the user is recorded as failed. The command exits 1 if any user failed. `--require-password` records each user it leaves out as `skipped`, so the run -ends `partial`. +ends `partial`. A continue carries the flag and leaves them out again, so when +they are all that's left, neither the import nor `runs ` suggests one. To +import them, run the file again with `--new-run` and without the flag. `--json` returns `{ target, run, resume, checks, result }`. When a run stops before importing, it carries one of `dryRun: true`, `refused: true`, @@ -485,16 +487,17 @@ to run next: for an interrupted or partial import, the import that continues it, with the flags it ran with, then the undo. The continue is left out when importing the file again might not reach this run: a newer run of the same file and source that may be for the same instance (one recorded under a key's -stand-in ID may be any), a file changed since, or any undo of it. Both carry -what reaches the run's instance again, from the key its latest attempt used: -`--secret-key ` for a key passed with `--secret-key` or exported (as -`CLERK_SECRET_KEY`, or a framework's own variable such as -`NUXT_CLERK_SECRET_KEY`), and `--app` and `--instance` for `--app` or a linked -profile (`--secret-key ` when the run is under a stand-in ID: no -`--instance` resolves one, and a bare `--app` resolves development). A key file -(`.env`, or a keyless app's) gets no target flags: it refuses `--instance`, and -run from the same project it names the key itself. The import's own next steps, -and a partial undo's, carry the same. An unknown ID exits 2. +stand-in ID may be any), a file changed since, or any undo of it. It is also +left out when only `--require-password` skips are left. Both carry what reaches +the run's instance again, from the key its latest attempt used: `--secret-key +` for a key passed with `--secret-key` or exported (as `CLERK_SECRET_KEY`, +or a framework's own variable such as `NUXT_CLERK_SECRET_KEY`), and `--app` and +`--instance` for `--app` or a linked profile (`--secret-key ` when the run +is under a stand-in ID: no `--instance` resolves one, and a bare `--app` +resolves development). A key file (`.env`, or a keyless app's) gets no target +flags: it refuses `--instance`, and run from the same project it names the key +itself. The import's own next steps, and a partial undo's, carry the same. An +unknown ID exits 2. ### `clerk migrate undo` 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 40e6968439..b2f88ba14b 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -315,6 +315,32 @@ export function runState(runsDir: string, record: RunRecord): RunState { return liveLockPid(runsDir, record.id) === undefined ? "interrupted" : "running"; } +/** + * Counts are written when a run finishes, so an interrupted one still holds + * its start's zeros. Those are counted from its user lines instead. + */ +export function withLiveCounts(runsDir: string, record: RunRecord): RunRecord { + if (record.finishedAt) return record; + return { ...record, counts: countLines(latestUserLines(runsDir, record.id).values()) }; +} + +/** Why `--require-password` leaves a user out. */ +export const NO_PASSWORD_REASON = "no password (--require-password)"; + +/** + * Whether continuing a run could finish anything: a user never sent, or one a + * continue retries. Not a `--require-password` skip: the continue carries the + * flag, so it leaves that user out again, and suggesting it would loop. + */ +export function continueFinishes(runsDir: string, record: RunRecord): boolean { + if ((record.counts.notSent ?? 0) > 0) return true; + return [...latestUserLines(runsDir, record.id).values()].some((line) => + line.status === "skipped" + ? line.reason !== NO_PASSWORD_REASON + : line.status !== "created" || (line.pending?.length ?? 0) > 0, + ); +} + /** * The imports of this file with this source, newest first: the runs a * re-import of it chooses among, before it looks at the instance. diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 1f80562e4d..e90eb0bc79 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -329,8 +329,12 @@ describe("run", () => { reason: "no password (--require-password)", }); expect(record?.status).toBe("partial"); - // So `runs ` suggests a continue that still leaves them out. + // So a continue would still leave them out. expect(importFlags(record?.flags ?? {})).toEqual(["--require-password"]); + // ...which is why none is offered: following it would print it again. + const err = Bun.stripANSI(captured.err); + expect(err).not.toContain("to retry them"); + expect(err).toContain("to see what happened to each user"); }); // They never reach the checks, so the preview has to count them itself. diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index b74d6c1874..ddfe868ea6 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -48,6 +48,7 @@ import { ALREADY_IMPORTED, checkImport, type ImportChecks } from "./lib/checks.t import { fetchInstanceSettings, fetchUserCount } from "./lib/clerk-config.ts"; import { DEV_USER_LIMIT, resolveLimits, type InstanceType } from "./lib/instance.ts"; import { + continueFinishes, continueRun, latestUserLines, listRuns, @@ -60,6 +61,7 @@ import { type Run, type RunRecord, type UserLine, + NO_PASSWORD_REASON, unfinishedUndoMessage, inUseMessage, openUndoOf, @@ -889,7 +891,7 @@ export async function run(rawOptions: MigrateRunOptions): Promise { run.append({ sourceId: user.userId, status: "skipped", - reason: "no password (--require-password)", + reason: NO_PASSWORD_REASON, }); } @@ -981,10 +983,11 @@ export async function run(rawOptions: MigrateRunOptions): Promise { const later = laterCarry(options.runsDir, record.target); const undo = undoCommand(record.id, later); // A partial run continues, as `runs ` says: a skip fixed since (a - // setting the checks named) imports then, and one that isn't is skipped again. + // setting the checks named) imports then. Not when only `--require-password` + // skips are left: the continue would leave them out again. const steps = - record.status === "partial" - ? NEXT_STEPS.MIGRATE_DONE_WITH_ERRORS( + record.status === "partial" && continueFinishes(runsDir, record) + ? NEXT_STEPS.MIGRATE_PARTIAL( runsStep, // The same import again continues this run; --new-run would not. commandFor({ ...options, newRun: false }, options.yes ? ["--yes"] : [], later), diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 5eec4c1617..05ee97c510 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -254,6 +254,27 @@ describe("runs ", () => { expect(next[0]).toContain("--source clerk --require-password --runs-dir"); }); + // The continue carries --require-password, so it would leave them out again. + test("offers no continue when only --require-password skips are left", async () => { + fs.writeFileSync(usersFile(), "[]"); + const run = startRun(runsDir, { + kind: "import", + target: TARGET, + source: "clerk", + file: { path: usersFile(), sha256: sha256File(usersFile()) }, + flags: { requirePassword: true }, + }); + run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + run.append({ sourceId: "d", status: "skipped", reason: "no password (--require-password)" }); + const record = run.finish(); + + await runs(record.id, { runsDir, json: true }); + + const { run: shown, next } = JSON.parse(captured.out) as { run: RunRecord; next: string[] }; + expect(shown.status).toBe("partial"); + expect(next).toEqual([expect.stringContaining(`clerk migrate undo ${record.id}`)]); + }); + // Each of these would start another run, or refuse, rather than continue it. describe("offers only the undo when importing the file again would not continue the run", () => { const onlyUndo = async (id: string) => { diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index d895ab33f3..c831a9c3c0 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -13,7 +13,7 @@ import { quoteArg } from "../../lib/json-body.ts"; import { log } from "../../lib/log.ts"; import { tallyError } from "./lib/failures.ts"; import { - countLines, + continueFinishes, latestUserLines, listRuns, importsOfFile, @@ -29,6 +29,7 @@ import { type UserLine, USERS_FILE, noRunMessage, + withLiveCounts, } from "./lib/run-store.ts"; import { carryFlags, importFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; import { plural } from "./lib/plural.ts"; @@ -166,7 +167,10 @@ function nextCommands( if (record.kind === "import" && state !== "undone") { const undo = `Run \`${undoCommand(record.id, carry)}\` to delete the users it created`; const again = continueCommand(record, carry); - if (!again || state === "complete" || !continuesThisRun(runsDir, record)) return [undo]; + const finishes = state === "interrupted" || continueFinishes(runsDir, record); + if (!again || state === "complete" || !finishes || !continuesThisRun(runsDir, record)) { + return [undo]; + } // Continuing is what a stopped run is for: the same file, source and // target pick it up where it left off. const why = state === "interrupted" ? "continue it" : "retry the users it didn't finish"; @@ -285,15 +289,6 @@ function printRun(runsDir: string, record: RunRecord, carry: string[]): void { for (const step of view.next) log.info(` → ${step}`); } -/** - * Counts are written when a run finishes, so an interrupted one still holds - * its start's zeros. Those are counted from its user lines instead. - */ -function withLiveCounts(runsDir: string, record: RunRecord): RunRecord { - if (record.finishedAt) return record; - return { ...record, counts: countLines(latestUserLines(runsDir, record.id).values()) }; -} - export async function runs(id: string | undefined, options: RunsOptions = {}): Promise { const runsDir = await resolveRunsDir(options.runsDir); diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index 711242c73d..d4863d5c4d 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -497,7 +497,7 @@ describe("stopped part-way", () => { const undoRun = listRuns(runsDir).find((r) => r.kind === "undo"); // As it stands on disk, not as it opened: counted from its lines. - const printed = JSON.parse(captured.out) as { run: RunRecord & { state: string } }; + const printed = JSON.parse(captured.out) as { run: RunRecord }; expect(printed).toMatchObject({ stopped: true, run: { id: undoRun?.id, state: "interrupted" }, diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 7c8d872675..15f75c6e69 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -30,7 +30,6 @@ import { resolveLimits } from "./lib/instance.ts"; import { retryOn429 } from "./lib/retry.ts"; import { continueRun, - countLines, latestUserLines, lockRun, patchRun, @@ -41,10 +40,12 @@ import { startRun, type Run, type RunRecord, + type RunState, openUndoOf, inUseMessage, noRunMessage, withAttempt, + withLiveCounts, } from "./lib/run-store.ts"; import { withProgress, type ProgressUpdate } from "./lib/progress.ts"; import { createApiScheduler, type ApiScheduler } from "./lib/scheduler.ts"; @@ -310,13 +311,16 @@ function printPreview(record: RunRecord, preview: UndoPreview): void { * What the JSON adds to the preview: why undo stopped before deleting, or, * once it did, its run and result. One of these, never two. */ +/** The undo run a stop left on disk, as `runs` shows an unfinished run. */ +type StoppedRun = RunRecord & { state: RunState }; + type UndoOutcome = | { dryRun: true } | { consent: "required" } | { changed: true; signedInSincePreview?: number } | { recheckFailed: true } - | { writeFailed: true; run: RunRecord | null } - | { stopped: true; run: RunRecord | null } + | { writeFailed: true; run: StoppedRun | null } + | { stopped: true; run: StoppedRun | null } | { run: RunRecord; result: { deleted: number; failed: number; errors: { error: string; count: number }[] }; @@ -459,12 +463,10 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise [ + MIGRATE_PARTIAL: (runsCommand: string, retryCommand: string, undoCommand: string) => [ `Run \`${runsCommand}\` to see every user that didn't finish, and why`, `Run \`${retryCommand}\` to retry them, in the same run`, `Run \`${undoCommand}\` to delete the users it created`, From 35570f35ed4fe2cadaa51a7eb0d86d309db1c3a0 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 03:58:02 -0400 Subject: [PATCH 54/63] fix(migrate): point --require-password's skipped users at the re-run that imports them, and match the skip by code - The README said to import them with `--new-run` and without the flag, which refuses the users the run already created. The same import without the flag continues the run and creates them; a test pins that. - The refusal names the earlier run's file as "this file" when its sha256 matches, instead of "an earlier version of this file". - A `--require-password` skip carries `code: "no_password"`, and the retry check matches that rather than the display text. `hasRetryableUsers` (was `continueFinishes`) takes the lines, so `runs ` reads them once and a complete run doesn't read them for it. - `StoppedRun` moves above `UndoOutcome`'s doc. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 9 ++--- .../src/commands/migrate/lib/run-store.ts | 19 ++++++----- .../cli-core/src/commands/migrate/run.test.ts | 33 ++++++++++++++++++- packages/cli-core/src/commands/migrate/run.ts | 19 ++++++----- .../src/commands/migrate/runs.test.ts | 14 ++++++-- .../cli-core/src/commands/migrate/runs.ts | 11 +++---- .../cli-core/src/commands/migrate/undo.ts | 6 ++-- 7 files changed, 79 insertions(+), 32 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index cb31f7ac0b..d789dba39e 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -251,10 +251,11 @@ 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. -`--require-password` records each user it leaves out as `skipped`, so the run -ends `partial`. A continue carries the flag and leaves them out again, so when -they are all that's left, neither the import nor `runs ` suggests one. To -import them, run the file again with `--new-run` and without the flag. +`--require-password` records each user it leaves out as `skipped`, with +`code: "no_password"`, so the run ends `partial`. A continue carries the flag +and leaves them out again, so when they are all that's left, neither the import +nor `runs ` suggests one. To import them, run the same import again without +the flag: it continues this run and creates them. `--json` returns `{ target, run, resume, checks, result }`. When a run stops before importing, it carries one of `dryRun: true`, `refused: true`, 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 b2f88ba14b..e3cdb4dd6f 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -324,19 +324,22 @@ export function withLiveCounts(runsDir: string, record: RunRecord): RunRecord { return { ...record, counts: countLines(latestUserLines(runsDir, record.id).values()) }; } -/** Why `--require-password` leaves a user out. */ -export const NO_PASSWORD_REASON = "no password (--require-password)"; +/** + * The `code` on a user `--require-password` left out. Matched rather than its + * `reason`, which is display text a later version may reword. + */ +export const NO_PASSWORD_CODE = "no_password"; /** - * Whether continuing a run could finish anything: a user never sent, or one a - * continue retries. Not a `--require-password` skip: the continue carries the - * flag, so it leaves that user out again, and suggesting it would loop. + * Whether a continue has users to retry: one never sent, or a latest line it + * retries. Not a `--require-password` skip: the continue carries the flag, so + * it leaves that user out again, and suggesting it would loop. */ -export function continueFinishes(runsDir: string, record: RunRecord): boolean { +export function hasRetryableUsers(record: RunRecord, latest: Iterable): boolean { if ((record.counts.notSent ?? 0) > 0) return true; - return [...latestUserLines(runsDir, record.id).values()].some((line) => + return [...latest].some((line) => line.status === "skipped" - ? line.reason !== NO_PASSWORD_REASON + ? line.code !== NO_PASSWORD_CODE : line.status !== "created" || (line.pending?.length ?? 0) > 0, ); } diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index e90eb0bc79..8a7cbab656 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -327,6 +327,7 @@ describe("run", () => { expect(latestUserLines(runsDir(), record!.id).get("u2")).toMatchObject({ status: "skipped", reason: "no password (--require-password)", + code: "no_password", }); expect(record?.status).toBe("partial"); // So a continue would still leave them out. @@ -337,6 +338,34 @@ describe("run", () => { expect(err).toContain("to see what happened to each user"); }); + // The README's way to import them: the same import without the flag. + test("the same import without --require-password continues the run and creates them", async () => { + await run({ ...baseOptions, requirePassword: true }); + const [first] = listRuns(runsDir()); + requests = []; + + await run(baseOptions); + + expect(created()).toEqual(["u2"]); + expect(listRuns(runsDir())).toHaveLength(1); + expect(readRun(runsDir(), first!.id)?.status).toBe("complete"); + }); + + // --new-run refuses the users the run created; it names the same file as such. + test("--new-run on the same file names it as this file, not an earlier version", async () => { + await run({ ...baseOptions, requirePassword: true }); + const [first] = listRuns(runsDir()); + captured.clear(); + stubClerk({ existing: [{ id: "user_u1", external_id: "u1" }] }); + + const error = (await run({ ...baseOptions, newRun: true }).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain(`created by run ${first!.id}, from this file.`); + }); + // 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 }); @@ -1292,7 +1321,9 @@ describe("run", () => { const error = (await run(baseOptions).catch((caught: unknown) => caught)) as CliError; expect(error.exitCode).toBe(EXIT_CODE.USAGE); - expect(error.message).toContain(`1 of them was created by run ${first!.id}`); + expect(error.message).toContain( + `1 of them was created by run ${first!.id}, from an earlier version of this file.`, + ); expect(error.examples?.map((example) => example.command)).toContain( `clerk migrate undo ${first!.id} --secret-key `, ); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index ddfe868ea6..87f3baca7e 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -48,7 +48,6 @@ import { ALREADY_IMPORTED, checkImport, type ImportChecks } from "./lib/checks.t import { fetchInstanceSettings, fetchUserCount } from "./lib/clerk-config.ts"; import { DEV_USER_LIMIT, resolveLimits, type InstanceType } from "./lib/instance.ts"; import { - continueFinishes, continueRun, latestUserLines, listRuns, @@ -61,7 +60,8 @@ import { type Run, type RunRecord, type UserLine, - NO_PASSWORD_REASON, + hasRetryableUsers, + NO_PASSWORD_CODE, unfinishedUndoMessage, inUseMessage, openUndoOf, @@ -549,7 +549,8 @@ function earlierRunHolding( source: string, instanceId: string, checks: ImportChecks, -): { id: string; count: number } | undefined { + sha256: string, +): { id: string; count: number; sameFile: boolean } | undefined { const already = new Set( checks.rejects.filter((reject) => reject.reason === ALREADY_IMPORTED).map((r) => r.sourceId), ); @@ -561,7 +562,7 @@ function earlierRunHolding( for (const line of latestUserLines(runsDir, record.id).values()) { if (line.status === "created" && already.has(line.sourceId)) count++; } - if (count > 0) return { id: record.id, count }; + if (count > 0) return { id: record.id, count, sameFile: record.file?.sha256 === sha256 }; } return undefined; } @@ -796,13 +797,13 @@ export async function run(rawOptions: MigrateRunOptions): Promise { if (refused) { if (options.json) preview({ refused: true }); - const earlier = earlierRunHolding(runsDir, source, target.instanceId, checks); + const earlier = earlierRunHolding(runsDir, source, target.instanceId, checks, sha256); const undoEarlier = earlier ? undoCommand(earlier.id, carry) : undefined; throwUsageError( `${plural(checks.rejects.length, "user")} would be rejected, so nothing was imported.` + `${rejectSummary(checks, options.json)} ` + (earlier - ? `${earlier.count} of them ${earlier.count === 1 ? "was" : "were"} created by run ${earlier.id}, from an earlier version of this file. ` + + ? `${earlier.count} of them ${earlier.count === 1 ? "was" : "were"} created by run ${earlier.id}, from ${earlier.sameFile ? "this file" : "an earlier version of this file"}. ` + `Pass --allow-partial to import the rest into a new run, or undo run ${earlier.id} first (\`${undoEarlier}\`) to import the whole file again.` : "Fix them, or pass --allow-partial to import the rest and record them as skipped."), undefined, @@ -891,7 +892,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { run.append({ sourceId: user.userId, status: "skipped", - reason: NO_PASSWORD_REASON, + reason: "no password (--require-password)", + code: NO_PASSWORD_CODE, }); } @@ -986,7 +988,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { // setting the checks named) imports then. Not when only `--require-password` // skips are left: the continue would leave them out again. const steps = - record.status === "partial" && continueFinishes(runsDir, record) + record.status === "partial" && + hasRetryableUsers(record, latestUserLines(runsDir, record.id).values()) ? NEXT_STEPS.MIGRATE_PARTIAL( runsStep, // The same import again continues this run; --new-run would not. diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 05ee97c510..e6450f749d 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -42,7 +42,12 @@ function partialImport(init: Partial = {}) { run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); run.append({ sourceId: "b", status: "failed", error: "That email address is taken." }); run.append({ sourceId: "c", status: "failed", error: "That email address is taken." }); - run.append({ sourceId: "d", status: "skipped", reason: "no password (--require-password)" }); + run.append({ + sourceId: "d", + status: "skipped", + reason: "no password (--require-password)", + code: "no_password", + }); return run.finish(); } @@ -265,7 +270,12 @@ describe("runs ", () => { flags: { requirePassword: true }, }); run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); - run.append({ sourceId: "d", status: "skipped", reason: "no password (--require-password)" }); + run.append({ + sourceId: "d", + status: "skipped", + reason: "no password (--require-password)", + code: "no_password", + }); const record = run.finish(); await runs(record.id, { runsDir, json: true }); diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index c831a9c3c0..1c91b9c093 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -13,7 +13,7 @@ import { quoteArg } from "../../lib/json-body.ts"; import { log } from "../../lib/log.ts"; import { tallyError } from "./lib/failures.ts"; import { - continueFinishes, + hasRetryableUsers, latestUserLines, listRuns, importsOfFile, @@ -162,15 +162,14 @@ function nextCommands( record: RunRecord, state: RunState, carry: string[], + latest: UserLine[], ): string[] { if (state === "running") return []; if (record.kind === "import" && state !== "undone") { const undo = `Run \`${undoCommand(record.id, carry)}\` to delete the users it created`; const again = continueCommand(record, carry); - const finishes = state === "interrupted" || continueFinishes(runsDir, record); - if (!again || state === "complete" || !finishes || !continuesThisRun(runsDir, record)) { - return [undo]; - } + if (!again || state === "complete" || !continuesThisRun(runsDir, record)) return [undo]; + if (state === "partial" && !hasRetryableUsers(record, latest)) return [undo]; // Continuing is what a stopped run is for: the same file, source and // target pick it up where it left off. const why = state === "interrupted" ? "continue it" : "retry the users it didn't finish"; @@ -222,7 +221,7 @@ function showJson(runsDir: string, record: RunRecord, carry: string[]) { return { runsDir, run: { ...record, state }, - next: nextCommands(runsDir, record, state, carry), + next: nextCommands(runsDir, record, state, carry, lines), errors: errorBreakdown(lines), failed: lines.filter((line) => line.status === "failed"), skipped: lines.filter((line) => line.status === "skipped"), diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 15f75c6e69..18b93ac935 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -307,13 +307,13 @@ function printPreview(record: RunRecord, preview: UndoPreview): void { } } +/** The undo run a stop left on disk, as `runs` shows an unfinished run. */ +type StoppedRun = RunRecord & { state: RunState }; + /** * What the JSON adds to the preview: why undo stopped before deleting, or, * once it did, its run and result. One of these, never two. */ -/** The undo run a stop left on disk, as `runs` shows an unfinished run. */ -type StoppedRun = RunRecord & { state: RunState }; - type UndoOutcome = | { dryRun: true } | { consent: "required" } From 7f16165ee98a73eb795af43aed6c530a7bb6f6fe Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 13:11:15 -0400 Subject: [PATCH 55/63] fix(migrate): offer the continue when --new-run refuses a partial run of the same file A same-file --new-run refusal now leads with "Drop --new-run to continue run X" and the continue command, when that run is partial or interrupted. earlierRunHolding returns the run's state, and its doc covers the same-file case. UserLine.code gets a doc, and the README says what code holds on a failed and on a skipped line. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 3 ++ .../src/commands/migrate/lib/run-store.ts | 1 + .../cli-core/src/commands/migrate/run.test.ts | 23 +++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 29 +++++++++++++++---- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index d789dba39e..05a6ffa682 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -141,6 +141,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 | +On a `failed` line, `code` is the HTTP status (`422`, `429` or `unknown`). On a +`skipped` line it gives the reason in machine form, such as `no_password`. + A user's status is `creating`, `created`, `failed`, `skipped` or `deleted`. 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 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 e3cdb4dd6f..28f94740ac 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -109,6 +109,7 @@ export type UserLine = { /** Why a user was skipped. */ reason?: string; error?: string; + /** Failed: the HTTP status, or "unknown". Skipped: a machine reason, such as NO_PASSWORD_CODE. */ code?: string; passwordDropped?: boolean; }; diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 8a7cbab656..92aa26efb1 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -364,6 +364,29 @@ describe("run", () => { expect(error.exitCode).toBe(EXIT_CODE.USAGE); expect(error.message).toContain(`created by run ${first!.id}, from this file.`); + // Dropping --new-run continues that run, so the refusal offers it first. + expect(error.message).toContain(`Drop --new-run to continue run ${first!.id}.`); + expect(error.examples?.[0]?.command).not.toContain("--new-run"); + expect(error.examples?.[0]?.description).toBe(`Continue run ${first!.id}`); + }); + + // A complete run has nothing to continue: without --new-run it is already imported. + test("--new-run on the same file doesn't offer to continue a complete run", async () => { + await run(baseOptions); + captured.clear(); + stubClerk({ + existing: [ + { id: "user_u1", external_id: "u1" }, + { id: "user_u2", external_id: "u2" }, + ], + }); + + const error = (await run({ ...baseOptions, newRun: true }).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.message).toContain("from this file."); + expect(error.message).not.toContain("Drop --new-run"); }); // They never reach the checks, so the preview has to count them itself. diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 87f3baca7e..7244ea9227 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -59,6 +59,7 @@ import { startRun, type Run, type RunRecord, + type RunState, type UserLine, hasRetryableUsers, NO_PASSWORD_CODE, @@ -541,8 +542,9 @@ function rejectSummary(checks: ImportChecks, json = false): string { /** * The earlier import run, of this source into this instance, that created the - * users the checks reject as already there, and how many of them: fixing a - * skipped row changes the file, and the changed file's run meets them. + * users the checks reject as already there: how many of them, whether it ran + * this same file, and its state. Fixing a skipped row changes the file, and the + * changed file's run meets them; so does the same file run with `--new-run`. */ function earlierRunHolding( runsDir: string, @@ -550,19 +552,21 @@ function earlierRunHolding( instanceId: string, checks: ImportChecks, sha256: string, -): { id: string; count: number; sameFile: boolean } | undefined { +): { id: string; count: number; sameFile: boolean; state: RunState } | undefined { const already = new Set( checks.rejects.filter((reject) => reject.reason === ALREADY_IMPORTED).map((r) => r.sourceId), ); if (already.size === 0) return undefined; for (const record of listRuns(runsDir)) { if (record.kind !== "import" || record.source !== source) continue; - if (record.target.instanceId !== instanceId || runState(runsDir, record) === "undone") continue; + if (record.target.instanceId !== instanceId) continue; + const state = runState(runsDir, record); + if (state === "undone") continue; let count = 0; for (const line of latestUserLines(runsDir, record.id).values()) { if (line.status === "created" && already.has(line.sourceId)) count++; } - if (count > 0) return { id: record.id, count, sameFile: record.file?.sha256 === sha256 }; + if (count > 0) return { id: record.id, count, sameFile: record.file?.sha256 === sha256, state }; } return undefined; } @@ -799,16 +803,29 @@ export async function run(rawOptions: MigrateRunOptions): Promise { if (options.json) preview({ refused: true }); const earlier = earlierRunHolding(runsDir, source, target.instanceId, checks, sha256); const undoEarlier = earlier ? undoCommand(earlier.id, carry) : undefined; + // Without --new-run, the same file continues that run and creates the rest. + const continueEarlier = + options.newRun && + earlier?.sameFile && + (earlier.state === "partial" || earlier.state === "interrupted") + ? commandFor({ ...options, newRun: false }, ["--yes"]) + : undefined; throwUsageError( `${plural(checks.rejects.length, "user")} would be rejected, so nothing was imported.` + `${rejectSummary(checks, options.json)} ` + (earlier ? `${earlier.count} of them ${earlier.count === 1 ? "was" : "were"} created by run ${earlier.id}, from ${earlier.sameFile ? "this file" : "an earlier version of this file"}. ` + - `Pass --allow-partial to import the rest into a new run, or undo run ${earlier.id} first (\`${undoEarlier}\`) to import the whole file again.` + (continueEarlier + ? `Drop --new-run to continue run ${earlier.id}. Or pass` + : "Pass") + + ` --allow-partial to import the rest into a new run, or undo run ${earlier.id} first (\`${undoEarlier}\`) to import the whole file again.` : "Fix them, or pass --allow-partial to import the rest and record them as skipped."), undefined, undefined, [ + ...(continueEarlier + ? [{ command: continueEarlier, description: `Continue run ${earlier!.id}` }] + : []), { command: commandFor(options, ["--allow-partial", "--yes"]), description: "Import the users that pass", From 46a2b102bf668a4706ae1a1dea4cec9adbd351d4 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 13:36:01 -0400 Subject: [PATCH 56/63] fix(migrate): offer the refusal's continue only when findResume would take that run The same-file --new-run refusal restated findResume's choice of run, and offered "Drop --new-run to continue run A" when a newer run of the file would be continued, or an unfinished undo would refuse. It now asks findResume itself, through continuesWithoutNewRun, and under --require-password also needs hasRetryableUsers, so it never offers a continue that imports nobody. earlierRunHolding drops the state field v17 added. The README no longer reads as if it listed every code. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 5 +- .../cli-core/src/commands/migrate/run.test.ts | 52 ++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 62 ++++++++++++------- 3 files changed, 95 insertions(+), 24 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 05a6ffa682..39e40fa542 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -141,8 +141,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 | -On a `failed` line, `code` is the HTTP status (`422`, `429` or `unknown`). On a -`skipped` line it gives the reason in machine form, such as `no_password`. +On a `failed` line, `code` is the HTTP status, such as `422` or `429`, or +`unknown`. On a `skipped` line it gives the reason in machine form, such as +`no_password`. A user's status is `creating`, `created`, `failed`, `skipped` or `deleted`. The last line for each `sourceId` wins. A `429` retry, an extra email or phone that did not diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 92aa26efb1..7e99dc9f92 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -389,6 +389,58 @@ describe("run", () => { expect(error.message).not.toContain("Drop --new-run"); }); + // findResume continues the newest run of the file, so the refusal offers the + // continue only when that is the run that created the users. + test("--new-run doesn't offer to continue a run a newer one would take over", async () => { + stubClerk({ failing: new Set(["u2"]) }); + await run(baseOptions); + stubClerk({ failing: new Set(["u2"]), existing: [{ id: "user_u1", external_id: "u1" }] }); + await run({ ...baseOptions, newRun: true, allowPartial: true }); + expect(listRuns(runsDir())).toHaveLength(2); + captured.clear(); + + const error = (await run({ ...baseOptions, newRun: true }).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.message).toContain("from this file."); + expect(error.message).not.toContain("Drop --new-run"); + }); + + // Without --new-run the import refuses over the unfinished undo. + test("--new-run doesn't offer to continue a run with an unfinished undo", async () => { + await run({ ...baseOptions, requirePassword: true }); + const [first] = listRuns(runsDir()); + const undoRun = startRun(runsDir(), { + kind: "undo", + target: { instanceId: "ins_1" }, + undoes: first!.id, + }); + undoRun.append({ sourceId: "u1", status: "failed", clerkId: "user_u1" }); + undoRun.finish(); + stubClerk({ existing: [{ id: "user_u1", external_id: "u1" }] }); + + const error = (await run({ ...baseOptions, newRun: true }).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.message).toContain(`created by run ${first!.id}, from this file.`); + expect(error.message).not.toContain("Drop --new-run"); + }); + + // The continue would carry --require-password and leave the same users out. + test("--new-run --require-password doesn't offer a continue that imports nobody", async () => { + await run({ ...baseOptions, requirePassword: true }); + stubClerk({ existing: [{ id: "user_u1", external_id: "u1" }] }); + + const error = (await run({ ...baseOptions, newRun: true, requirePassword: true }).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.message).toContain("from this file."); + expect(error.message).not.toContain("Drop --new-run"); + }); + // 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 }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 7244ea9227..76d4e4fe6d 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -59,7 +59,6 @@ import { startRun, type Run, type RunRecord, - type RunState, type UserLine, hasRetryableUsers, NO_PASSWORD_CODE, @@ -542,9 +541,9 @@ function rejectSummary(checks: ImportChecks, json = false): string { /** * The earlier import run, of this source into this instance, that created the - * users the checks reject as already there: how many of them, whether it ran - * this same file, and its state. Fixing a skipped row changes the file, and the - * changed file's run meets them; so does the same file run with `--new-run`. + * users the checks reject as already there: how many of them, and whether it + * ran this same file. Fixing a skipped row changes the file, and the changed + * file's run meets them; so does the same file run with `--new-run`. */ function earlierRunHolding( runsDir: string, @@ -552,25 +551,48 @@ function earlierRunHolding( instanceId: string, checks: ImportChecks, sha256: string, -): { id: string; count: number; sameFile: boolean; state: RunState } | undefined { +): { id: string; count: number; sameFile: boolean } | undefined { const already = new Set( checks.rejects.filter((reject) => reject.reason === ALREADY_IMPORTED).map((r) => r.sourceId), ); if (already.size === 0) return undefined; for (const record of listRuns(runsDir)) { if (record.kind !== "import" || record.source !== source) continue; - if (record.target.instanceId !== instanceId) continue; - const state = runState(runsDir, record); - if (state === "undone") continue; + if (record.target.instanceId !== instanceId || runState(runsDir, record) === "undone") continue; let count = 0; for (const line of latestUserLines(runsDir, record.id).values()) { if (line.status === "created" && already.has(line.sourceId)) count++; } - if (count > 0) return { id: record.id, count, sameFile: record.file?.sha256 === sha256, state }; + if (count > 0) return { id: record.id, count, sameFile: record.file?.sha256 === sha256 }; } return undefined; } +/** + * Whether the same import without `--new-run` would continue run `id` and + * import someone: `findResume` has to pick it, and `--require-password` has to + * leave a user it would retry. + */ +function continuesWithoutNewRun( + runsDir: string, + match: Parameters[1], + carry: string[], + id: string, + requirePassword: boolean | undefined, +): boolean { + let resume: ResumeCase; + try { + resume = findResume(runsDir, match, carry); + } catch { + // It would refuse: the run is still going, or has an unfinished undo. + return false; + } + if (resume.kind !== "continue" || resume.record.id !== id) return false; + return ( + !requirePassword || hasRetryableUsers(resume.record, latestUserLines(runsDir, id).values()) + ); +} + /** * Records the checks' rejects as skipped users, so the run says who they were. * The checks never reject an adopted user: it is created already. @@ -612,18 +634,15 @@ export async function run(rawOptions: MigrateRunOptions): Promise { const sha256 = sha256File(filePath); const runsDir = await resolveRunsDir(options.runsDir); + const match = { + sha256, + source, + instanceId: target.instanceId, + keyInstanceId: keyInstanceId(secretKey), + }; const resume: ResumeCase = options.newRun ? { kind: "new" } - : findResume( - runsDir, - { - sha256, - source, - instanceId: target.instanceId, - keyInstanceId: keyInstanceId(secretKey), - }, - carry, - ); + : findResume(runsDir, match, carry); if (resume.kind === "complete") { if (options.json) { @@ -803,11 +822,10 @@ export async function run(rawOptions: MigrateRunOptions): Promise { if (options.json) preview({ refused: true }); const earlier = earlierRunHolding(runsDir, source, target.instanceId, checks, sha256); const undoEarlier = earlier ? undoCommand(earlier.id, carry) : undefined; - // Without --new-run, the same file continues that run and creates the rest. const continueEarlier = options.newRun && - earlier?.sameFile && - (earlier.state === "partial" || earlier.state === "interrupted") + earlier && + continuesWithoutNewRun(runsDir, match, carry, earlier.id, options.requirePassword) ? commandFor({ ...options, newRun: false }, ["--yes"]) : undefined; throwUsageError( From 299cb03b4fd73226da55e4fa060871d689dab166 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 13:58:29 -0400 Subject: [PATCH 57/63] fix(migrate): count an unfinished run as having users to retry hasRetryableUsers reads counts.notSent, which only finish() writes, so a run stopped with Ctrl-C or killed looked like it had nobody left. Under --require-password the same-file --new-run refusal then left out a continue that works. An unfinished run now always has users to retry, which is what the import's steps and runs already assumed. findResume's @throws names its third refusal, and the catch points there. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/lib/run-store.ts | 5 ++-- .../cli-core/src/commands/migrate/run.test.ts | 30 +++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 5 ++-- 3 files changed, 36 insertions(+), 4 deletions(-) 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 28f94740ac..bc43424c42 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -334,10 +334,11 @@ export const NO_PASSWORD_CODE = "no_password"; /** * Whether a continue has users to retry: one never sent, or a latest line it * retries. Not a `--require-password` skip: the continue carries the flag, so - * it leaves that user out again, and suggesting it would loop. + * it leaves that user out again, and suggesting it would loop. A run that never + * finished always has: the users it never sent have no lines to count. */ export function hasRetryableUsers(record: RunRecord, latest: Iterable): boolean { - if ((record.counts.notSent ?? 0) > 0) return true; + if (!record.finishedAt || (record.counts.notSent ?? 0) > 0) return true; return [...latest].some((line) => line.status === "skipped" ? line.code !== NO_PASSWORD_CODE diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 7e99dc9f92..0460324431 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -428,6 +428,36 @@ describe("run", () => { expect(error.message).not.toContain("Drop --new-run"); }); + // Stopped before finish(), a run has no lines for the users it never sent. + test("--new-run --require-password offers to continue an interrupted run", async () => { + const both = export2.map((user) => ({ + ...user, + password_digest: BCRYPT, + password_hasher: "bcrypt", + })); + fs.writeFileSync(path.join(workDir, "export.json"), JSON.stringify(both)); + await run(baseOptions); + const [first] = listRuns(runsDir()); + // As a Ctrl-C after u1 leaves it: no finish time, the opening counts, one line. + const record = readRun(runsDir(), first!.id)!; + delete record.finishedAt; + fs.writeFileSync( + path.join(runsDir(), first!.id, "run.json"), + JSON.stringify({ ...record, status: "running", counts: { total: 0 } }), + ); + fs.writeFileSync( + path.join(runsDir(), first!.id, "users.ndjson"), + `${JSON.stringify({ sourceId: "u1", clerkId: "user_u1", status: "created" })}\n`, + ); + stubClerk({ existing: [{ id: "user_u1", external_id: "u1" }] }); + + const error = (await run({ ...baseOptions, newRun: true, requirePassword: true }).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.message).toContain(`Drop --new-run to continue run ${first!.id}.`); + }); + // The continue would carry --require-password and leave the same users out. test("--new-run --require-password doesn't offer a continue that imports nobody", async () => { await run({ ...baseOptions, requirePassword: true }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 76d4e4fe6d..8b0d984dfa 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -322,7 +322,8 @@ type ResumeCase = * * @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. + * not, so neither continuing it nor starting over is safe. Also when Clerk + * did not confirm the instance and a run of this file names a real one. */ function findResume( runsDir: string, @@ -584,7 +585,7 @@ function continuesWithoutNewRun( try { resume = findResume(runsDir, match, carry); } catch { - // It would refuse: the run is still going, or has an unfinished undo. + // It would refuse: see findResume's @throws. return false; } if (resume.kind !== "continue" || resume.record.id !== id) return false; From 5292ffe37b8cf81eef556fb46f28e105571fd1cc Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 14:22:23 -0400 Subject: [PATCH 58/63] fix(migrate): answer the open #515 review comments - runs 's continue carries --yes, so it runs as printed. - An attach-only continue counts its users as created on the progress bar, keeps the error its earlier line recorded, and names the attach in the consent prompt and refusal instead of "0 users". - undo --yes without --include-signed-in marks its --json refusal with includeSignedIn: "required", so a script can tell it from a plain consent refusal. - The README says a Ctrl-C may exit before undo prints its stopped object, and completes the stand-in sentence. - The read-only runs-folder test skips as root, where 0o555 doesn't block. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 12 ++++--- .../src/commands/migrate/import-users.test.ts | 35 +++++++++++++++++-- .../src/commands/migrate/import-users.ts | 7 ++-- .../cli-core/src/commands/migrate/run.test.ts | 7 ++++ packages/cli-core/src/commands/migrate/run.ts | 10 ++++-- .../src/commands/migrate/runs.test.ts | 2 ++ .../cli-core/src/commands/migrate/runs.ts | 2 +- .../src/commands/migrate/undo.test.ts | 17 ++++++++- .../cli-core/src/commands/migrate/undo.ts | 5 +-- 9 files changed, 81 insertions(+), 16 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 39e40fa542..a695052c49 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -491,8 +491,8 @@ create (`unconfirmed` in `--json`), with the path to the full record, then what to run next: for an interrupted or partial import, the import that continues it, with the flags it ran with, then the undo. The continue is left out when importing the file again might not reach this run: a newer run of the same file -and source that may be for the same instance (one recorded under a key's -stand-in ID may be any), a file changed since, or any undo of it. It is also +and source that may be for the same instance (a run under a key's stand-in ID +could be for any instance), a file changed since, or any undo of it. It is also left out when only `--require-password` skips are left. Both carry what reaches the run's instance again, from the key its latest attempt used: `--secret-key ` for a key passed with `--secret-key` or exported (as `CLERK_SECRET_KEY`, @@ -589,13 +589,17 @@ is written, as an import's is. Once the deletes run, `run` is the undo run, and before that, the object says why: - `"dryRun": true`: a dry run. -- `"consent": "required"`: refused for consent. +- `"consent": "required"`: refused for consent. When + `"includeSignedIn": "required"` is beside it, `--yes` was given, but users + who signed in since the import also need `--include-signed-in`. - `"changed": true`: refused because the run changed while the preview waited, or another process holds it. - `"recheckFailed": true`: the second look at sign-ins, right before the deletes, failed. - `"writeFailed": true`: the file system refused a write to the runs folder. -- `"stopped": true`: anything else stopped it, such as a Ctrl-C. +- `"stopped": true`: anything else stopped it. A Ctrl-C may exit before the + object is printed: `clerk migrate runs` then lists the undo run, and + `runs ` shows how far it got. With `writeFailed` or `stopped`, `run` is the undo run as it stands on disk, once one was written: its counts come from its lines, and `state` says how it diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index 94e830da0b..c1731cff0f 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -491,7 +491,7 @@ describe("importUsers", () => { // A continue with only attaches left would otherwise read 0/0 throughout. test("counts an attach-only user towards progress once its attaches are sent", async () => { stub(() => ok("idn_1")); - const reported: number[] = []; + const reported: { done: number; ok: number }[] = []; await importUsers({ users: [], @@ -506,10 +506,39 @@ describe("importUsers", () => { secretKey: "sk_test_x", limits: LIMITS, record, - progress: ({ done }) => reported.push(done), + progress: ({ done, ok }) => reported.push({ done, ok }), }); - expect(reported.at(-1)).toBe(1); + // Created by the run's earlier attempt, so the counts under the bar add up. + expect(reported.at(-1)).toEqual({ done: 1, ok: 1 }); + }); + + // The pending phone attaches now, but the email Clerk refused stays refused. + test("an attach-only user keeps the error its earlier line recorded", async () => { + stub(() => ok("idn_1")); + + await importUsers({ + users: [], + attachOnly: [ + { + sourceId: "u1", + clerkId: "user_1", + status: "created", + error: "b@x.dev: That email address is taken.", + pending: [{ kind: "phone", value: "+15555550100", verified: false }], + }, + ], + secretKey: "sk_test_x", + limits: LIMITS, + record, + }); + + expect(lines.at(-1)).toMatchObject({ + sourceId: "u1", + status: "created", + error: "b@x.dev: That email address is taken.", + }); + expect(lines.at(-1)?.pending).toBeUndefined(); }); test("an adopted user is not created again; only its extras attach", async () => { diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index e4af51aca5..a9310d9fec 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -402,7 +402,9 @@ export async function importUsers(options: ImportUsersOptions): Promise report?.({ done: processed + attachedUsers, ok: successful, failed }); + // An attach-only user was created by this run's earlier attempt, as an adopted one was. + const progress = () => + report?.({ done: processed + attachedUsers, ok: successful + attachedUsers, failed }); const recordFailure = ( userId: string, @@ -530,7 +532,8 @@ export async function importUsers(options: ImportUsersOptions): Promise { - await finishUser(line, line.pending ?? [], []); + // Its earlier error names identifiers Clerk refused; the new line keeps it. + await finishUser(line, line.pending ?? [], line.error ? [line.error] : []); attachedUsers++; progress(); }; diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 0460324431..552f5d1648 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -1101,6 +1101,13 @@ describe("run", () => { const err = Bun.stripANSI(captured.err); expect(err).toContain("Identifiers left to attach: 1 user"); expect(err).toContain("--secret-key --yes` to retry them"); + + // Without --yes, the consent names the attach, not just "0 users". + globalThis.fetch = clerk; + const error = (await run({ ...baseOptions, yes: false }).catch( + (caught: unknown) => caught, + )) as CliError; + expect(error.message).toContain("will create 0 users, attach identifiers to 1 user."); }); // A skip the checks named a fix for imports once the setting changes: the diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 8b0d984dfa..a5280a3ab4 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -880,15 +880,18 @@ 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. With users - // skipped, the question names them: "Import 0 users?" hides the skips. + // skipped or attaches left, the question names them: "Import 0 users?" + // hides both. const toCreate = plural(checks.importable.length, "user"); + const attaches = + attachOnly.length > 0 ? `, attach identifiers to ${plural(attachOnly.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 ${toCreate}${skips}. Pass --yes to confirm.`, + `\`clerk migrate import\` will create ${toCreate}${attaches}${skips}. Pass --yes to confirm.`, undefined, undefined, [ @@ -901,7 +904,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { } log.blank(); const proceed = await confirm({ - message: skips ? `Create ${toCreate}${skips}?` : `Import ${toCreate}?`, + message: + skips || attaches ? `Create ${toCreate}${attaches}${skips}?` : `Import ${toCreate}?`, default: false, }); if (!proceed) throwUserAbort(); diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index e6450f749d..22b8557825 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -247,6 +247,8 @@ describe("runs ", () => { `clerk migrate import ${quoteArg(usersFile())} --source clerk --runs-dir ${quoteArg(runsDir)}`, ); expect(next[0]).toContain("to retry the users it didn't finish"); + // Run as printed, it goes ahead rather than asking for consent. + expect(next[0]).toContain(`--runs-dir ${quoteArg(runsDir)} --yes\``); }); // Left out, the continue creates the users --require-password held back. diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index 1c91b9c093..f2e53323a3 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -119,7 +119,7 @@ function continueCommand(record: RunRecord, carry: string[]): string | undefined if (!input) return undefined; const parts = ["clerk migrate import", quoteArg(input)]; if (!record.fromExport && record.source) parts.push("--source", quoteArg(record.source)); - return withFlags(parts.join(" "), [...importFlags(record.flags ?? {}), ...carry]); + return withFlags(parts.join(" "), [...importFlags(record.flags ?? {}), ...carry, "--yes"]); } /** diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index d4863d5c4d..0299900d18 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -240,7 +240,8 @@ describe("refusals, all exit 2 and delete nothing", () => { // The run's own folder takes the lock, but the undo run can't be created // beside it. `--runs-dir` is never written to by resolveRunsDir. - test.skipIf(process.platform === "win32")( + // Root writes through 0o555, so the undo run would be created. + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( "--json prints the preview, marked writeFailed, when the runs folder can't be written", async () => { const record = importRun(); @@ -539,6 +540,20 @@ describe("users who signed in since the import", () => { expect(deletes()).toHaveLength(0); }); + // `consent` alone would tell a script to retry with --yes, which it gave. + test("--yes --json marks the refusal as needing --include-signed-in", async () => { + const record = importRun(); + + await expect( + undo(record.id, withDir({ yes: true, json: true, includeSignedIn: false })), + ).rejects.toThrow("--include-signed-in"); + + expect(JSON.parse(captured.out)).toMatchObject({ + consent: "required", + includeSignedIn: "required", + }); + }); + // Without them the suggested command looks in the default runs folder, or // reaches another instance, and refuses. test("the suggested command keeps the runs folder and the key", async () => { diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 18b93ac935..55e3165284 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -316,7 +316,7 @@ type StoppedRun = RunRecord & { state: RunState }; */ type UndoOutcome = | { dryRun: true } - | { consent: "required" } + | { consent: "required"; includeSignedIn?: "required" } | { changed: true; signedInSincePreview?: number } | { recheckFailed: true } | { writeFailed: true; run: StoppedRun | null } @@ -443,7 +443,8 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 && !options.includeSignedIn) { - printJson({ consent: "required" }); + // Its own marker: a script that retries with --yes would loop on exit 2. + printJson({ consent: "required", includeSignedIn: "required" }); throwUsageError( `${plural(preview.signedInSince, "user")} the import created ${preview.signedInSince === 1 ? "has" : "have"} signed in since, and --yes alone does not delete them. ` + "Pass --include-signed-in to delete them too. Nothing was deleted.", From 089789266967f7652e897cc8e487c2bd02a2b607 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 14:41:47 -0400 Subject: [PATCH 59/63] fix(migrate): leave out runs 's continue when the run's flags can't be read Every import records its flags. A run from an earlier build of this branch has none, or the string[] form, and its continue would drop --require-password and create the users it held back. runs now offers only the undo for it. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/runs.test.ts | 16 ++++++++++++++++ packages/cli-core/src/commands/migrate/runs.ts | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 22b8557825..ee67b25b9c 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -37,6 +37,8 @@ function partialImport(init: Partial = {}) { target: TARGET, source: "clerk", file: { path: usersFile(), sha256: sha256File(usersFile()) }, + // What an import with none of the behaviour flags records. + flags: {}, ...init, }); run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); @@ -251,6 +253,20 @@ describe("runs ", () => { expect(next[0]).toContain(`--runs-dir ${quoteArg(runsDir)} --yes\``); }); + // An earlier build of this branch wrote none, or a string[]: a continue + // without them would create the users --require-password held back. + test.each([ + ["no flags", undefined], + ["flags as a string[]", ["--require-password"]], + ])("offers no continue for a run with %s", async (_, flags) => { + const record = partialImport({ flags: flags as StartRunInit["flags"] }); + + await runs(record.id, { runsDir, json: true }); + + const { next } = JSON.parse(captured.out) as { next: string[] }; + expect(next).toEqual([expect.stringContaining(`clerk migrate undo ${record.id}`)]); + }); + // Left out, the continue creates the users --require-password held back. test("the continue repeats the flags the import ran with", async () => { const record = partialImport({ flags: { requirePassword: true } }); diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index f2e53323a3..e554f629b3 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -115,6 +115,10 @@ function errorBreakdown(lines: UserLine[]): { error: string; count: number }[] { * with the flags that reach the same runs folder and instance. */ function continueCommand(record: RunRecord, carry: string[]): string | undefined { + // Every import records its flags. None, or the string[] an earlier build + // wrote, can't be read back, and a continue without them would create the + // users --require-password held back. + if (!record.flags || Array.isArray(record.flags)) return undefined; const input = record.fromExport ?? record.file?.path; if (!input) return undefined; const parts = ["clerk migrate import", quoteArg(input)]; From cb57e0598741acb5a92108df9445b6f49b7ff477 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 15:02:36 -0400 Subject: [PATCH 60/63] docs(migrate): say an unfinished run counts as having users to retry A run stopped after its last user's line, but before finish(), sent everyone, so "always has" overstated it. The behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/lib/run-store.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 bc43424c42..73cdccfc55 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -335,7 +335,8 @@ export const NO_PASSWORD_CODE = "no_password"; * Whether a continue has users to retry: one never sent, or a latest line it * retries. Not a `--require-password` skip: the continue carries the flag, so * it leaves that user out again, and suggesting it would loop. A run that never - * finished always has: the users it never sent have no lines to count. + * finished counts as having some: the users it never sent have no lines to + * count. */ export function hasRetryableUsers(record: RunRecord, latest: Iterable): boolean { if (!record.finishedAt || (record.counts.notSent ?? 0) > 0) return true; From 5e05934ab5ce2e2a0a451e1d13be273e11500a84 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 15:21:12 -0400 Subject: [PATCH 61/63] fix(migrate): count attach-only users as created, show unsent users in runs, and word an empty undo's dry run - An attach-only continue counts its users in `successful`, so the summary's "Imported:" and `result.created` match the bar. - The --json preview carries `attachOnly`, as it carries `withoutPassword`. - `runs` and `runs ` show "N not sent" for an import stopped before it sent everyone. - With every user already gone, undo's preview and dry run say it marks the run undone, not "Will delete 0 users". - Drop the 429 branch `failureOf` already covers, a dead `?? {}`, and an `isAgent()` check `isHuman()` already makes. - Docs: `flags`'s shapes, `continueCommand`'s return, and the README's list of when `runs ` leaves out the continue. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 7 +++++-- .../src/commands/migrate/import-users.test.ts | 5 +++-- .../src/commands/migrate/import-users.ts | 11 ++++------ .../src/commands/migrate/lib/run-store.ts | 2 ++ .../src/commands/migrate/lib/terminal.ts | 5 +++-- .../cli-core/src/commands/migrate/run.test.ts | 5 +++++ packages/cli-core/src/commands/migrate/run.ts | 6 +++++- .../src/commands/migrate/runs.test.ts | 21 +++++++++++++++++++ .../cli-core/src/commands/migrate/runs.ts | 8 ++++--- .../src/commands/migrate/undo.test.ts | 13 ++++++++++++ .../cli-core/src/commands/migrate/undo.ts | 8 +++++-- 11 files changed, 72 insertions(+), 19 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index a695052c49..48a9409bc2 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -266,7 +266,9 @@ before importing, it carries one of `dryRun: true`, `refused: true`, `consent: "required"` or `nothingToImport: true` in place of `result`, and a file already imported in full returns `alreadyImported: true`. With `--require-password`, `withoutPassword` counts the users it left out before the -checks, so `checks.total` plus it is the file's size. +checks, so `checks.total` plus it is the file's size. A continue with +identifiers left to attach carries `attachOnly`: the users it attaches them to, +which skip the checks too. #### Re-running @@ -493,7 +495,8 @@ with the flags it ran with, then the undo. The continue is left out when importing the file again might not reach this run: a newer run of the same file and source that may be for the same instance (a run under a key's stand-in ID could be for any instance), a file changed since, or any undo of it. It is also -left out when only `--require-password` skips are left. Both carry what reaches +left out when only `--require-password` skips are left, or for a run from an +earlier build, whose flags can't be read. Both carry what reaches the run's instance again, from the key its latest attempt used: `--secret-key ` for a key passed with `--secret-key` or exported (as `CLERK_SECRET_KEY`, or a framework's own variable such as `NUXT_CLERK_SECRET_KEY`), and `--app` and diff --git a/packages/cli-core/src/commands/migrate/import-users.test.ts b/packages/cli-core/src/commands/migrate/import-users.test.ts index c1731cff0f..9a0af5d242 100644 --- a/packages/cli-core/src/commands/migrate/import-users.test.ts +++ b/packages/cli-core/src/commands/migrate/import-users.test.ts @@ -493,7 +493,7 @@ describe("importUsers", () => { stub(() => ok("idn_1")); const reported: { done: number; ok: number }[] = []; - await importUsers({ + const summary = await importUsers({ users: [], attachOnly: [ { @@ -509,8 +509,9 @@ describe("importUsers", () => { progress: ({ done, ok }) => reported.push({ done, ok }), }); - // Created by the run's earlier attempt, so the counts under the bar add up. + // Created by the run's earlier attempt: the bar and the summary agree. expect(reported.at(-1)).toEqual({ done: 1, ok: 1 }); + expect(summary.successful).toBe(1); }); // The pending phone attaches now, but the email Clerk refused stays refused. diff --git a/packages/cli-core/src/commands/migrate/import-users.ts b/packages/cli-core/src/commands/migrate/import-users.ts index a9310d9fec..67c37867b8 100644 --- a/packages/cli-core/src/commands/migrate/import-users.ts +++ b/packages/cli-core/src/commands/migrate/import-users.ts @@ -402,9 +402,7 @@ export async function importUsers(options: ImportUsersOptions): Promise - report?.({ done: processed + attachedUsers, ok: successful + attachedUsers, failed }); + const progress = () => report?.({ done: processed + attachedUsers, ok: successful, failed }); const recordFailure = ( userId: string, @@ -488,10 +486,6 @@ export async function importUsers(options: ImportUsersOptions): Promise { // Its earlier error names identifiers Clerk refused; the new line keeps it. await finishUser(line, line.pending ?? [], line.error ? [line.error] : []); + // Created by this run's earlier attempt, as an adopted user was: the bar, + // the summary and `result.created` all count it. + successful++; attachedUsers++; progress(); }; 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 73cdccfc55..810be0c2f1 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -85,6 +85,8 @@ export type RunRecord = { /** * The flags an import's latest attempt ran with, as values: a record * outlives the CLI that wrote it, and `importFlags` spells them for this one. + * An earlier build wrote a `string[]`, or none; `runs ` offers no + * continue for those. */ flags?: ImportFlags; /** The import run an undo reverses. */ diff --git a/packages/cli-core/src/commands/migrate/lib/terminal.ts b/packages/cli-core/src/commands/migrate/lib/terminal.ts index 38606fe1a6..12f361448c 100644 --- a/packages/cli-core/src/commands/migrate/lib/terminal.ts +++ b/packages/cli-core/src/commands/migrate/lib/terminal.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { isAgent, isHuman } from "../../../mode.ts"; +import { isHuman } from "../../../mode.ts"; /** * Whether a prompt has a terminal to read: stdin, or `/dev/tty` behind a piped @@ -20,5 +20,6 @@ export const terminal = { /** Whether someone can answer a prompt: not an agent, a non-TTY run, or `--json`. */ export function canPrompt(options: { json?: boolean }): boolean { - return !options.json && isHuman() && !isAgent() && terminal.available(); + // A mode is human or agent, so isHuman() alone rules an agent out. + return !options.json && isHuman() && terminal.available(); } diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 552f5d1648..b29766bf21 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -1108,6 +1108,11 @@ describe("run", () => { (caught: unknown) => caught, )) as CliError; expect(error.message).toContain("will create 0 users, attach identifiers to 1 user."); + + // The --json refusal counts them too, as it counts --require-password's. + captured.clear(); + await run({ ...baseOptions, yes: false, json: true }).catch(() => undefined); + expect(JSON.parse(captured.out)).toMatchObject({ consent: "required", attachOnly: 1 }); }); // A skip the checks named a fix for imports once the setting changes: the diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index a5280a3ab4..eb43822c7d 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -789,7 +789,11 @@ 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 } : {}; + // Attach-only users skip them too, and a consent refusal names them. + const leftOut = { + ...(withoutPassword.length > 0 ? { withoutPassword: withoutPassword.length } : {}), + ...(attachOnly.length > 0 ? { attachOnly: attachOnly.length } : {}), + }; const preview = (extra: Record) => log.data( JSON.stringify( diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index ee67b25b9c..566fe76db5 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -172,6 +172,27 @@ describe("runs ", () => { expect(err).toContain("1 user: bad gateway (a re-run checks)"); }); + // A stopped import's unsent users have no lines: only the count says so. + test("shows the users a stopped import never sent, in the list and the run", async () => { + fs.writeFileSync(usersFile(), "[]"); + const run = startRun(runsDir, { + kind: "import", + target: TARGET, + source: "clerk", + file: { path: usersFile(), sha256: sha256File(usersFile()) }, + flags: {}, + }); + run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + const record = run.finish({ notSent: 9000 }); + + await runs(undefined, { runsDir }); + expect(Bun.stripANSI(captured.err)).toContain("1 created, 9000 not sent"); + + captured.clear(); + await runs(record.id, { runsDir }); + expect(Bun.stripANSI(captured.err)).toContain("1 created, 9000 not sent"); + }); + // An unfinished run is counted from its lines, `pending` included. test("counts identifiers left to attach on a run that never finished", async () => { const record = partialImport(); diff --git a/packages/cli-core/src/commands/migrate/runs.ts b/packages/cli-core/src/commands/migrate/runs.ts index e554f629b3..70660eaa14 100644 --- a/packages/cli-core/src/commands/migrate/runs.ts +++ b/packages/cli-core/src/commands/migrate/runs.ts @@ -60,6 +60,7 @@ function colorState(state: RunState): string { const COUNT_LABELS: Partial> = { creating: "not confirmed", pending: "left to attach", + notSent: "not sent", }; /** The counts that matter for a run's kind, in the order they are read. */ @@ -70,7 +71,7 @@ function formatCounts(record: RunRecord): string { ? (["exported", "skipped"] as const) : record.kind === "undo" ? (["deleted", "failed"] as const) - : (["created", "pending", "creating", "failed", "skipped"] as const); + : (["created", "pending", "creating", "failed", "notSent", "skipped"] as const); const parts = order .filter((status) => (counts[status] ?? 0) > 0 || status === order[0]) .map((status) => `${counts[status] ?? 0} ${COUNT_LABELS[status] ?? status}`); @@ -112,7 +113,8 @@ function errorBreakdown(lines: UserLine[]): { error: string; count: number }[] { /** * The import that continues this run: its file (or export run) and source, - * with the flags that reach the same runs folder and instance. + * with the flags that reach the same runs folder and instance, ending in + * `--yes`. None when the run has no file, or flags this build can't read. */ function continueCommand(record: RunRecord, carry: string[]): string | undefined { // Every import records its flags. None, or the string[] an earlier build @@ -123,7 +125,7 @@ function continueCommand(record: RunRecord, carry: string[]): string | undefined if (!input) return undefined; const parts = ["clerk migrate import", quoteArg(input)]; if (!record.fromExport && record.source) parts.push("--source", quoteArg(record.source)); - return withFlags(parts.join(" "), [...importFlags(record.flags ?? {}), ...carry, "--yes"]); + return withFlags(parts.join(" "), [...importFlags(record.flags), ...carry, "--yes"]); } /** diff --git a/packages/cli-core/src/commands/migrate/undo.test.ts b/packages/cli-core/src/commands/migrate/undo.test.ts index 0299900d18..8a40ac8581 100644 --- a/packages/cli-core/src/commands/migrate/undo.test.ts +++ b/packages/cli-core/src/commands/migrate/undo.test.ts @@ -224,6 +224,19 @@ describe("refusals, all exit 2 and delete nothing", () => { expect(error.examples?.[0]?.description).toBe(`Mark run ${record.id} undone`); }); + // The preview and the dry run say the same, rather than "Will delete 0 users". + test("a dry run with every user already gone names the mark, not 0 deletes", async () => { + instanceUsers.clear(); + const record = importRun(); + + await undo(record.id, withDir({ dryRun: true })); + + const err = Bun.stripANSI(captured.err); + expect(err).toContain(`Will mark import run ${record.id} undone: nothing is left to delete.`); + expect(err).toContain("to mark it undone."); + expect(err).not.toContain("Will delete 0"); + }); + // Another process continues the import while the undo previews it. describe("the run changing during the preview", () => { const duringPreview = (act: () => void) => { diff --git a/packages/cli-core/src/commands/migrate/undo.ts b/packages/cli-core/src/commands/migrate/undo.ts index 55e3165284..45f1721116 100644 --- a/packages/cli-core/src/commands/migrate/undo.ts +++ b/packages/cli-core/src/commands/migrate/undo.ts @@ -283,8 +283,11 @@ async function deleteUsers(options: { function printPreview(record: RunRecord, preview: UndoPreview): void { log.blank(); + // Every user already gone: the undo only records the import as undone. log.info( - `${bold(`Will delete ${plural(preview.toDelete, "user")}`)} created by import run ${record.id}.`, + preview.toDelete > 0 + ? `${bold(`Will delete ${plural(preview.toDelete, "user")}`)} created by import run ${record.id}.` + : `${bold(`Will mark import run ${record.id} undone`)}: nothing is left to delete.`, ); if (preview.signedInSince > 0) { const who = @@ -399,7 +402,8 @@ export async function undo(runId: string, options: UndoOptions = {}): Promise 0 ? "to delete them" : "to mark it undone"; + log.info(dim(`Dry run: nothing was deleted. Run \`${command}\` ${then}.`)); } return; } From 4eb82999fbf67fd0c9cd6da178d46b8267c0e54a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 17:06:18 -0400 Subject: [PATCH 62/63] fix(migrate): stop suggesting a continue for skips the same file brings back, and carry the flags every continue needs Michael's second pass on #515: - A skip for the file's own data (invalid, a duplicate within it, an email Clerk refuses, a malformed hash) is recorded with code "in_file", and hasRetryableUsers no longer counts it. A continue reads the same file, so suggesting one looped. - The --new-run refusal offers "Drop --new-run" only when that continue retries someone. Its command carries the earlier run's --allow-partial and --skip-legal-checks, so it runs as printed. - The import's retry step always ends in --yes, as runs 's does. - README: the Ctrl-C exceptions in undo's --json, "if one was written", when "Not sent" is counted, and the preview's sum on a continue. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 31 +++++++---- .../src/commands/migrate/lib/checks.test.ts | 16 ++++-- .../src/commands/migrate/lib/checks.ts | 35 ++++++++++--- .../src/commands/migrate/lib/run-store.ts | 16 ++++-- .../commands/migrate/run-interactive.test.ts | 19 +++++++ .../cli-core/src/commands/migrate/run.test.ts | 50 ++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 52 +++++++++++++------ .../src/commands/migrate/runs.test.ts | 25 +++++++++ 8 files changed, 201 insertions(+), 43 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 48a9409bc2..bf28cdaf35 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -142,8 +142,10 @@ Each run is a folder named for its ID, `YYYYMMDD-HHmmss-xxxx`: | `lock` | The PID of the process writing the run, while it runs | On a `failed` line, `code` is the HTTP status, such as `422` or `429`, or -`unknown`. On a `skipped` line it gives the reason in machine form, such as -`no_password`. +`unknown`. On a `skipped` line it gives the reason in machine form: +`no_password` for a user `--require-password` left out, or `in_file` for one +the checks skipped for the file's own data (invalid, a duplicate within it, an +email Clerk refuses, a malformed hash). A user's status is `creating`, `created`, `failed`, `skipped` or `deleted`. The last line for each `sourceId` wins. A `429` retry, an extra email or phone that did not @@ -259,14 +261,17 @@ failed. `code: "no_password"`, so the run ends `partial`. A continue carries the flag and leaves them out again, so when they are all that's left, neither the import nor `runs ` suggests one. To import them, run the same import again without -the flag: it continues this run and creates them. +the flag: it continues this run and creates them. A skip for the file's own data +(`code: "in_file"`) comes back on every continue too, so it isn't counted as +something to retry: fix the file, which makes a new run. `--json` returns `{ target, run, resume, checks, result }`. When a run stops before importing, it carries one of `dryRun: true`, `refused: true`, `consent: "required"` or `nothingToImport: true` in place of `result`, and a file already imported in full returns `alreadyImported: true`. With `--require-password`, `withoutPassword` counts the users it left out before the -checks, so `checks.total` plus it is the file's size. A continue with +checks, so on a first import `checks.total` plus it is the file's size. A +continue leaves out the users the run already created. A continue with identifiers left to attach carries `attachOnly`: the users it attaches them to, which skip the checks too. @@ -467,8 +472,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. 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) to send them. +`counts.notSent` in `run.json`), written when the run finishes. A run stopped +with Ctrl-C never finishes, so it can't count them, and `runs ` offers its +continue instead. Once the limit is raised, [run the import again](#re-running) +to send them. ### `clerk migrate runs` @@ -495,8 +502,9 @@ with the flags it ran with, then the undo. The continue is left out when importing the file again might not reach this run: a newer run of the same file and source that may be for the same instance (a run under a key's stand-in ID could be for any instance), a file changed since, or any undo of it. It is also -left out when only `--require-password` skips are left, or for a run from an -earlier build, whose flags can't be read. Both carry what reaches +left out when only `--require-password` skips, or skips for the file's own +data, are left, or for a run from an earlier build, whose flags can't be read. +Both carry what reaches the run's instance again, from the key its latest attempt used: `--secret-key ` for a key passed with `--secret-key` or exported (as `CLERK_SECRET_KEY`, or a framework's own variable such as `NUXT_CLERK_SECRET_KEY`), and `--app` and @@ -573,7 +581,8 @@ the users that failed, in the same undo run. A Ctrl-C leaves the undo run `interrupted`, as it does an import, and running `undo` again carries on. Once undo reaches its preview, `--json` prints one object on stdout, whatever -happens next. A refusal before that — no such run, a run that isn't an import, +happens next, unless a Ctrl-C exits first (see `stopped` below). A refusal +before that — no such run, a run that isn't an import, or a key for another instance — is an error, as it is for every command. The object: @@ -601,8 +610,8 @@ before that, the object says why: deletes, failed. - `"writeFailed": true`: the file system refused a write to the runs folder. - `"stopped": true`: anything else stopped it. A Ctrl-C may exit before the - object is printed: `clerk migrate runs` then lists the undo run, and - `runs ` shows how far it got. + object is printed: `clerk migrate runs` then lists the undo run, if one was + written, and `runs ` shows how far it got. With `writeFailed` or `stopped`, `run` is the undo run as it stands on disk, once one was written: its counts come from its lines, and `state` says how it diff --git a/packages/cli-core/src/commands/migrate/lib/checks.test.ts b/packages/cli-core/src/commands/migrate/lib/checks.test.ts index ff1fa96f43..70539dc6e4 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.test.ts @@ -164,7 +164,9 @@ describe("rejects", () => { const checks = await checkImport( input({ failures: [{ userId: "bad", row: 0, error: "Invalid email", path: ["email"] }] }), ); - expect(checks.rejects).toEqual([{ sourceId: "bad", reason: "invalid: Invalid email" }]); + expect(checks.rejects).toEqual([ + { sourceId: "bad", reason: "invalid: Invalid email", inFile: true }, + ]); expect(checks.total).toBe(1); }); @@ -247,6 +249,7 @@ describe("rejects", () => { sourceId: "auth0|1", reason: "email is also used by an earlier user in the file, which is kept", keptSourceId: "email|1", + inFile: true, }, ]); }); @@ -332,7 +335,7 @@ describe("rejects", () => { const checks = await checkImport(input({ users: [user("a"), user("a")] })); expect(checks.importable.map((u) => u.userId)).toEqual(["a"]); expect(checks.rejects).toEqual([ - { sourceId: "a", reason: "duplicate source ID in the file", keptSourceId: "a" }, + { sourceId: "a", reason: "duplicate source ID in the file", keptSourceId: "a", inFile: true }, ]); }); @@ -529,6 +532,7 @@ describe("rejects", () => { sourceId: "early", reason: "shares an email, phone or username with a user an earlier attempt already created", keptSourceId: "late", + inFile: true, }, ]); }); @@ -551,6 +555,7 @@ describe("rejects", () => { sourceId: "a", reason: "shares an email, phone or username with a user an earlier attempt already created", keptSourceId: "b", + inFile: true, }, ]); }); @@ -564,7 +569,7 @@ describe("rejects", () => { ); expect(checks.importable).toHaveLength(1); expect(checks.rejects).toEqual([ - { sourceId: "a", reason: "duplicate source ID in the file", keptSourceId: "a" }, + { sourceId: "a", reason: "duplicate source ID in the file", keptSourceId: "a", inFile: true }, ]); }); @@ -592,7 +597,9 @@ describe("rejects", () => { const checks = await checkImport( input({ users: [user("a", { skipReason: "anonymous Better Auth user" }), user("b")] }), ); - expect(checks.rejects).toEqual([{ sourceId: "a", reason: "anonymous Better Auth user" }]); + expect(checks.rejects).toEqual([ + { sourceId: "a", reason: "anonymous Better Auth user", inFile: true }, + ]); expect(checks.importable.map((entry) => entry.userId)).toEqual(["b"]); }); @@ -607,6 +614,7 @@ describe("rejects", () => { { sourceId: "a", reason: "only has emails Clerk refuses (malformed, or a domain that can't receive mail)", + inFile: true, }, ]); }); diff --git a/packages/cli-core/src/commands/migrate/lib/checks.ts b/packages/cli-core/src/commands/migrate/lib/checks.ts index d68c9dc5c9..e80ad51a43 100644 --- a/packages/cli-core/src/commands/migrate/lib/checks.ts +++ b/packages/cli-core/src/commands/migrate/lib/checks.ts @@ -50,6 +50,11 @@ export type Reject = { reason: string; /** For a duplicate: the earlier user in the file that is kept instead. */ keptSourceId?: string; + /** + * Caused by the file's own data, so the same file rejects it again: no + * setting or instance change lets it in on a continue. + */ + inFile?: true; }; export type ReasonCount = { reason: string; count: number }; @@ -983,6 +988,7 @@ export async function checkImport(input: CheckInput): Promise { const rejects: Reject[] = input.failures.map((failure) => ({ sourceId: failure.userId, reason: `invalid: ${failure.error}`, + inFile: true, })); const disabledProviders = findDisabledProviderRejects(input); @@ -1012,15 +1018,22 @@ export async function checkImport(input: CheckInput): Promise { if (phoned.dropped) loosePhoneUsers.add(original.userId); const user = phoned.user; const sent = dropDisabledIdentifiers(user, input.settings); + // The file's own data, which the same file brings back on a continue. + const refusedOnly = + refused.length > 0 && !phoned.dropped && !hasAnyIdentifier(user) + ? "only has emails Clerk refuses (malformed, or a domain that can't receive mail)" + : undefined; + const hashProblem = + user.password && user.passwordHasher + ? hashShapeProblem(user.password, user.passwordHasher) + : undefined; const reason = adopted(original) ? undefined : (original.skipReason ?? (refused.length > 0 && phoned.dropped && !hasAnyIdentifier(user) ? "only has emails Clerk refuses and phones not in E.164 form (numeric usernames are on)" : undefined) ?? - (refused.length > 0 && !hasAnyIdentifier(user) - ? "only has emails Clerk refuses (malformed, or a domain that can't receive mail)" - : undefined) ?? + refusedOnly ?? (phoned.dropped && !hasAnyIdentifier(user) ? "only has phones not in E.164 form, which this instance refuses (numeric usernames are on)" : undefined) ?? @@ -1045,12 +1058,12 @@ export async function checkImport(input: CheckInput): Promise { ? "no legal acceptance on record, which this instance requires (--skip-legal-checks imports them without it)" : undefined) ?? usernameProblem(user, input.settings, loosePhones) ?? - (user.password && user.passwordHasher - ? hashShapeProblem(user.password, user.passwordHasher) - : undefined) ?? + hashProblem ?? disabledProviders.get(user.userId)); if (reason) { - rejects.push({ sourceId: user.userId, reason }); + const inFile = + reason === original.skipReason || reason === refusedOnly || reason === hashProblem; + rejects.push({ sourceId: user.userId, reason, ...(inFile ? { inFile: true as const } : {}) }); } else { passed.push(sent); unstripped.set(sent, user); @@ -1077,6 +1090,7 @@ export async function checkImport(input: CheckInput): Promise { sourceId: user.userId, reason: "shares an email, phone or username with a user an earlier attempt already created", keptSourceId: holder, + inFile: true, }); duplicates.add(originals.get(user) ?? user); continue; @@ -1087,7 +1101,12 @@ export async function checkImport(input: CheckInput): Promise { const duplicate = fileDuplicates.get(user); const reason = adopted(user) && duplicate !== DUPLICATE_SOURCE_ID ? undefined : duplicate; if (reason) { - rejects.push({ sourceId: user.userId, reason, ...(kept ? { keptSourceId: kept } : {}) }); + rejects.push({ + sourceId: user.userId, + reason, + ...(kept ? { keptSourceId: kept } : {}), + inFile: true, + }); duplicates.add(originals.get(user) ?? user); } else candidates.push(user); } 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 810be0c2f1..69780dfd1b 100644 --- a/packages/cli-core/src/commands/migrate/lib/run-store.ts +++ b/packages/cli-core/src/commands/migrate/lib/run-store.ts @@ -111,7 +111,7 @@ export type UserLine = { /** Why a user was skipped. */ reason?: string; error?: string; - /** Failed: the HTTP status, or "unknown". Skipped: a machine reason, such as NO_PASSWORD_CODE. */ + /** Failed: the HTTP status, or "unknown". Skipped: a machine reason, NO_PASSWORD_CODE or IN_FILE_CODE. */ code?: string; passwordDropped?: boolean; }; @@ -333,10 +333,18 @@ export function withLiveCounts(runsDir: string, record: RunRecord): RunRecord { */ export const NO_PASSWORD_CODE = "no_password"; +/** + * The `code` on a user the checks skipped for the file's own data: invalid, + * a duplicate within it, an email Clerk refuses, a malformed hash. A continue + * reads the same file, so it skips them again. + */ +export const IN_FILE_CODE = "in_file"; + /** * Whether a continue has users to retry: one never sent, or a latest line it - * retries. Not a `--require-password` skip: the continue carries the flag, so - * it leaves that user out again, and suggesting it would loop. A run that never + * retries. Not a `--require-password` skip, nor one for the file's own data: + * the continue carries the flag and reads the same file, so it leaves that + * user out again, and suggesting it would loop. A run that never * finished counts as having some: the users it never sent have no lines to * count. */ @@ -344,7 +352,7 @@ export function hasRetryableUsers(record: RunRecord, latest: Iterable) if (!record.finishedAt || (record.counts.notSent ?? 0) > 0) return true; return [...latest].some((line) => line.status === "skipped" - ? line.code !== NO_PASSWORD_CODE + ? line.code !== NO_PASSWORD_CODE && line.code !== IN_FILE_CODE : line.status !== "created" || (line.pending?.length ?? 0) > 0, ); } 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 1f45c7649b..473b56ce09 100644 --- a/packages/cli-core/src/commands/migrate/run-interactive.test.ts +++ b/packages/cli-core/src/commands/migrate/run-interactive.test.ts @@ -140,6 +140,25 @@ const created = () => const importOptions = { input: "export.json", source: "clerk", secretKey: "sk_test_x" }; +// A yes at the prompt is consent to this run, so the retry it suggests runs +// as printed, like `runs `'s continue. +test("after a yes at the prompt, the suggested retry carries --yes", async () => { + const clerk = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const body = typeof init?.body === "string" ? init.body : ""; + return init?.method === "POST" && body.includes('"u2"') + ? Response.json( + { errors: [{ message: "taken", code: "form_identifier_exists" }] }, + { status: 422 }, + ) + : clerk(input, init); + }) as typeof fetch; + + await run(importOptions); + + expect(Bun.stripANSI(captured.err)).toMatch(/--yes` to retry them/); +}); + 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" }); diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index b29766bf21..857ffc7276 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -458,6 +458,56 @@ describe("run", () => { expect(error.message).toContain(`Drop --new-run to continue run ${first!.id}.`); }); + // The same file rejects it again, so a continue would only loop. + test("a run left partial only by the file's own bad data offers no retry, nor a continue", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([...export2, { id: "u3", primary_email_address: "not-an-email" }]), + ); + await run({ ...baseOptions, allowPartial: true }); + const [first] = listRuns(runsDir()); + expect(first).toMatchObject({ status: "partial" }); + expect(latestUserLines(runsDir(), first!.id).get("u3")).toMatchObject({ + status: "skipped", + code: "in_file", + }); + expect(Bun.stripANSI(captured.err)).not.toContain("to retry them"); + + captured.clear(); + stubClerk({ + existing: [ + { id: "user_u1", external_id: "u1" }, + { id: "user_u2", external_id: "u2" }, + ], + }); + const error = (await run({ ...baseOptions, newRun: true }).catch( + (caught: unknown) => caught, + )) as CliError; + expect(error.message).not.toContain("Drop --new-run"); + }); + + // Without the flag that got the earlier run past its checks, its continue + // would refuse the same users again. + test("--new-run's continue carries the --allow-partial the earlier run needed", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([...export2, { id: "u3", primary_email_address: "not-an-email" }]), + ); + stubClerk({ failing: new Set(["u2"]) }); + await run({ ...baseOptions, allowPartial: true }); + const [first] = listRuns(runsDir()); + captured.clear(); + stubClerk({ existing: [{ id: "user_u1", external_id: "u1" }] }); + + const error = (await run({ ...baseOptions, newRun: true }).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.message).toContain(`Drop --new-run to continue run ${first!.id}.`); + expect(error.examples?.[0]?.command).toContain("--allow-partial"); + expect(error.examples?.[0]?.command).not.toContain("--new-run"); + }); + // The continue would carry --require-password and leave the same users out. test("--new-run --require-password doesn't offer a continue that imports nobody", async () => { await run({ ...baseOptions, requirePassword: true }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index eb43822c7d..39fad4e251 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -62,6 +62,7 @@ import { type UserLine, hasRetryableUsers, NO_PASSWORD_CODE, + IN_FILE_CODE, unfinishedUndoMessage, inUseMessage, openUndoOf, @@ -570,9 +571,10 @@ function earlierRunHolding( } /** - * Whether the same import without `--new-run` would continue run `id` and - * import someone: `findResume` has to pick it, and `--require-password` has to - * leave a user it would retry. + * The run the same import without `--new-run` would continue, when that is + * run `id` and it would import someone: `findResume` has to pick it, and it + * has to hold a user the continue retries. A `--require-password` skip is one + * when this import drops the flag. */ function continuesWithoutNewRun( runsDir: string, @@ -580,18 +582,21 @@ function continuesWithoutNewRun( carry: string[], id: string, requirePassword: boolean | undefined, -): boolean { +): RunRecord | undefined { let resume: ResumeCase; try { resume = findResume(runsDir, match, carry); } catch { // It would refuse: see findResume's @throws. - return false; + return undefined; } - if (resume.kind !== "continue" || resume.record.id !== id) return false; - return ( - !requirePassword || hasRetryableUsers(resume.record, latestUserLines(runsDir, id).values()) - ); + if (resume.kind !== "continue" || resume.record.id !== id) return undefined; + const lines = [...latestUserLines(runsDir, id).values()]; + const retries = + hasRetryableUsers(resume.record, lines) || + (!requirePassword && + lines.some((line) => line.status === "skipped" && line.code === NO_PASSWORD_CODE)); + return retries ? resume.record : undefined; } /** @@ -599,11 +604,12 @@ function continuesWithoutNewRun( * The checks never reject an adopted user: it is created already. */ function recordRejects(run: Run, checks: ImportChecks): void { - for (const { sourceId, reason, keptSourceId } of checks.rejects) { + for (const { sourceId, reason, keptSourceId, inFile } of checks.rejects) { run.append({ sourceId, status: "skipped", reason: keptSourceId ? `${reason} (kept: ${keptSourceId})` : reason, + ...(inFile ? { code: IN_FILE_CODE } : {}), }); } } @@ -827,12 +833,25 @@ export async function run(rawOptions: MigrateRunOptions): Promise { if (options.json) preview({ refused: true }); const earlier = earlierRunHolding(runsDir, source, target.instanceId, checks, sha256); const undoEarlier = earlier ? undoCommand(earlier.id, carry) : undefined; - const continueEarlier = - options.newRun && - earlier && - continuesWithoutNewRun(runsDir, match, carry, earlier.id, options.requirePassword) - ? commandFor({ ...options, newRun: false }, ["--yes"]) + const earlierRun = + options.newRun && earlier + ? continuesWithoutNewRun(runsDir, match, carry, earlier.id, options.requirePassword) : undefined; + // The flags that got that run past its checks: without them its + // continue refuses the same users again. + const recorded = + earlierRun?.flags && !Array.isArray(earlierRun.flags) ? earlierRun.flags : {}; + const continueEarlier = earlierRun + ? commandFor( + { + ...options, + newRun: false, + allowPartial: options.allowPartial || recorded.allowPartial, + skipLegalChecks: options.skipLegalChecks || recorded.skipLegalChecks, + }, + ["--yes"], + ) + : undefined; throwUsageError( `${plural(checks.rejects.length, "user")} would be rejected, so nothing was imported.` + `${rejectSummary(checks, options.json)} ` + @@ -1037,7 +1056,8 @@ export async function run(rawOptions: MigrateRunOptions): Promise { ? NEXT_STEPS.MIGRATE_PARTIAL( runsStep, // The same import again continues this run; --new-run would not. - commandFor({ ...options, newRun: false }, options.yes ? ["--yes"] : [], later), + // Run as printed: consent was given to this run, at the prompt or by -y. + commandFor({ ...options, newRun: false }, ["--yes"], later), undo, ) : NEXT_STEPS.MIGRATE_DONE(runsStep, undo); diff --git a/packages/cli-core/src/commands/migrate/runs.test.ts b/packages/cli-core/src/commands/migrate/runs.test.ts index 566fe76db5..eb2c4b6cb4 100644 --- a/packages/cli-core/src/commands/migrate/runs.test.ts +++ b/packages/cli-core/src/commands/migrate/runs.test.ts @@ -324,6 +324,31 @@ describe("runs ", () => { expect(next).toEqual([expect.stringContaining(`clerk migrate undo ${record.id}`)]); }); + // The continue reads the same file, so the checks skip them again. + test("offers no continue when only skips for the file's own data are left", async () => { + fs.writeFileSync(usersFile(), "[]"); + const run = startRun(runsDir, { + kind: "import", + target: TARGET, + source: "clerk", + file: { path: usersFile(), sha256: sha256File(usersFile()) }, + flags: { allowPartial: true }, + }); + run.append({ sourceId: "a", status: "created", clerkId: "user_a" }); + run.append({ + sourceId: "b", + status: "skipped", + reason: "invalid: Invalid email", + code: "in_file", + }); + const record = run.finish(); + + await runs(record.id, { runsDir, json: true }); + + const { next } = JSON.parse(captured.out) as { next: string[] }; + expect(next).toEqual([expect.stringContaining(`clerk migrate undo ${record.id}`)]); + }); + // Each of these would start another run, or refuse, rather than continue it. describe("offers only the undo when importing the file again would not continue the run", () => { const onlyUndo = async (id: string) => { From 74e7b96a0814b3940234e502ea69805a717e2067 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 18:28:42 -0400 Subject: [PATCH 63/63] fix(migrate): don't offer --allow-partial when a refusal rejects every user With no user passing the checks, the refusal still suggested --allow-partial and an "Import the users that pass" example, which would start a run that imports nobody. It now leaves both out: a refusal naming an earlier run offers its undo (and its continue, when that retries someone), and a plain refusal says --allow-partial would import nobody. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/run.test.ts | 42 +++++++++++++++++++ packages/cli-core/src/commands/migrate/run.ts | 30 +++++++++---- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 857ffc7276..85b4f0f38e 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -508,6 +508,48 @@ describe("run", () => { expect(error.examples?.[0]?.command).not.toContain("--new-run"); }); + // --allow-partial would start a run that imports nobody. + test("--new-run with every user rejected offers the undo, not --allow-partial", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([...export2, { id: "u3", primary_email_address: "not-an-email" }]), + ); + await run({ ...baseOptions, allowPartial: true }); + const [first] = listRuns(runsDir()); + captured.clear(); + stubClerk({ + existing: [ + { id: "user_u1", external_id: "u1" }, + { id: "user_u2", external_id: "u2" }, + ], + }); + + const error = (await run({ ...baseOptions, newRun: true }).catch( + (caught: unknown) => caught, + )) as CliError; + + expect(error.message).toContain(`Undo run ${first!.id} first (`); + expect(error.message).not.toContain("import the rest into a new run"); + expect(error.examples?.map((example) => example.description)).toEqual([ + `Undo run ${first!.id} first`, + ]); + }); + + test("a refusal of a file where no user passes doesn't offer --allow-partial", async () => { + fs.writeFileSync( + path.join(workDir, "export.json"), + JSON.stringify([{ id: "u3", primary_email_address: "not-an-email" }]), + ); + + const error = (await run(baseOptions).catch((caught: unknown) => caught)) as CliError; + + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain( + "with every user rejected, --allow-partial would import nobody", + ); + expect(error.examples ?? []).toEqual([]); + }); + // The continue would carry --require-password and leave the same users out. test("--new-run --require-password doesn't offer a continue that imports nobody", async () => { await run({ ...baseOptions, requirePassword: true }); diff --git a/packages/cli-core/src/commands/migrate/run.ts b/packages/cli-core/src/commands/migrate/run.ts index 39fad4e251..08270ea5fe 100644 --- a/packages/cli-core/src/commands/migrate/run.ts +++ b/packages/cli-core/src/commands/migrate/run.ts @@ -852,26 +852,40 @@ export async function run(rawOptions: MigrateRunOptions): Promise { ["--yes"], ) : undefined; + // With every user rejected, --allow-partial would start a run that + // imports nobody, so it isn't offered. + const nonePass = checks.importable.length === 0 && attachOnly.length === 0; + const ways = earlier + ? [ + ...(nonePass ? [] : ["pass --allow-partial to import the rest into a new run"]), + `undo run ${earlier.id} first (\`${undoEarlier}\`) to import the whole file again`, + ].join(", or ") + : ""; throwUsageError( `${plural(checks.rejects.length, "user")} would be rejected, so nothing was imported.` + `${rejectSummary(checks, options.json)} ` + (earlier ? `${earlier.count} of them ${earlier.count === 1 ? "was" : "were"} created by run ${earlier.id}, from ${earlier.sameFile ? "this file" : "an earlier version of this file"}. ` + (continueEarlier - ? `Drop --new-run to continue run ${earlier.id}. Or pass` - : "Pass") + - ` --allow-partial to import the rest into a new run, or undo run ${earlier.id} first (\`${undoEarlier}\`) to import the whole file again.` - : "Fix them, or pass --allow-partial to import the rest and record them as skipped."), + ? `Drop --new-run to continue run ${earlier.id}. Or ${ways}.` + : `${ways[0]!.toUpperCase()}${ways.slice(1)}.`) + : nonePass + ? "Fix them: with every user rejected, --allow-partial would import nobody." + : "Fix them, or pass --allow-partial to import the rest and record them as skipped."), undefined, undefined, [ ...(continueEarlier ? [{ command: continueEarlier, description: `Continue run ${earlier!.id}` }] : []), - { - command: commandFor(options, ["--allow-partial", "--yes"]), - description: "Import the users that pass", - }, + ...(nonePass + ? [] + : [ + { + command: commandFor(options, ["--allow-partial", "--yes"]), + description: "Import the users that pass", + }, + ]), ...(undoEarlier ? [{ command: undoEarlier, description: `Undo run ${earlier!.id} first` }] : []),