Skip to content

feat(migrate): add migration command suite - #479

Draft
royanger wants to merge 47 commits into
mainfrom
ra/integrate-migration-tool-into-cli
Draft

royanger wants to merge 47 commits into
mainfrom
ra/integrate-migration-tool-into-cli

Conversation

@royanger

Copy link
Copy Markdown
Member

No description provided.

royanger added 30 commits August 6, 2026 12:46
…`.settings`

`clerk migrate run` wrote a `.settings` file into the working directory to
remember what it last imported. The CLI cannot gitignore that on the user's
behalf, so it lands inside the repository being migrated — and it carried the
Firebase signer key, a secret, as plaintext JSON.

That state now lives in the `migrations` section of the CLI's own config file,
keyed by project through `getProjectKey()` (linked profile, then git remote,
then directory). This is the shape `clerk webhooks listen` already uses for its
relay token, so `migrations` sits beside `relay` with the same accessor pair.

The Firebase hash parameters are dropped from persistence rather than moved:
remembering a secret writes it to disk wherever the file lives. They now fall
back to `CLERK_FIREBASE_SIGNER_KEY`, `CLERK_FIREBASE_SALT_SEPARATOR`,
`CLERK_FIREBASE_ROUNDS` and `CLERK_FIREBASE_MEM_COST`, so a repeat run still
need not re-type four flags, and `.env.local` is already gitignored.

No migration path for existing `.settings` files: `clerk migrate` is unreleased,
so nothing in the wild has one.
…v vars the way the secret key does

Two related gaps.

**The env vars did not work from a file.** `CLERK_FIREBASE_*`, `AUTH0_*` and
the `*_DB_URL` vars were read straight off `process.env`, which the shipped
binary never populates from a `.env` file — it is compiled with
`--no-compile-autoload-dotenv`. Only an exported shell variable reached them.
The secret key avoided this by parsing the project's env files itself.

That lookup is now shared: `findKeyInProject` moves out of `keyless-target.ts`
into `lib/dotenv.ts` as `findEnvValue`, and every migration value goes through
it. Each resolution reports its source, so `--verbose` names the file a value
came from. Left on `process.env`: `CLERK_MIGRATE_RATE_LIMIT`,
`CLERK_MIGRATE_CONCURRENCY_LIMIT` and `FIREBASE_AUTH_EMULATOR_HOST` — runtime
knobs rather than project config, and `resolveLimits` is sync on a hot path.

**There was no way to see or change what a run would pick up.** `clerk migrate
settings` lists every setting with its value and its source, `set` changes one,
`clear` forgets them. Values are split by what they are, not by which command
wrote them: project state to the CLI config, credentials to
`.env.clerk-migrate`.

That file is the migration's own rather than the app's `.env.local`, because a
Firebase signer key is of no use to the application being migrated and does not
belong in the file its developers read daily. It is added to `.gitignore` on
creation — reusing `ensureGitignoreEntry`, promoted from `keyless.ts` to
`lib/git.ts` for the second caller — and deleted when `clear` removes its last
value. Credentials are redacted everywhere they are shown, `--json` included.
…e column alignment

The list showed seven kebab-case identifiers and nothing else, so `file` and
`skip-unsupported-providers` read as jargon rather than as anything a user could
act on. A description column now carries the prose the registry already held.

The names stay kebab-case on purpose: each one is identical to the `migrate run`
flag it backs, so `firebase-signer-key` and `--firebase-signer-key` are one knob
reached two ways rather than two spellings to learn. Sentence case belongs in
the description, which is where it now is.

