Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
8a7f608
feat(migrate): add custom sources and migrate sources
royanger Oct 6, 2026
8c781dd
docs(changeset): Add clerk migrate sources and custom sources
royanger Oct 6, 2026
e3cd071
docs(migrate): list both hook validation errors for a custom source
royanger Oct 7, 2026
d0096a5
fix(migrate): refuse a second import while one of the same file is live
royanger Oct 9, 2026
fc671e3
fix(migrate): say --source takes a key or a path in the sources listing
royanger Oct 9, 2026
3bb0fd2
fix(migrate): hold an exclusive lock for an import of a file, from be…
royanger Oct 9, 2026
5d60274
fix(migrate): load a custom source by the hash a run records for it
royanger Oct 9, 2026
3969ed4
docs(migrate): say a custom source's hash covers its own file only
royanger Oct 9, 2026
72b60c5
fix(migrate): link a lock into place whole, so no reader sees it with…
royanger Oct 9, 2026
c1d8c6d
docs(migrate): say a custom source can import its own files, but not …
royanger Oct 9, 2026
952c9df
fix(migrate): key the import lock by the secret key, so a failed inst…
royanger Oct 9, 2026
f8cce65
fix(migrate): lock an import by the instance too, so a second key for…
royanger Oct 10, 2026
57cd24f
fix(migrate): refuse a custom source whose caveats are not a list of …
royanger Oct 14, 2026
370f9f6
fix(migrate): refuse an edited custom source beside its earlier run, …
royanger Oct 14, 2026
fc9c896
test(migrate): let the lock-linking test pass where hard links are re…
royanger Oct 14, 2026
2f1a0d2
feat(migrate): let a source you wrote replace an export file's own
royanger Oct 15, 2026
cb61df9
fix(migrate): refuse an edited custom source while any earlier versio…
royanger Oct 15, 2026
21eb5fe
fix(migrate): match a file's earlier imports by file and instance, an…
royanger Oct 15, 2026
b474a9b
fix(migrate): check other unfinished runs behind an undone exact matc…
royanger Oct 16, 2026
06cbbc0
fix(migrate): name an earlier run of any source when a refusal meets …
royanger Oct 16, 2026
a8455e1
fix(migrate): include a source's fixed defaults in `sources <source> …
royanger Oct 21, 2026
085c9d5
docs(migrate): say a Ctrl-C can leave the import lock behind
royanger Oct 22, 2026
f9e3c1e
fix(migrate): point source authors at the Custom sources docs
royanger Oct 22, 2026
72ac1d0
fix(migrate): reject an async postTransform instead of losing its cha…
royanger Oct 22, 2026
ef61c13
fix(migrate): name the source and row when postTransform throws
royanger Oct 22, 2026
e5cc22b
docs(migrate): write out the custom source hooks' contract
royanger Oct 22, 2026
26a14ed
fix(migrate): mark field targets that aren't Clerk fields in `sources`
royanger Oct 22, 2026
28aa6c7
fix(migrate): report a directory passed as --source as a usage error
royanger Oct 22, 2026
bf91117
fix(migrate): reject any thenable a postTransform returns
royanger Oct 22, 2026
956763a
fix(migrate): keep a postTransform's own error and code
royanger Oct 23, 2026
ea768f2
fix(migrate): say a built-in source handles a field it doesn't store
royanger Oct 23, 2026
22d464e
fix(migrate): keep file completion for a custom source path
royanger Oct 27, 2026
2d20eb3
fix(completion): let fish complete files when the directive allows it
royanger Oct 27, 2026
4cc8312
fix(migrate): check Supabase providers under a source you wrote
royanger Oct 27, 2026
f732892
fix(migrate): count an export's social providers under a source you w…
royanger Oct 27, 2026
3e7df67
fix(migrate): skip the provider fallback for a platform this CLI does…
royanger Oct 28, 2026
eb28fa6
docs(migrate): say the import lock follows the key when the instance …
royanger Oct 28, 2026
220a924
docs(migrate): say two imports can both reclaim a stale import lock
royanger Oct 28, 2026
5672983
docs(migrate): say when an import holds only its key's lock
royanger Oct 28, 2026
f4b5fff
docs(migrate): say the custom-source example's email is created verified
royanger Oct 28, 2026
d2b969c
fix(migrate): let a preTransform parse a JSON file the CLI can't
royanger Oct 28, 2026
dcf656f
docs(migrate): say a preTransform's file keeps the import's format
royanger Oct 28, 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-custom-sources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Add `clerk migrate sources`, and let `clerk migrate import --source` take the path to a source you wrote. `clerk migrate sources` lists the built-in sources with the passwords, MFA and metadata each brings across, and `clerk migrate sources <source>` shows where each field lands, how to export, and any caveats. A source you wrote is a `.ts` or `.js` file in your project, checked before use, so a platform with no built-in source can be imported without rebuilding the CLI. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`.
28 changes: 22 additions & 6 deletions packages/cli-core/src/commands/completion/__complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ function noFileComp(completions: Completion[]): CompletionResult {
return { completions, directive: DIRECTIVE.NO_FILE_COMP };
}

/** The known values that start with `partial`, from a registry entry. */
function matching(known: Completion[], partial: string): CompletionResult {
const completions = known.filter((c) => c.name.startsWith(partial));
// A migrate source can be a path to one you wrote, so the shell still
// completes files beside the built-in keys.
return known === SOURCE_COMPLETIONS
? { completions, directive: DIRECTIVE.DEFAULT }
: noFileComp(completions);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const HTTP_METHOD_COMPLETIONS: Completion[] = [
{ name: "GET", description: "Read resource" },
{ name: "POST", description: "Create resource" },
Expand All @@ -36,7 +46,10 @@ 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. */
/**
* The built-in migrate sources. `--source` also takes a path to a source you
* wrote, so it cannot use `.choices()`.
*/
const SOURCE_COMPLETIONS: Completion[] = sources.map((entry) => ({
name: entry.key,
description: entry.label,
Expand Down Expand Up @@ -86,6 +99,7 @@ const KNOWN_OPTION_VALUES: Record<string, Completion[]> = {
* to root, e.g. "open dashboard".
*/
const KNOWN_POSITIONAL_COMPLETIONS: Record<string, Completion[]> = {
"migrate sources": SOURCE_COMPLETIONS,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
"open dashboard": KNOWN_DASHBOARD_PATHS.map((path) => ({
name: path,
description: "Dashboard subpath",
Expand Down Expand Up @@ -130,13 +144,15 @@ export function generateCompletions(root: CommandUnknownOpts, args: string[]): C
}

// Combine subcommands + argument choices + options
const argument = completeArguments(command, partial, positionalCount);
const completions = [
...completeSubcommands(command, partial).completions,
...completeArguments(command, partial, positionalCount).completions,
...argument.completions,
...completeOptions(command, "", usedOptions).completions,
];

return noFileComp(completions);
// The argument decides file completion: on only for one that can be a path.
return { completions, directive: argument.directive };
}

// ── Tree walking ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -254,10 +270,10 @@ function completeArguments(
const path = commandPath(cmd);
const registered = KNOWN_POSITIONAL_COMPLETIONS[path];
if (registered) {
return noFileComp(registered.filter((c) => c.name.startsWith(partial)));
return matching(registered, partial);
}

return EMPTY_DEFAULT;
return EMPTY_NO_FILE;
}

function commandPath(cmd: CommandUnknownOpts): string {
Expand Down Expand Up @@ -290,7 +306,7 @@ function completeOptionValue(opt: Option, flag: string, partial: string): Comple
const candidates = resolveOptionValues(opt, flag);
if (!candidates) return EMPTY_DEFAULT;

return noFileComp(candidates.filter((c) => c.name.startsWith(partial)));
return matching(candidates, partial);
}

function resolveOptionValues(opt: Option, flag: string): Completion[] | undefined {
Expand Down
11 changes: 11 additions & 0 deletions packages/cli-core/src/commands/completion/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ describe("shell script generators", () => {
expect(generateFish(BINARY)).toContain(`complete -c ${BINARY}`);
});

test("fish reads the directive, and turns off files only when the candidates apply", () => {
const script = generateFish(BINARY);
// An unconditional -f would turn off file completion for every command.
expect(script).not.toContain(`complete -c ${BINARY} -f -a`);
expect(script).toContain(
`complete -c ${BINARY} -n '__${BINARY}_prepare_completions' -f -a '$__${BINARY}_comp_results'`,
);
// Bit 4 is NO_FILE_COMP.
expect(script).toContain("math --scale 0 $directive / 4 % 2");
});

test("fish banner instructs users to mkdir ~/.config/fish/completions before writing", () => {
const script = generateFish(BINARY);
const mkdirIdx = script.indexOf("mkdir -p ~/.config/fish/completions");
Expand Down
29 changes: 16 additions & 13 deletions packages/cli-core/src/commands/completion/shells/fish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,34 @@ export function generate(binaryName: string): string {
# mkdir -p ~/.config/fish/completions
# ${binaryName} completion fish > ~/.config/fish/completions/${binaryName}.fish

function __${binaryName}_complete
function __${binaryName}_prepare_completions
set -g __${binaryName}_comp_results
set -l tokens (commandline -opc)
set -l current (commandline -ct)

# Remove the command name, pass the rest + current token to __complete
set -l args $tokens[2..]
set -l output (${binaryName} __complete $args $current 2>/dev/null)

if test $status -ne 0
return
if test $status -ne 0; or test (count $output) -eq 0
return 0
end

# Last line is the directive — skip it
set -l count (count $output)
if test $count -le 1
return
end
# Last line is the directive; the rest are the candidates
set -l directive (string replace ':' '' -- $output[-1])
set -e output[-1]
set -g __${binaryName}_comp_results $output

# Output all lines except the last (directive)
for i in (seq 1 (math $count - 1))
echo $output[$i]
# Nothing starts with what was typed, and the directive allows files (bit 4,
# NO_FILE_COMP, clear): false skips the entry below, so fish completes files.
set -l names (string replace -r '\\t.*' '' -- $output)
if not string match -q -- "$current*" $names; and test (math --scale 0 $directive / 4 % 2) -eq 0
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return 1
end
return 0
end

# Disable file completions by default, let __complete control it
complete -c ${binaryName} -f -a '(__${binaryName}_complete)'
# The candidates, with files off, whenever __complete has something to offer
complete -c ${binaryName} -n '__${binaryName}_prepare_completions' -f -a '$__${binaryName}_comp_results'
`;
}
Loading
Loading