Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
82d8552
feat(migrate): port clerk migrate import, its README and E2E from #47…
royanger Oct 6, 2026
b5d6d16
feat(migrate): trim clerk migrate import, its README and E2E to slice 1
royanger Oct 6, 2026
4ef5760
docs(changeset): Add an experimental clerk migrate import for Clerk a…
royanger Oct 6, 2026
234b375
test(migrate): expect an unknown hasher to be rejected, not abort the…
royanger Oct 6, 2026
dfe6ccb
test(migrate): restore the interactive tests' mode instead of forcing it
royanger Oct 6, 2026
5bf0176
docs(migrate): match the import's README and tests to the provider an…
royanger Oct 8, 2026
d97aca5
fix(migrate): filter --require-password's users with a Set, not a scan
royanger Oct 8, 2026
d94780f
fix(migrate): do not ask about legal acceptance under --yes
royanger Oct 8, 2026
660f1a3
fix(migrate): count --require-password's left-out users in the --json…
royanger Oct 8, 2026
4428b26
test(migrate): register the e2e import's users for cleanup before ass…
royanger Oct 8, 2026
1488da9
fix(migrate): sign in for an --app import instead of failing on the key
royanger Oct 8, 2026
cdb4add
docs(migrate): describe the -y holder as it is in the import slice
royanger Oct 8, 2026
2c63369
test(migrate): match the import's tests and README to the stricter ch…
royanger Oct 8, 2026
5491e79
fix(migrate): leave an interrupted import unfinished, and count users…
royanger Oct 8, 2026
dbcc32c
fix(migrate): report the users imported without the phone Clerk refused
royanger Oct 8, 2026
9b7990f
test(migrate): prove an import without --yes writes nothing to a real…
royanger Oct 8, 2026
14dc3a4
docs(migrate): say production has no user limit by default, not none …
royanger Oct 8, 2026
19057b3
fix(migrate): sign in for --app even when CLERK_SECRET_KEY is set
royanger Oct 8, 2026
b505a68
docs(migrate): say unknown fields are warned about, and use sentence …
royanger Oct 8, 2026
acb00ed
fix(migrate): name the users an import would skip when asking to go a…
royanger Oct 8, 2026
eaf8a7f
docs(migrate): say retries and refusals land in a line's error field
royanger Oct 9, 2026
db27ec6
test(migrate): cover the --json outcomes that stop before a run exists
royanger Oct 9, 2026
243cd5d
fix(migrate): say why an import stopped in its summary, after the pro…
royanger Oct 9, 2026
1c5558f
fix(migrate): print the run line on a Ctrl-C and close the gutter as …
royanger Oct 9, 2026
eb03061
fix(migrate): save the users a stopped import never sent in run.json'…
royanger Oct 9, 2026
51a39d8
fix(migrate): split the consent refusal into what the import would do…
royanger Oct 9, 2026
a318a1a
docs(migrate): document the Not sent count, how to send those users, …
royanger Oct 9, 2026
e8a2698
docs(migrate): document the phones numeric usernames make Clerk refuse
royanger Oct 9, 2026
b5c2466
fix(migrate): print the run ID and folder as the import starts, so a …
royanger Oct 9, 2026
74deef7
fix(migrate): say how to send the users a quota stop left, without re…
royanger Oct 9, 2026
be6c7fe
docs(migrate): bring the username and bcrypt cost check bullets in li…
royanger Oct 9, 2026
755dad8
test(migrate): keep an inherited runs-dir override out of the Ctrl-C …
royanger Oct 9, 2026
248da9b
docs(migrate): say only an import that gets as far as writing is a run
royanger Oct 9, 2026
0e75219
docs(migrate): say only an empty file writes no run among the imports…
royanger Oct 9, 2026
b4d0d40
docs(migrate): say how the E.164 checks treat trimmed phones, phones …
royanger Oct 13, 2026
3286db9
fix(migrate): say why users would be rejected in the refusal itself
royanger Oct 13, 2026
ba510ec
fix(migrate): carry a yes at the legal prompt into the suggested comm…
royanger Oct 13, 2026
c352fd9
fix(migrate): check --source before signing in
royanger Oct 13, 2026
3535cbf
fix(migrate): let an explicit --mode win over --json's agent mode
royanger Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/migrate-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Add an experimental `clerk migrate import` that checks a Clerk or Supabase user export against your instance and imports it. It is hidden and refuses to run unless `CLERK_EXPERIMENTAL=migrate` is set.
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/completion/__complete.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CommandUnknownOpts, Option } from "@commander-js/extra-typings";
import { sources } from "../migrate/sources/registry.ts";
import { KNOWN_DASHBOARD_PATHS } from "../open/dashboard-paths.ts";