Also fixes the alignment. `column()` pads to the visible width before colouring;
the previous code coloured first and then hand-compensated for the escape bytes
with `dim("—").length - 1`, which only held for unset rows — any row with a
value pulled `SOURCE` and everything after it out of line.
…` to a trailing parenthetical

Matches how the README heading already scopes it (`--skip-unsupported-providers
(Supabase)`), and leaves the description reading as one sentence rather than a
label plus a colon.
`migrate logs list|clean|convert` and `migrate transformers list` printed
flush-left with no intro/outro frame, while `migrate run`, `migrate delete`
and every `migrate export` already wrapped — as do the pre-existing commands
they mirror (`apps list`, `mcp list`, `unlink`, `config pull`).

Follows `commands/mcp/list.ts`: the `--json` early return stays outside the
gutter so machine-readable output is unchanged, and only the human path wraps.
`withGutter` no-ops outside human mode, so agent output is untouched, and it
turns a cancelled prompt into `└ Paused` rather than `└ Failed` — which
`logs clean` and `logs convert` both needed.
…d icon conventions

Five formatting mismatches between `migrate` and every command around it.

**Summaries escaped the gutter.** `run` and `delete` printed their final
summary through `log.raw`, the un-prefixed channel meant for machine-readable
output, so it landed flush-left and broke the `┌ … │ … └` frame. `log.info`
runs each line through `applyPrefix`, so only the channel changes.

**Spinners lost the `...` convention.** All 39 pre-existing `withSpinner` call
sites end their message in `...` and none passes a done-message — the
vanishing ellipsis *is* the completion signal. Migrate had inverted both
halves. Adds the ellipsis to every message and `spinner.update()` string, and
drops the four done-message arguments so the stop text derives from the
message like everywhere else.

**No Next steps blocks.** Every comparable command closes with one. Adds
`MIGRATE_DONE`, `MIGRATE_DELETE` and `MIGRATE_EXPORT`, wired through the
gutter's `setNextSteps`. `reportExport` now returns the steps rather than
hand-rolling a `dim("Next: …")` line, which keeps the six export modules'
call shape intact. An export of zero users returns none — there is nothing to
import — so `setNextSteps` ignores an empty list instead of rendering a
header with no bullets under it.

**`user(s)` pluralization.** The CLI's form is `${n} thing${n === 1 ? "" : "s"}`.
Migrate used `user(s)`/`row(s)` in ten places while using the correct form in
others.

**`●`/`○` status icons**, which appear nowhere else in the codebase, become
the established `✓`/`✗`/`!` vocabulary from `doctor` and `init`.
`MultiSelectPrompt` has always bound `a` to toggle every option, but clack's
instruction footer never listed it and takes no override — so a genuinely
useful key stayed undiscoverable unless each call site spelled it out in its
own message, which is worse: it is a property of the prompt, not of any one
question.

`MULTISELECT_INSTRUCTIONS` is the only seam, and it is read fresh on every
render. Inserted second-to-last so `Enter: confirm` stays where readers
expect it. `i` (invert) is left out deliberately: it is rarely what anyone
wants, and a four-item legend stops being scannable.

The test lives outside `prompts.test.ts`, which mocks the whole module. What
is worth checking is that the real clack export is still a live array read at
render time — an upgrade that froze it, replaced it, or rendered a copy would
drop `a: all` silently and nothing else in the suite would notice.
The report already knew which instance settings would cost users; acting on
it meant leaving the CLI for the dashboard. A human run now offers one
selectable change per flagged row, before the import confirmation, and writes
the selection as a single `PATCH` of the instance config document — the same
document `clerk config patch` writes.

These are offers, not corrections. A flagged setting is not a wrong setting:
an instance that genuinely requires an email address is configured exactly as
its owner intended, and fixing the export may well be the right answer.
Nothing is preselected, and selecting nothing continues to the import with
the instance untouched.

The redraw after a write is computed from the write, not from a second
settings fetch. Clerk's Frontend API is eventually consistent, so a
`/v1/environment` read issued this soon after routinely still reports the
pre-write settings and would redraw every row the operator just cleared. The
offer then repeats while anything is still flagged: applying one change
routinely leaves others worth making, so reaching the second never costs a
second run of the command.

Email and phone take two writes rather than one — they are verifiable
attributes, and Clerk rejects one that is on with no way to verify it, while
switching it off empties `verification_strategies`.

To make the offer answerable, the report itself now leads with **outcomes**
rather than per-field coverage: each user is classified once, into the worst
outcome that applies to them, so the ✗/⚠/✓ totals add up to the file. A
required identifier rejects a user outright; a required password does not,
because the import sends `skip_password_requirement`. The field rows below no
longer restate user counts, which read as contradicting that block.

"If you import them, this applies to them too" names what is masked behind a
rejection. A user who is not being created cannot lose a field, so a setting
affecting only rejected users costs nothing today — right up until the
requirement rejecting them is relaxed, at which point all of it lands at
once. Surfacing it up front collapses apply → re-check → discover → apply
into one decision.

Stands down with a warning rather than a failed run when the instance cannot
be resolved, and for keyless applications, whose Backend API has no route for
any of these settings.
`run` is registered `isDefault`, so `clerk migrate --transformer clerk --file
users.json` has always worked and is the shorter spelling. Every example, error
message, next-step line and README invocation now uses it. `clerk migrate run`
stays addressable — scripts and older docs use it — but nothing points there.

The group's own help follows `clerk config`: options stay on the subcommands,
and `migrate --help` is a list of subcommands plus examples covering each one.
`transformers` had no examples block at all; it does now.

Two fixes this turned up:

- A partial `CLERK_FIREBASE_*` set left in `.env.clerk-migrate` failed *every*
  subsequent run, including a Supabase one that never asked for Firebase. That
  was a regression from routing those values through the env file: previously
  only explicit flags could trigger the all-four-or-nothing check. A partial set
  that came from saved config is now warned about and ignored; a partial set
  that came from flags still fails, because there the user did ask.
- `readme.test.ts` resolved a documented command to its group and read only that
  group's options, so every `clerk migrate --transformer …` example looked like
  it used a flag the binary rejects. It now follows the default subcommand, the
  same way Commander does.

`migrate delete`'s description said "in this directory"; the record it reads has
been keyed by project since the `.settings` removal.
…rmer is firebase

`migrate run` is one command serving every platform, so a
`CLERK_FIREBASE_SIGNER_KEY` left in `.env.clerk-migrate` after a Firebase
migration was in scope for whatever ran next. A complete leftover set was
resolved and passed along on a Supabase import; a partial one failed that import
outright, naming four `--firebase-*` flags the user had not used and did not
need.

The gate now sits before the lookup rather than being a filter after it: any
transformer but `firebase` returns immediately, without reading the environment,
the env files, or even its own flags. Nothing downstream misused the value —
only the Firebase transformer reads it off `TransformContext` — but resolving it
at all is what let stale config warn and fail unrelated runs.

The previous fix only covered the partial case, and did it transformer-blind.

Moved to `lib/firebase-hash.ts` so the wizard can resolve after the platform is
picked without importing from `run.ts`, which imports the wizard. That also
keeps the interactive path: choosing Firebase with all four already set skips
the prompt, choosing anything else never looks.

The per-platform export commands need no equivalent gate — `migrate export
auth0` reads `AUTH0_*` and nothing else, because there the command *is* the
platform. `migrate run` is the only one that spans them.
`clerk migrate export` writes real user records to ./exports, and the Firebase
export is driven by a service account key people download into the checkout.
Neither belongs in the repository, and both are one `git add -A` away from it.
`setMigrationEntry` mutated the in-memory config and returned without writing
it, so nothing recorded what the last import did. `clerk migrate delete` reads
that entry to find the users to undo, and with it never written the undo path
had nothing to work from.
…clerk-secret-key

`run` was registered `isDefault`, so `clerk migrate` on its own meant "import".
That reads fine until `migrate export` sits beside it: one direction is implied
by the bare group name and the other has to be spelled out. Both are named now,
and bare `clerk migrate` prints help.

The `--clerk-secret-key` alias goes with it. It was carried over from the
standalone migration tool, but `migrate` ships new in this CLI — there is no
released spelling to stay compatible with, so there is nothing to deprecate.
Two exports of the same platform used to write the same path, so the second
silently overwrote the first. Filenames now carry a local `YYYYMMDD-HHmm`
stamp, and every export settles its destination *before* it starts — one
prompt, prefilled with the proposed path, so Enter accepts it.

Asked up front on purpose: coming back to a long export stalled on a prompt,
with every user held in memory and nothing on disk, is the worse half of that
trade. `--output` is an answer already given, and agent mode takes the
proposal without asking.
Dashboards hand out `postgres://user:[YOUR-PASSWORD]@host/db` and people paste
their real password in verbatim. A `#`, `@` or `/` in it makes the whole string
unparseable, here and later inside `Bun.SQL` — and the prompt is masked, so the
paste that failed is not even visible to check.

