Repository navigation
feat(migrate): add experimental clerk migrate import for Clerk and Supabase files #520
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 b5d6d16
feat(migrate): trim clerk migrate import, its README and E2E to slice 1
royanger 4ef5760
docs(changeset): Add an experimental clerk migrate import for Clerk a…
royanger 234b375
test(migrate): expect an unknown hasher to be rejected, not abort the…
royanger dfe6ccb
test(migrate): restore the interactive tests' mode instead of forcing it
royanger 5bf0176
docs(migrate): match the import's README and tests to the provider an…
royanger d97aca5
fix(migrate): filter --require-password's users with a Set, not a scan
royanger d94780f
fix(migrate): do not ask about legal acceptance under --yes
royanger 660f1a3
fix(migrate): count --require-password's left-out users in the --json…
royanger 4428b26
test(migrate): register the e2e import's users for cleanup before ass…
royanger 1488da9
fix(migrate): sign in for an --app import instead of failing on the key
royanger cdb4add
docs(migrate): describe the -y holder as it is in the import slice
royanger 2c63369
test(migrate): match the import's tests and README to the stricter ch…
royanger 5491e79
fix(migrate): leave an interrupted import unfinished, and count users…
royanger dbcc32c
fix(migrate): report the users imported without the phone Clerk refused
royanger 9b7990f
test(migrate): prove an import without --yes writes nothing to a real…
royanger 14dc3a4
docs(migrate): say production has no user limit by default, not none …
royanger 19057b3
fix(migrate): sign in for --app even when CLERK_SECRET_KEY is set
royanger b505a68
docs(migrate): say unknown fields are warned about, and use sentence …
royanger acb00ed
fix(migrate): name the users an import would skip when asking to go a…
royanger eaf8a7f
docs(migrate): say retries and refusals land in a line's error field
royanger db27ec6
test(migrate): cover the --json outcomes that stop before a run exists
royanger 243cd5d
fix(migrate): say why an import stopped in its summary, after the pro…
royanger 1c5558f
fix(migrate): print the run line on a Ctrl-C and close the gutter as …
royanger eb03061
fix(migrate): save the users a stopped import never sent in run.json'…
royanger 51a39d8
fix(migrate): split the consent refusal into what the import would do…
royanger a318a1a
docs(migrate): document the Not sent count, how to send those users, …
royanger e8a2698
docs(migrate): document the phones numeric usernames make Clerk refuse
royanger b5c2466
fix(migrate): print the run ID and folder as the import starts, so a …
royanger 74deef7
fix(migrate): say how to send the users a quota stop left, without re…
royanger be6c7fe
docs(migrate): bring the username and bcrypt cost check bullets in li…
royanger 755dad8
test(migrate): keep an inherited runs-dir override out of the Ctrl-C …
royanger 248da9b
docs(migrate): say only an import that gets as far as writing is a run
royanger 0e75219
docs(migrate): say only an empty file writes no run among the imports…
royanger b4d0d40
docs(migrate): say how the E.164 checks treat trimmed phones, phones …
royanger 3286db9
fix(migrate): say why users would be rejected in the refusal itself
royanger ba510ec
fix(migrate): carry a yes at the legal prompt into the suggested comm…
royanger c352fd9
fix(migrate): check --source before signing in
royanger 3535cbf
fix(migrate): let an explicit --mode win over --json's agent mode
royanger File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
| return assumeYes; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 fromindex.test.ts. Nothing in this PR reads it, and the doc comment justifies it withwithInputRetryand 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
There was a problem hiding this comment.
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
withInputRetrystarts reading it.