Skip to content

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

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

royanger wants to merge 33 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: 4bf8048

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
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