`normalizeConnectionString` percent-encodes the userinfo when the raw string
will not parse, splitting on the LAST `@` so an unencoded one inside the
password does not end the userinfo early. Strings that already parse are
returned untouched, so a correctly encoded password is never double-encoded.
Every other resolver in the CLI answers "where do I operate?" with the linked
project, silently. For an export that default is actively dangerous: the linked
instance is normally the migration's *destination*, so taking it without asking
is how a run exports an instance and imports it straight back into itself.

So a resolved instance is no longer taken silently — the account's instances
are offered, one flat row each (`my-app - Production instance (ins_…)`) rather
than an application picker followed by an instance picker. An application is
not what an export reads from; an instance is, and dev and prod are different
user pools. The resolved application's instances lead the list, so taking one
is still a single Enter.

`--secret-key` still names an instance outright and runs unquestioned.
`export firebase` without `--service-account` exited with a usage error, which
is a dead end in the interactive picker: choose Firebase, get told to re-run
with a flag. It now prompts, the way `export supabase` prompts for its
connection string.

The answer can be a path to the downloaded file *or* the key's JSON pasted
whole, so a key kept in a password manager or a CI secret never has to be
written to disk. Prompted as a password, since the JSON carries a private key.
Agent mode has nobody to ask, so it still names the flag.
A section heading and a leading sentence, matching how `--help` lays out its
own sections, with each description wrapped rather than run off the edge. Width
is capped at 80 columns, not merely measured, so two runs of the same command
lay out the same way on different terminals.