const DIRECTIVE = {
Expand Down Expand Up @@ -33,6 +34,12 @@ const HTTP_METHOD_COMPLETIONS: Completion[] = [
{ name: "DELETE", description: "Delete resource" },
];

/** The built-in migrate sources. Kept off `.choices()` so an unknown key gets the source error. */
const SOURCE_COMPLETIONS: Completion[] = sources.map((entry) => ({
name: entry.key,
description: entry.label,
}));

/**
* Hardcoded option-value completions for options that don't use Commander's `.choices()`.
* Keys are the long or short flag (e.g., "--mode", "-X").
Expand All @@ -52,6 +59,7 @@ const KNOWN_OPTION_VALUES: Record<string, Completion[]> = {
{ name: "latest", description: "Latest stable release" },
{ name: "canary", description: "Latest canary (pre-release) build" },
],
"--source": SOURCE_COMPLETIONS,
"--for": [
{ name: "orgs", description: "Organizations only" },
{ name: "users", description: "Users only" },
Expand Down
548 changes: 545 additions & 3 deletions packages/cli-core/src/commands/migrate/README.md

Large diffs are not rendered by default.

147 changes: 147 additions & 0 deletions packages/cli-core/src/commands/migrate/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getMode, setMode } from "../../mode.ts";
import { createProgram } from "../../cli-program.ts";
import { isAssumeYes, setAssumeYes } from "./lib/assume-yes.ts";

let saved: string | undefined;
beforeAll(() => {
saved = process.env.CLERK_EXPERIMENTAL;
process.env.CLERK_EXPERIMENTAL = "migrate";
});
afterAll(() => {
if (saved === undefined) delete process.env.CLERK_EXPERIMENTAL;
else process.env.CLERK_EXPERIMENTAL = saved;
});

function findCommand(names: string[]) {
let current = createProgram().commands.find((cmd) => cmd.name() === names[0]);
for (const name of names.slice(1)) {
current = current?.commands.find((cmd) => cmd.name() === name);
}
return current;
}

describe("registerMigrate", () => {
test("registers migrate as a top-level command group", () => {
const migrate = findCommand(["migrate"]);
expect(migrate).toBeDefined();
expect(migrate?.description()).toContain("Migrate users");
});

test("registers import as its only subcommand", () => {
expect(findCommand(["migrate"])?.commands.map((cmd) => cmd.name())).toEqual(["import"]);
});

test("leaves migrate with no default subcommand", () => {
const migrate = findCommand(["migrate"]) as unknown as { _defaultCommandName?: string };
expect(migrate._defaultCommandName).toBeFalsy();
});

test("migrate import takes exactly its flags", () => {
expect(findCommand(["migrate", "import"])?.options.map((option) => option.long)).toEqual([
"--source",
"--dry-run",
"--allow-partial",
"--require-password",
"--skip-legal-checks",
"--yes",
"--json",
"--secret-key",
"--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", () => {
const option = findCommand(["migrate", "import"])?.options.find((o) => o.long === "--source");
expect(option?.argChoices).toBeUndefined();
});

test("takes the file as an optional argument", () => {
const [argument] = findCommand(["migrate", "import"])?.registeredArguments ?? [];
expect(argument?.name()).toBe("file");
expect(argument?.required).toBe(false);
});

test("exposes -y as the short form of --yes", () => {
const option = findCommand(["migrate", "import"])?.options.find((o) => o.long === "--yes");
expect(option?.short).toBe("-y");
});
});

/**
* The hook is the only link between the parsed flag and the code that reads
* it below the action. If it stopped firing — a Commander upgrade that dropped
* hook inheritance, an action registered outside the group — the behaviour
* would silently revert and every unit test around it would still pass,
* because they set the flag directly.
*/
describe("the migrate group's -y hook", () => {
let missing: string;
beforeAll(() => {
missing = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-hook-")), "none.json");
});

async function parse(argv: string[]) {
const program = createProgram();
// `exitOverride` so a usage error inside the action throws here instead of
// taking the test runner down with it; the hook has already run by then.
program.exitOverride();
try {
await program.parseAsync(["node", "clerk", ...argv]);
} catch {
// The action is allowed to fail — only the hook's effect is under test.
}
return isAssumeYes();
}

test("records -y on import", async () => {
expect(await parse(["migrate", "import", missing, "-y", "--secret-key", "sk_test_x"])).toBe(
true,
);
});

// `--json` means nobody reads a prompt, and agent mode is how every prompt in
// this tree already knows to stand down.
test("--json runs the command in agent mode", async () => {
const original = getMode();
try {
setMode("human");
await parse(["migrate", "import", missing, "--json", "--secret-key", "sk_test_x"]);
expect(getMode()).toBe("agent");
} finally {
setMode(original);
}
});

test("an explicit --mode wins over --json", async () => {
const original = getMode();
try {
setMode("agent");
await parse([
"--mode",
"human",
"migrate",
"import",
missing,
"--json",
"--secret-key",
"sk_test_x",
]);
expect(getMode()).toBe("human");
} finally {
setMode(original);
}
});

test("records its absence, so a previous run cannot leak into this one", async () => {
setAssumeYes(true);
expect(await parse(["migrate", "import", missing, "--secret-key", "sk_test_x"])).toBe(false);
});
});
71 changes: 69 additions & 2 deletions packages/cli-core/src/commands/migrate/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { Program } from "../../cli-program.ts";
import { isExperimentEnabled, requireExperiment } from "../../lib/experimental.ts";
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";

export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = process.env): void {
if (!isExperimentEnabled("migrate", env)) {
Expand All @@ -22,7 +26,70 @@ export function registerMigrate(program: Program, env: NodeJS.ProcessEnv = proce
return;
}

program
const migrateCommand = program
.command("migrate")
.description("Migrate users into Clerk from another auth provider or another Clerk instance");
.description("Migrate users into Clerk from another auth provider or another Clerk instance")
.setExamples([
{
command: "clerk migrate import users.json --source supabase --dry-run",
description: "Check a Supabase export against the instance, and write nothing",
},
{
command: "clerk migrate import users.json --source supabase --yes",
description: "Import it",
},
]);

// `-y` is resolved once here, for code below a command's options to read
// rather than having it passed down. Hooks are inherited, so this fires for
// every subcommand under `migrate`; one that declares no `-y` resolves to false.
//
// `--json` means nobody is reading a prompt, so it runs the command in agent
// mode: every prompt in this tree already stands down for an agent, with the
// usage error naming what to pass instead. An explicit `--mode` still wins.
migrateCommand.hook("preAction", (_thisCommand, actionCommand) => {
const opts = actionCommand.optsWithGlobals();
setAssumeYes(Boolean(opts.yes));
if (opts.json && !opts.mode) setMode("agent");
});

// Named, not `isDefault`: bare `clerk migrate` prints help.
//
// The flags stay here rather than on `migrate`, matching how `config` keeps
// its own on `pull`/`patch`/`put` — a group's help is a list of subcommands
// and examples, not a merge of everything underneath it.
migrateCommand
.command("import")
.description("Import users from an exported JSON or CSV file")
.argument("[file]", "A JSON or CSV export")
.option("--source <key>", "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("--require-password", "Import only users that have a password")
.option(
"--skip-legal-checks",
"Import users with no legal acceptance on record into an instance that requires it",
)
.option("-y, --yes", "Import without prompting")
.option("--json", "Output as JSON; never prompts, so pair it with --yes to import")
.option("--secret-key <key>", "Backend API secret key to use")
.option("--app <id>", "Application ID to target (works from any directory)")
.option("--instance <id>", "Instance to target (dev, prod, or a full instance ID)")
.option(RUNS_DIR_FLAG, RUNS_DIR_DESCRIPTION)
.setExamples([
{
command: "clerk migrate import users.json --source supabase --dry-run",
description: "Check the file against the instance, and write nothing",
},
{
command: "clerk migrate import users.json --source clerk --allow-partial --yes",
description: "Import a Clerk Dashboard export, skipping users that would be rejected",
},
])
.action(async (input, _opts, cmd) =>
run({
...(cmd.optsWithGlobals() as Parameters<typeof run>[0]),
...(input ? { input } : {}),
}),
);
}
24 changes: 24 additions & 0 deletions packages/cli-core/src/commands/migrate/lib/assume-yes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Whether this run was given `-y`.
*
* Not the same question as {@link isAgent}: agent mode says the CLI *cannot*
* prompt; `-y` says the operator does not want it to.
*
* Set once by the `migrate` group's `preAction` hook, so every subcommand under
* it is covered whether or not it declares the flag — one that does not simply
* resolves to `false`. Held per run, the way `mode.ts` holds `--mode`, so code
* below a command's options can read it without the flag being passed down.
* Nothing in `import` needs that: it reads `options.yes` directly.
*/

let assumeYes = false;

/** Records this run's `-y`. Called once per invocation, before the action. */
export function setAssumeYes(value: boolean): void {
assumeYes = value;
}

/** Whether `-y` was passed to the command now running. */
export function isAssumeYes(): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW]

isAssumeYes() is only called from index.test.ts. Nothing in this PR reads it, and the doc comment justifies it with withInputRetry and export commands that don't exist in the repo. The preAction hook therefore maintains global state that nothing uses, and the comment describes behavior that isn't there. Either move this module and the hook into the slice that adds its readers, or reduce the comment to what is true today.

Sent from Claude

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9c7de0f (describe the -y holder as it is in the import slice). Both comments now say only what holds in this slice. The fuller explanation comes back in #524, where withInputRetry starts reading it.

return assumeYes;
}
21 changes: 21 additions & 0 deletions packages/cli-core/src/commands/migrate/lib/run-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,27 @@ describe("locks", () => {
});