A backticked span is never broken across lines: `log.info` pairs backticks per
line, so a split span leaves an unmatched backtick on each and colours the
wrong half of both.

No gutter — this reads a static registry, it does not run anything — and no
dimmed text, which the descriptions are the whole point of.
`migrate import` now writes `import-<timestamp>.log` and `migrate delete`
writes `delete-<timestamp>.log`, so a listing points at the command behind
each line. The old `migration-` and `user-deletion-` names, written by the
standalone tool and earlier CLI builds, still classify and convert.

`migrate logs list` leads with the filename (what `logs convert` and `logs
clean` talk about), renders the UTC stamp in the reader's own zone, prints
the log directory relative to the cwd, and closes with a fixed legend of
every kind rather than only the ones present.
`[REDACTED]` was local to `lib/users.ts`; `clerk migrate settings` needs the
same string so a withheld credential reads identically wherever the CLI
declines to show one.
…ngs list

Firebase hands its scrypt parameters over as `base64_signer_key`, `rounds`
and friends, and every guide — Clerk's own standalone script included —
tells you to paste them into `.env` under those names. Those spellings, and
their `FIREBASE_` prefixed forms, now resolve as aliases behind the
`CLERK_FIREBASE_*` variables, read from one registry shared by the listing
and the import.

`migrate settings list` gains the orientation lines, count and next-steps
block the CLI's other listings carry, attributes an environment value to the
env file it actually came from (Bun loads `.env.local` before the CLI runs,
so "`ROUNDS` env var" named nothing the reader could edit), names the alias
alongside the file, and withholds credentials as `[REDACTED]` rather than a
head-and-tail truncation.
Bun reports every connection-level failure — DNS, refused, no route — as a
bare `Error` reading "Unable to connect. Is the computer able to access the
url?". It names neither the host nor what wanted it, and the global handler
could only render it as `unexpected_error`. Connection failures now surface
as a `CliError` naming the host, under the new `network_unreachable` code.
Everything else, an aborted request included, is left exactly as thrown.
Migration logs are the only record of which users landed and which failed,
and `migrate delete` reads them to undo a run — so where they go is worth
one question, asked before the first log file is written. `import`, `export`
and `delete` now start at `startLogging()`, which settles the directory
(`CLERK_MIGRATE_LOG_DIR`, then the saved `log-dir`, then `./logs`) and asks
a human who has chosen neither. The answer is saved under the new `log-dir`
setting, so the question is asked once per project and never again; `-y`,
agent mode and a non-TTY take `./logs` and save nothing, leaving the
question open for the first interactive run.

`logs list|clean|convert` resolve the directory without ever asking: they
are read-only, and "where should logs go?" is not a question to put in front
of someone who asked to see the logs they already have.

`log-dir` is the first setting kept in the config that also answers to an
environment variable, so `settings list` checks the environment for a config
setting too — a listing that showed the remembered path while the run read
another is the one thing the source column exists to prevent.
`clerk migrate settings clear <name>` forgets a single setting and leaves
the rest of the project alone; with no name it still clears both stores, as
before. Both stores are cleared either way, because a setting can sit in
either and `log-dir` can sit in both — clearing half of one is worse than
clearing none, since the command would report the setting gone while the
next run still read it. An `env` value goes under every spelling the setting
answers to, so dropping `CLERK_FIREBASE_ROUNDS` no longer leaves a bare
`ROUNDS` behind to win the next resolution.

`.choices()` rejects an unknown name before the action runs, so the friendly
"Unknown setting" errors inside `set.ts` and `clear.ts` were unreachable
from the CLI and a one-character miss got back only the list of eight names.
The argument's parser now names the near miss first — `logs-dir` suggests
`log-dir` — while leaving whether a value is allowed to Commander.
…dev user limit

`DEV_USER_LIMIT` was 500 and enforced: an import of more users into a
development instance was refused outright. Both halves were wrong. The limit
a development instance is created with is 100, Clerk raises it per instance
on request, and the real value (`max_allowed_users`) is served by no public
API — so the number can never be known to be this instance's, and refusing
blocked imports the destination would happily accept.

The import now reads the live user count from `GET /v1/users/count`,
measures the file against the headroom that implies, and warns when it does
not fit — naming what the instance already holds and roughly how many users
will be rejected. A human is asked whether to continue; `-y` and agent mode
proceed on the warning alone. The final prompt then restates the split
("Import 1 user and expect 1 to fail?") rather than a number the instance
will not take.

The summary's error breakdown gains notes for the two errors that read as
account-level restrictions and are not: blocked SMS countries (a per-instance
blocklist, with development instances pointed at Clerk's test numbers and
production at the Dashboard setting) and the user quota. Both messages point
at "contact support", which is the wrong first move for most readers. After a
partial import the next steps now lead with the grep that names which users
failed and why, since the breakdown only counts each error.
Without this the first complaint came from deep inside the secret-key chain,
which resolves the linked profile before it ever asks for a token — so a
signed-out operator in an unlinked directory was told to run `clerk link`, a
command that would only turn around and ask them to sign in. Both failures
landed after the wizard had already walked them through picking a platform
and a file.