// In case a folder's mode is ever looser than the run store sets it.
// Users never sent have no line to count, so the caller says how many.
test("a finish with users never sent is partial", () => {
const sent = startRun(runsDir, init);
sent.append({ sourceId: "a", status: "created", clerkId: "user_a" });
expect(sent.finish().status).toBe("complete");

const stopped = startRun(runsDir, init);
stopped.append({ sourceId: "a", status: "created", clerkId: "user_a" });
const record = stopped.finish({ notSent: 2 });
expect(record.status).toBe("partial");
// Saved, so a later reader can tell how many were never sent.
expect(readRun(runsDir, record.id)?.counts).toEqual({ total: 1, created: 1, notSent: 2 });
});

test("release leaves the run unfinished and unlocked", () => {
const run = startRun(runsDir, init);
run.release();
expect(readRun(runsDir, run.record.id)?.finishedAt).toBeUndefined();
expect(fs.existsSync(path.join(run.dir, "lock"))).toBe(false);
});

test.skipIf(process.platform === "win32")("run files are owner-only", () => {
const run = startRun(runsDir, init);
run.append({ sourceId: "u1", status: "creating" });
Expand Down
29 changes: 22 additions & 7 deletions packages/cli-core/src/commands/migrate/lib/run-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export type RunTarget = {

export type RunFile = { path: string; sha256: string };

export type RunCounts = Partial<Record<UserStatus, number>> & { total: number };
/** `notSent`: users a stopped import never sent, so they have no line. */
export type RunCounts = Partial<Record<UserStatus, number>> & { total: number; notSent?: number };

export type RunRecord = {
id: string;
Expand Down Expand Up @@ -312,10 +313,16 @@ export type Run = {
/**
* Counts the outcomes, settles the status and releases the lock.
*
* `partial` when any user failed, was skipped, may not have been created or
* has identifiers still pending, `complete` otherwise.
* `partial` when any user failed, was skipped, may not have been created,
* was never sent (`notSent`: they have no line to count) or has identifiers
* still pending, `complete` otherwise.
*/
finish(): RunRecord;
finish(options?: { notSent?: number }): RunRecord;
/**
* Releases the lock and leaves the run unfinished: with no `finishedAt` and
* no live holder, it reads as interrupted.
*/
release(): void;
};

function openRun(runsDir: string, record: RunRecord): Run {
Expand All @@ -336,23 +343,31 @@ function openRun(runsDir: string, record: RunRecord): Run {
run.record = { ...run.record, ...patch };
writeRecord(runsDir, run.record);
},
finish() {
finish(options) {
const lines = [...latestUserLines(runsDir, run.record.id).values()];
const counts = countLines(lines);
if (options?.notSent) counts.notSent = options.notSent;
// A created user whose extra identifiers never attached is left for a continue.
const pendingAttaches = lines.filter(
(line) => line.status === "created" && (line.pending?.length ?? 0) > 0,
).length;
const unfinished =
(counts.failed ?? 0) + (counts.skipped ?? 0) + (counts.creating ?? 0) + pendingAttaches;
(counts.failed ?? 0) +
(counts.skipped ?? 0) +
(counts.creating ?? 0) +
(counts.notSent ?? 0) +
pendingAttaches;
run.update({
counts,
status: unfinished > 0 ? "partial" : "complete",
finishedAt: new Date().toISOString(),
});
fs.rmSync(path.join(dir, LOCK_FILE), { force: true });
run.release();
return run.record;
},
release() {
fs.rmSync(path.join(dir, LOCK_FILE), { force: true });
},
};
return run;
}
Expand Down
Loading
Loading