`migrate import` now checks for somewhere to import *into* first, mirroring
`resolveBapiSecretKey`: `--secret-key`, `--app`, `CLERK_SECRET_KEY` and an
unclaimed accountless application each name the destination on their own. A
human gets the same sign-in-then-link flow `clerk link` already runs; an
agent, which can answer neither a browser login nor an application picker,
gets an error naming whichever half is missing.
`--db-url "libsql://app-org.turso.io"` fell through to the SQLite default
and `bun:sqlite` tried to open a local file by that name. A libsql URL now
routes to the server's HTTP pipeline endpoint instead: `bun:sqlite` only
opens local files, and `@libsql/client` ships native optional dependencies
that do not survive `bun build --compile`, so the wire protocol is fewer
lines than the dependency would be. The client reports itself as `sqlite`,
since that is the dialect — nothing downstream branches differently.

The token comes from `?authToken=` on the URL, the form the Turso CLI
prints, or from `TURSO_AUTH_TOKEN`/`LIBSQL_AUTH_TOKEN`; a self-hosted sqld
with auth disabled needs neither. Redaction covers the query parameter as
well as userinfo, so a token cannot reach an error message or `--verbose`
output, and a 401 is explained rather than left as a bare status.
@changeset-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 13c37c0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
clerk Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

await fetchInstanceSettings("sk_test_abc");

const urls = mockFetch.mock.calls.map(([input]) => String(input));
expect(urls.every((url) => !url.includes("satellite.example.com"))).toBe(true);
@royanger
royanger force-pushed the ra/integrate-migration-tool-into-cli branch from 67dda1a to 4bf8048 Compare September 11, 2026 04:15
royanger and others added 13 commits September 14, 2026 11:28
A connection string is long, pasted by hand, masked as it is typed, and
wrong in ways nothing can check until something connects: a typo'd host, an
expired token, the pooler URL where the direct one was needed, the right
server but the wrong database. Any of those ended the command, charging the
operator a full re-run — platform, log directory, output path and all — for
one mistyped line they could not see.

`supabase`, `authjs` and `betterauth` now run the database work through
`withDbRetry`, which explains the failure and puts the prompt back up. Only
the database read is inside the loop, so an export that has already written
its file cannot run twice. `-y`, agent mode and a non-TTY fail as before:
there is nobody to ask, and a loop that cannot prompt is a loop that cannot
end.

A libsql 404 is explained rather than sent to the generic advice. Turso
resolves every `*.turso.io` name, so a typo'd database answers 404 instead
of failing to connect, and "check the host" points at the half that is right.
…on string

`withDbRetry` only ever knew how to re-ask for a connection string, but the
shape it handled is not specific to databases: every credential a migration
takes is long, pasted by hand, masked as it is typed, and wrong in ways
nothing local can check. A Firebase key revoked in the console and an Auth0
application missing `read:users` both read as valid input right up until the
far end says otherwise — and both ended the command there, after the
operator had already answered every other question it asked.

It becomes `withInputRetry` in `migrate/lib/`, taking the input, a way to
ask for another, and the step that proves it. `export firebase` and
`export auth0` now run their token exchange through it, alongside the three
database exports. The helper hands back the input that finally worked, so
the rest of the export runs against that one — a Firebase export reads its
project id off the key Google accepted, not the key first offered.

Only the proving step goes inside the loop: a fetch already under way or a
file already written must not run twice. `-y`, agent mode and a non-TTY
fail as before, and a cancelled prompt leaves the loop, since declining the
question is an answer.
The hash parameters are printed inside the gutter, which prefixes every line
it is given with `│`. The command was split over four lines with backslash
continuations, so copying it took three of those bars along with it and the
shell read them as arguments:

    error: too many arguments for 'import'. Expected 0 arguments but got 3: │, │, │.

The command a user is told to run has to survive being copied, so it is one
line however long it gets. A line that wraps on screen carries no bar and
pastes back as what was printed.

Reported against a real Firebase export.
`promise-function-async` was enabled on main in #436, after this branch's
migrate work was written, and arrived here through a later merge of main. That
turned 41 call sites across the command into lint errors without any of them
changing.

Every one is the same shape: a callback handed to Commander, the API scheduler,
or a spinner/retry wrapper that returns a promise without being declared
`async`. Adding `async` is the sanctioned fix rather than a suppression —
`require-await` is deliberately left off (see .claude/rules/promises.md) so
exactly these callback shapes can satisfy the rule.

Also drops two eslint-disable directives whose violations no longer fire, which
`--report-unused-disable-directives-severity=error` counts as failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4wXePuWJHWiocgoBaAX5n
`clerk migrate export workos` pulls users out of a WorkOS tenant through the
User Management API, and `--transformer workos` reads the file back.

WorkOS is API-only: there is no bring-your-own-database option, so this has no
`--db-url` sibling. Apps commonly mirror WorkOS users into their own store
through webhooks, but that mirror is a derived copy holding no credentials.
Pagination is cursor-based, so unlike Auth0 there is no record ceiling.

No credential survives the move, and the run says so rather than leaving it to
be found when nobody can sign in. WorkOS accepts password hashes on import and
never returns them, and `totp.secret` comes back on enrol only. The transformer
therefore names no `passwordHasher` — every other one names its platform's
hasher, but there is no digest here to verify, and naming one would imply a
column that cannot exist. Users are created with `skip_password_requirement`
and no password credential at all, which is safer than a synthetic one: a
placeholder digest is a real, working credential on every migrated account. The
coverage table carries a permanent `0/N have a password` row so the gap is
visible before the import, not after.

`--with-identities` is off by default because WorkOS has no bulk endpoint for
OAuth identities — it is one request per user, turning ten requests into 1,010
for a thousand users, and Clerk's import has no external-accounts field to put
the result in. The interactive path asks once, after the user count is known,
so the question names the real cost. The breakdown prints as its own OAuth
providers block rather than as coverage rows: a coverage row means "N of the M
users have this field", while one user can hold two providers and `not
readable` is not a property of the user at all. A failed lookup is counted on
its own row rather than folded into `no OAuth provider`, since flattening the
two would understate social sign-in.

Non-interactive runs get progress on stderr during both fetches. `withSpinner`
hands a no-op to anything that is not a TTY, so an agent exporting a large
tenant would otherwise see nothing until the run finished.

Covered by the existing `clerk migrate` changeset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4wXePuWJHWiocgoBaAX5n
`9a56d9f docs(migrate): mention migration command` added the row with a
truncated description, so `readme.test.ts` failed comparing the committed help
block against `clerk --help`. The command's own description is the longer form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4wXePuWJHWiocgoBaAX5n
Four places where the documentation had drifted from the code.

`export clerk`'s own help example was `--instance prod --output
prod-users.json`, described as "Export a specific instance to a chosen
path" — which reads as a run that does not prompt. It does prompt:
`resolveSource` treats only `--secret-key` as a choice the user typed, so
`--app` and `--instance` decide whose instances lead the picker and
nothing more. The example now shows `--secret-key`, which is the flag
that actually skips it, and the README says so in the bullet rather than
leaving it to be discovered.

The README also claimed the flat instance picker opens when there is
nothing to resolve at all — no link, no key, no flags. That tier falls
through to `resolveUsersInstanceContext({})`, the application picker
`users list` uses, which offers "create a new application" as well.
`clerk-source.ts`'s own header comment said the same thing, so both are
corrected; leaving one would re-seed the other.

And two counts: the transformers listing example still showed six
built-ins and no WorkOS row, and the settings example showed four of
seven settings when there are eight, omitting three rows the command
always prints.

Covered by the existing `clerk migrate` changeset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4wXePuWJHWiocgoBaAX5n
`-y` did not exist on any `migrate export` subcommand, and `ensureLogDir`
never consulted it — yet the README and both functions' own doc comments
claimed it suppressed the credential-retry loop and took the default log
directory. Three prompts could still stop a run that had asked not to be
asked.

`-y` is not the same question as agent mode. Agent mode says the CLI
*cannot* prompt; `-y` says the operator does not want it to. A confirm is
skipped by either, but the two places that take a default instead of
asking need to know a human chose it, so they cannot be collapsed.

It is held per-run in `lib/assume-yes.ts` and set by a `preAction` hook on
the `migrate` group, mirroring how `mode.ts` resolves `--mode` once and is
read everywhere. The readers sit three layers below the parse: the
log-directory question runs inside the gutter of seven commands and the
retry loop under every credential prompt, so threading a `yes` parameter
down would have put one on every export handler signature on the way.
Hooks are inherited, so a subcommand that declares no `-y` resolves to
false rather than to nothing.

The three prompts do not all answer it the same way:

- **A rejected credential** fails on the first attempt instead of
  re-asking.
- **The log directory** takes `./logs` without asking and without saving.
  Landing on a default is not a choice, and recording one would retire the
  question for a human who never saw it.
- **The export path** fails, naming `--output` and handing back the whole
  command with the proposed path already in it. It is the one prompt whose
  default cannot be undone by running the command again: a file written
  where nobody chose it has to be found and moved, and the second run
  writes a second copy. Guessing is the expensive answer here, so it does
  not guess.

Agent mode keeps defaulting on that last one even when it also passes
`-y` — there was never a prompt on that path to suppress, and agents pass
`-y` reflexively, so erroring would turn working automation into a usage
failure for nothing.

The hook is the only link between the flag and its readers, and every
unit test around them sets the flag directly, so a hook that stopped
firing would revert all three behaviours with the suite still green.
`index.test.ts` therefore parses real argv through the real program and
asserts the flag both sets and clears.

Covered by the existing `clerk migrate` changeset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4wXePuWJHWiocgoBaAX5n
`resolveSource` treated only `--secret-key` as a choice the user had
made, so `--app`, `--instance` and an exported `CLERK_SECRET_KEY` all
opened the instance picker anyway — and then overrode whatever they had
resolved with the picker's answer. Two consequences, both wrong.

`clerk migrate export clerk` could not be scripted outside agent mode.
Every run stopped at a prompt no flag could pre-answer, which is not a
reasonable ask of an export that may be the slow half of a migration
runbook.

And it made this the one command in the family where exporting
`CLERK_SECRET_KEY` did less than not exporting it. `resolveBapiSecretKey`
puts an exported key above the linked profile everywhere else, with a
comment saying so; here the key was resolved and then discarded.

The distinction the picker exists for is not "did the CLI look anything
up" but "did the user say which instance". A flag or an exported key is a
sentence typed for this run; the linked profile is a choice made for some
other purpose, and normally names the migration's destination rather than
its source. Only the second is worth asking about, and the catch branch
below already drew the line in exactly that place — `named` is that
predicate, hoisted so both branches read it.

`--instance` counts on its own: with the linked application it names one
instance, and with `--app` it addresses any instance on the account.

Covered by the existing `clerk migrate` changeset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4wXePuWJHWiocgoBaAX5n
…asked

`clerk migrate settings clear` forgets the whole project entry and every
credential in `.env.clerk-migrate`. In agent mode or off a TTY it did
both with no prompt and no flag, while `migrate logs clean` and `migrate
delete` — the other two destructive commands in this tree — refuse
outright without `-y`.

Of the three it is the one that destroys secrets, and the only one whose
loss cannot be recovered from a log. Reading silence as consent is the
one interpretation that cannot be walked back, so it now refuses the same
way, naming the file it would have emptied.

`settings clear <name>` is left as it was: naming the single setting to
forget is itself the confirmation, the same way `settings set` needs
none.

Covered by the existing `clerk migrate` changeset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4wXePuWJHWiocgoBaAX5n
The root `build` script only forwarded to cli-core's `build`, which emitted a
plain `dist/cli.js` bundle nothing consumed — the published artifact is the
compiled binary from `build:compile`. CI now runs `build:compile` directly, so
the bundle step no longer runs at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@royanger
royanger force-pushed the ra/integrate-migration-tool-into-cli branch from 8e31d52 to 3030f79 Compare September 18, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants