From aa8401ed691a01b96eda9ff094d15db6fa91228a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 12:55:38 -0400 Subject: [PATCH 01/21] feat(migrate): add export workos and WorkOS imports `clerk migrate export workos` reads a tenant's users through the User Management API with a secret API key, taken from --api-key, WORKOS_API_KEY, or a prompt. Pagination is cursor-based, so there is no record ceiling. --with-identities adds one request per user to record each user's OAuth providers; it is asked once interactively and defaults to no. WorkOS returns no password hashes or TOTP secrets, and the coverage report says so. `clerk migrate import` reads WorkOS exports, routing an unverified email away from the primary field, metadata to unsafe metadata and WorkOS's own external_id to private metadata. `clerk init` now flags a WorkOS install. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/init/scan.ts | 5 + .../cli-core/src/commands/migrate/README.md | 101 +++- .../src/commands/migrate/export/index.ts | 32 ++ .../commands/migrate/export/registry.test.ts | 2 +- .../src/commands/migrate/export/registry.ts | 8 + .../commands/migrate/export/workos.test.ts | 469 +++++++++++++++++ .../src/commands/migrate/export/workos.ts | 497 ++++++++++++++++++ .../src/commands/migrate/index.test.ts | 14 + .../cli-core/src/commands/migrate/run.test.ts | 3 +- .../src/commands/migrate/sources/registry.ts | 9 +- .../commands/migrate/sources/sources.test.ts | 62 ++- .../src/commands/migrate/sources/workos.ts | 59 +++ .../src/commands/migrate/wizard.test.ts | 1 + .../src/test/integration/completion.test.ts | 2 +- 14 files changed, 1242 insertions(+), 22 deletions(-) create mode 100644 packages/cli-core/src/commands/migrate/export/workos.test.ts create mode 100644 packages/cli-core/src/commands/migrate/export/workos.ts create mode 100644 packages/cli-core/src/commands/migrate/sources/workos.ts diff --git a/packages/cli-core/src/commands/init/scan.ts b/packages/cli-core/src/commands/init/scan.ts index d70796ed54..3aba1a8485 100644 --- a/packages/cli-core/src/commands/init/scan.ts +++ b/packages/cli-core/src/commands/init/scan.ts @@ -54,6 +54,11 @@ const AUTH_LIBRARY_SCANS: AuthLibraryScan[] = [ name: "Better Auth", docsUrl: "https://clerk.com/docs/migrations/overview", }, + { + packages: ["@workos-inc/authkit-nextjs", "@workos-inc/node"], + name: "WorkOS", + docsUrl: "https://clerk.com/docs/migrations/overview", + }, { packages: ["@kinde-oss/kinde-auth-nextjs"], name: "Kinde", diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 32d58ec8ed..c756757b89 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -218,6 +218,7 @@ clerk migrate export auth0 --domain my-tenant.us.auth0.com \ --client-id … # asks for the secret, masked clerk migrate export supabase --db-url "postgres://postgres:...@db.xxx.supabase.co:5432/postgres" clerk migrate export firebase --service-account ./service-account.json +clerk migrate export workos --api-key sk_… ``` The platform is an optional positional. Omitted, you get a picker built from @@ -243,6 +244,7 @@ told not to. | `auth0` | Auth0 Management API | `--source auth0` | | `supabase` | Supabase Postgres (`auth.users`) | `--source supabase` | | `firebase` | Firebase Identity Toolkit | `--source firebase` | +| `workos` | WorkOS User Management API | `--source workos` | Every export is a [run](#the-run-store), and the file lands in the run folder as `export.json`. `--output` writes it somewhere else instead, @@ -283,6 +285,9 @@ keep `--runs-dir`: the import looks the run ID up there. | `--domain ` | `auth0` | Tenant domain, e.g. `my-tenant.us.auth0.com`, not a custom domain | | `--client-id ` | `auth0` | Machine-to-machine application client ID | | `--client-secret ` | `auth0` | Machine-to-machine application client secret | +| `--api-key ` | `workos` | WorkOS secret API key, the one starting `sk_` | +| `--with-identities` | `workos` | Also record each user's OAuth providers | +| `--no-with-identities` | `workos` | Skip the OAuth provider fan-out without being asked | `export clerk` also takes the targeting flags — it reads from a Clerk instance, so it resolves a key the same way `clerk migrate import` does, with one extra @@ -356,7 +361,7 @@ profile in that order. The export run has one line per exported user, so `clerk migrate runs` lists it alongside imports. -#### Two platforms export no passwords +#### Three platforms export no passwords - **Clerk** never returns password digests, TOTP secrets or backup codes over the API — only the `*_enabled` booleans. Migrated users must reset their @@ -366,9 +371,14 @@ alongside imports. `auth0|` plus `alt_id`, or `_id` when there's no `alt_id`. Copy each `passwordHash` onto the matching user before importing, or migrate without passwords. +- **WorkOS** returns neither password hashes nor TOTP secrets, and has no + support-request escape hatch: hashes go in on import and never come back, and + `totp.secret` is returned on enrol only. There is nothing to add to the file. -Both print a warning whenever they export anyone. `clerk`'s coverage also -counts users who _have_ a password, so the size of the gap is visible up front. +All three print a warning whenever they export anyone. `clerk`'s and `workos`'s +coverage also counts users who _have_ a password, so the size of the gap is +visible up front — `workos` prints that row at zero unconditionally, because +zero is the only value it can take. #### `supabase` reads the database @@ -523,6 +533,60 @@ missing permission instead, and exits. An outage, a refused connection, or a `429` that outlasts its retries is not asked for again: another credential would not fix it, and it exits 1. +#### WorkOS credentials + +Needs a secret API key — the one starting `sk_`, from the WorkOS dashboard +under API Keys. Resolved from `--api-key`, then `WORKOS_API_KEY`, then a +prompt; agent mode exits naming both instead. + +There is no `--db-url` sibling because there is no database to point it at. +WorkOS is API-only: apps commonly mirror users into their own store through +webhooks, but that mirror is a derived copy holding no credentials, so the +User Management API is the only source. Pagination is cursor-based, so unlike +Auth0 there is no record ceiling — `after` runs to the end of the tenant. + +**`--with-identities` is off by default, and it is not free.** WorkOS has no +bulk endpoint for OAuth identities, so it is one request per user: ten requests +becomes 1,010 for a thousand users. Nothing it returns can be imported — +`POST /v1/users` has no external-accounts field — so it buys a provider +breakdown in the coverage report, and an `identities` array kept in the export +file for whoever runs the migration. The interactive path asks once, after the +user count is known, defaulting to no; agent mode takes the flag's answer and +asks nothing. `-y` answers the question `yes`, so pass +`--no-with-identities` to skip the fan-out without being asked. + +The breakdown prints as its own **OAuth providers** block under the coverage +table, not as extra coverage rows: + +``` +Field coverage + ✓ 6/6 have an email address + ✗ 0/6 have a password (WorkOS returns none) + +OAuth providers + GoogleOAuth 2 users + MicrosoftOAuth 1 user + no OAuth provider 2 users + not readable 1 user + Those users have no `identities` field in the export, rather than an empty one. +``` + +Separate because the two kinds of row do not mean the same thing. A coverage +row is "N of the M users have this field"; a provider row has no such +denominator — one user holding two providers is counted under both, so the +counts can sum past the user count, and `not readable` is not a property of the +user at all. + +A lookup that fails is counted on its own row rather than folded into +`no OAuth provider`. "Lookup failed" and "has no providers" are different +facts, and flattening the first into the second would understate social +sign-in. + +Non-interactive runs get progress on stderr every 500 users during the fan-out, +and every 10 pages during the user fetch. `withSpinner` hands a no-op to +anything that is not a TTY, so without this an agent exporting a large tenant +would see nothing at all until the run finished. + ### `clerk migrate import` Reads an exported user file, maps it onto Clerk's user schema, checks every @@ -980,6 +1044,7 @@ brings across. Adding a platform is one file in `sources/` plus one line in | `auth0` | Auth0 Management API | partial | no | yes | | `firebase` | `firebase auth:export` | yes | no | no | | `supabase` | Supabase `auth.users` export | yes | no | partial | +| `workos` | WorkOS User Management API | no | no | yes | An unknown `--source` exits 2 with the list of valid keys. @@ -994,10 +1059,10 @@ to enable any that are off. Auth0's come from each `user_id`'s prefix ### Metadata Metadata a user can edit on the source platform — Auth0's `user_metadata`, -Supabase's `raw_user_meta_data` — goes to Clerk's `unsafe_metadata`, which is -the user-editable one. Public metadata is read-only to the user, so putting it -there would take away an edit the user had. Auth0's `app_metadata` goes to -`private_metadata`. A Clerk Dashboard CSV carries no metadata. +Supabase's `raw_user_meta_data`, WorkOS's `metadata` — goes to Clerk's +`unsafe_metadata`, which is the user-editable one. Public metadata is read-only +to the user, so putting it there would take away an edit the user had. Auth0's +`app_metadata` goes to `private_metadata`. A Clerk Dashboard CSV carries no metadata. ### Verified vs unverified identifiers @@ -1007,7 +1072,7 @@ which style it uses. An identifier the source never confirmed is routed to field, because Clerk creates primary identifiers **verified** — sending an unconfirmed address there would silently promote it. -- **Boolean** (`auth0`, `firebase`): `true`/`false`. A CSV export stringifies these, so `"false"` is +- **Boolean** (`auth0`, `firebase`, `workos`): `true`/`false`. A CSV export stringifies these, so `"false"` is read as false, not as a non-empty string. `TRUE`, `FALSE`, `t` and `f` read too, as a spreadsheet or psql writes them. - **Timestamp** (`supabase`): a nullable confirmation time. Any real value @@ -1142,20 +1207,22 @@ The checks also read the instance's Frontend API `GET /v1/environment` attributes and enabled social providers. Nothing in `clerk migrate` writes instance settings: the checks print the `clerk config patch` to run instead. -Two exports talk to their own platform rather than to Clerk: +Three exports talk to their own platform rather than to Clerk: -| Method | Path | Used by | -| ------ | ---------------------------------------------- | ---------------------------------------------------- | -| `POST` | `https:///oauth/token` | `export auth0` — Management API access token | -| `GET` | `https:///api/v2/users` | `export auth0` — 100 per page, 1000 users maximum | -| `POST` | `https://oauth2.googleapis.com/token` | `export firebase` — RS256 assertion → access token | -| `GET` | `…/v1/projects/{project_id}/accounts:batchGet` | `export firebase` — pages users, 1000 at a time | -| `GET` | `…/admin/v2/projects/{project_id}/config` | `export firebase` — reads the scrypt hash parameters | +| Method | Path | Used by | +| ------ | ---------------------------------------------- | -------------------------------------------------------- | +| `POST` | `https:///oauth/token` | `export auth0` — Management API access token | +| `GET` | `https:///api/v2/users` | `export auth0` — 100 per page, 1000 users maximum | +| `POST` | `https://oauth2.googleapis.com/token` | `export firebase` — RS256 assertion → access token | +| `GET` | `…/v1/projects/{project_id}/accounts:batchGet` | `export firebase` — pages users, 1000 at a time | +| `GET` | `…/admin/v2/projects/{project_id}/config` | `export firebase` — reads the scrypt hash parameters | +| `GET` | `…/user_management/users` | `export workos` — 100 per page, cursor-paginated | +| `GET` | `…/user_management/users/{id}/identities` | `export workos` — `--with-identities` only, one per user | The two Identity Toolkit paths are on `identitytoolkit.googleapis.com`, or on `FIREBASE_AUTH_EMULATOR_HOST` when that is set. The emulator needs no token, so the export skips that request, and it may not carry the hash parameters: pass -them to the import. +them to the import. The two WorkOS paths are on `api.workos.com`. ## Notes diff --git a/packages/cli-core/src/commands/migrate/export/index.ts b/packages/cli-core/src/commands/migrate/export/index.ts index 34550a924a..c33e1779a9 100644 --- a/packages/cli-core/src/commands/migrate/export/index.ts +++ b/packages/cli-core/src/commands/migrate/export/index.ts @@ -6,6 +6,7 @@ import { exportAuth0 } from "./auth0.ts"; import { exportClerk } from "./clerk.ts"; import { exportFirebase } from "./firebase.ts"; import { exportSupabase } from "./supabase.ts"; +import { exportWorkOs } from "./workos.ts"; import type { DbExportOptions } from "./db-options.ts"; import { RUNS_DIR_DESCRIPTION, RUNS_DIR_FLAG } from "../lib/run-store.ts"; import { exportPlatformKeys, exportPlatforms, getExportPlatform } from "./registry.ts"; @@ -53,6 +54,7 @@ const handlers = { auth0: exportAuth0, supabase: exportSupabase, firebase: exportFirebase, + workos: exportWorkOs, }; /** The platforms that read a database, which share `--db-url`. */ @@ -170,6 +172,36 @@ export function registerMigrateExport(migrateCommand: Command<[], Record[0]), ); + exportCommand + .command("workos") + .description("Export users from a WorkOS tenant") + .option("--api-key ", "WorkOS secret API key, the one starting `sk_`") + .option( + "--with-identities", + "Also record each user's OAuth providers — one extra request per user", + ) + .option("--no-with-identities", "Skip the OAuth provider fan-out without being asked") + .option("-o, --output ", "Write the export here instead of the run folder") + .option( + "-y, --yes", + "Do not prompt: fail on a rejected credential, and assume --with-identities", + ) + .option(RUNS_DIR_FLAG, RUNS_DIR_DESCRIPTION) + .option("--json", "Print the result as JSON; never prompts") + .setExamples([ + { + command: "clerk migrate export workos --api-key sk_…", + description: "Export with an explicit API key", + }, + { + command: "clerk migrate export workos", + description: "Read WORKOS_API_KEY, or prompt", + }, + ]) + .action(async (_opts, cmd) => + handlers.workos(cmd.optsWithGlobals() as Parameters[0]), + ); + // Each takes exactly one connection string, so they are registered from a // table rather than near-identical blocks. for (const platform of DB_PLATFORMS) { diff --git a/packages/cli-core/src/commands/migrate/export/registry.test.ts b/packages/cli-core/src/commands/migrate/export/registry.test.ts index 69f594a4ed..54a0ec2d5e 100644 --- a/packages/cli-core/src/commands/migrate/export/registry.test.ts +++ b/packages/cli-core/src/commands/migrate/export/registry.test.ts @@ -4,7 +4,7 @@ import { exportPlatformKeys, exportPlatforms, getExportPlatform } from "./regist describe("export registry", () => { test("registers every source platform", () => { - expect(exportPlatformKeys()).toEqual(["clerk", "auth0", "supabase", "firebase"]); + expect(exportPlatformKeys()).toEqual(["clerk", "auth0", "supabase", "firebase", "workos"]); }); test.each([...exportPlatforms])("$key carries a label and description", (entry) => { diff --git a/packages/cli-core/src/commands/migrate/export/registry.ts b/packages/cli-core/src/commands/migrate/export/registry.ts index 9ff2dc3efc..2103941e93 100644 --- a/packages/cli-core/src/commands/migrate/export/registry.ts +++ b/packages/cli-core/src/commands/migrate/export/registry.ts @@ -14,6 +14,7 @@ import { exportAuth0 } from "./auth0.ts"; import { exportClerk } from "./clerk.ts"; import { exportFirebase } from "./firebase.ts"; import { exportSupabase } from "./supabase.ts"; +import { exportWorkOs } from "./workos.ts"; export type ExportRegistryEntry = { key: string; @@ -53,6 +54,13 @@ export const exportPlatforms: ExportRegistryEntry[] = [ sourceKey: "firebase", run: async (options) => exportFirebase(options), }, + { + key: "workos", + label: "WorkOS", + description: "A WorkOS tenant, via the User Management API — no password hashes", + sourceKey: "workos", + run: async (options) => exportWorkOs(options), + }, ]; export function exportPlatformKeys(): string[] { diff --git a/packages/cli-core/src/commands/migrate/export/workos.test.ts b/packages/cli-core/src/commands/migrate/export/workos.test.ts new file mode 100644 index 0000000000..89e34a2a11 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/export/workos.test.ts @@ -0,0 +1,469 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +// Colour is on or off depending on the runner, so rows are compared bare. +import { stripVTControlCharacters as stripAnsi } from "node:util"; +import type { UserLine } from "../lib/run-store.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import { setAssumeYes } from "../lib/assume-yes.ts"; +import { + buildIdentityReport, + buildWorkOsExport, + exportWorkOs, + fetchAllWorkOsIdentities, + fetchAllWorkOsUsers, + fetchWorkOsIdentities, + fetchWorkOsPage, + mapWorkOsUserToExport, + resolveWithIdentities, + resolveWorkOsApiKey, + type WorkOsIdentity, +} from "./workos.ts"; + +const captured = useCaptureLog(); + +const API_KEY = "sk_test"; + +let workDir: string; +let originalCwd: string; +let originalFetch: typeof globalThis.fetch; +let requests: string[]; + +let originalMode: string | undefined; + +beforeAll(() => { + originalMode = process.env.CLERK_MODE; + originalCwd = process.cwd(); + originalFetch = globalThis.fetch; + workDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "clerk-migrate-expworkos-"))); + process.chdir(workDir); +}); + +afterAll(() => { + if (originalMode === undefined) delete process.env.CLERK_MODE; + else process.env.CLERK_MODE = originalMode; + globalThis.fetch = originalFetch; + process.chdir(originalCwd); + fs.rmSync(workDir, { recursive: true, force: true }); +}); + +beforeEach(() => { + // Tests that need a prompt set human mode themselves; without this a leaked + // "human" from an earlier test stops a later one on the destination prompt. + process.env.CLERK_MODE = "agent"; + requests = []; + fs.rmSync(path.join(workDir, ".clerk"), { recursive: true, force: true }); + fs.rmSync(path.join(workDir, "exports"), { recursive: true, force: true }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const workosUser = (i: number, overrides: Record = {}) => ({ + id: `user_0${i}`, + email: `a${i}@x.dev`, + email_verified: true, + first_name: `Given${i}`, + last_name: `Family${i}`, + ...overrides, +}); + +/** + * Stubs one users page per entry in `pages`, chaining the cursor, plus an + * identities response per user id in `identities`. + */ +function stubWorkOs( + pages: Record[][], + identities: Record = {}, +) { + globalThis.fetch = (async (input: string | URL | Request) => { + const url = input.toString(); + requests.push(url); + + const identityMatch = /\/users\/([^/]+)\/identities/.exec(url); + if (identityMatch) { + const entry = identities[identityMatch[1] as string]; + if (entry === "fail") return new Response("nope", { status: 500 }); + return Response.json(entry ?? []); + } + + const after = new URL(url).searchParams.get("after"); + const index = after ? Number(after.replace("cursor", "")) : 0; + const isLast = index >= pages.length - 1; + return Response.json({ + data: pages[index] ?? [], + list_metadata: { after: isLast ? null : `cursor${index + 1}` }, + }); + }) as unknown as typeof fetch; +} + +describe("resolveWorkOsApiKey", () => { + test("prefers the flag", async () => { + expect(await resolveWorkOsApiKey({ apiKey: "sk_flag" }, {})).toBe("sk_flag"); + }); + + test("falls back to the environment", async () => { + expect(await resolveWorkOsApiKey({}, { WORKOS_API_KEY: "sk_env" })).toBe("sk_env"); + }); + + // Tests run non-TTY, the same signal an agent gives. + test("names the flag and the variable when neither supplied one", async () => { + await expect(resolveWorkOsApiKey({}, {})).rejects.toThrow( + /Missing: --api-key \(or WORKOS_API_KEY\)\./, + ); + }); +}); + +describe("fetchWorkOsPage", () => { + test("asks for the documented page size", async () => { + stubWorkOs([[]]); + await fetchWorkOsPage(API_KEY); + expect(requests[0]).toContain("limit=100"); + }); + + test("explains a rejection instead of surfacing a raw status", async () => { + globalThis.fetch = (async () => + Response.json({ message: "Unauthorized" }, { status: 401 })) as unknown as typeof fetch; + + await expect(fetchWorkOsPage(API_KEY)).rejects.toThrow( + /WorkOS returned 401 listing users: Unauthorized/, + ); + }); + + // The usual cause is a publishable key, or a key from the other environment. + test("points at the key itself", async () => { + globalThis.fetch = (async () => new Response("{}", { status: 401 })) as unknown as typeof fetch; + await expect(fetchWorkOsPage(API_KEY)).rejects.toThrow(/secret key/); + }); +}); + +describe("fetchAllWorkOsUsers", () => { + test("follows the cursor until it comes back null", async () => { + stubWorkOs([ + Array.from({ length: 100 }, (_, i) => workosUser(i)), + Array.from({ length: 4 }, (_, i) => workosUser(100 + i)), + ]); + + const all = await fetchAllWorkOsUsers({ apiKey: API_KEY }); + + expect(all).toHaveLength(104); + expect(requests).toHaveLength(2); + expect(requests[1]).toContain("after=cursor1"); + }); + + // Cursor pagination has no record ceiling, so a full page that happens to be + // the last one must not read as "there is more". + test("stops on a full final page", async () => { + stubWorkOs([Array.from({ length: 100 }, (_, i) => workosUser(i))]); + expect(await fetchAllWorkOsUsers({ apiKey: API_KEY })).toHaveLength(100); + expect(requests).toHaveLength(1); + }); + + test("reuses a page already fetched rather than asking twice", async () => { + stubWorkOs([[workosUser(0)]]); + const firstPage = await fetchWorkOsPage(API_KEY); + requests = []; + + expect(await fetchAllWorkOsUsers({ apiKey: API_KEY, firstPage })).toHaveLength(1); + expect(requests).toHaveLength(0); + }); +}); + +describe("resolveWithIdentities", () => { + test("is on when the flag asked for it", async () => { + expect(await resolveWithIdentities({ withIdentities: true }, 10)).toBe(true); + }); + + // The fan-out is one request per user and nothing it returns can be + // imported, so it is never the default. + test("is off without the flag when there is nobody to ask", async () => { + expect(await resolveWithIdentities({}, 10)).toBe(false); + }); + + test("is off when `--no-with-identities` said so, even under -y", async () => { + setAssumeYes(true); + try { + expect(await resolveWithIdentities({ withIdentities: false }, 10)).toBe(false); + } finally { + setAssumeYes(false); + } + }); + + // `-y` is "answer the prompts yes", and the prompt is "also fetch providers?". + test("is on under -y, which answers the question rather than asking it", async () => { + process.env.CLERK_MODE = "human"; + setAssumeYes(true); + try { + expect(await resolveWithIdentities({}, 10)).toBe(true); + } finally { + setAssumeYes(false); + } + }); + + test("does not ask when there are no users to ask about", async () => { + process.env.CLERK_MODE = "human"; + expect(await resolveWithIdentities({}, 0)).toBe(false); + }); +}); + +describe("fetchAllWorkOsIdentities", () => { + test("collects each user's providers", async () => { + stubWorkOs([[]], { + user_00: [{ provider: "GoogleOAuth", idp_id: "g1", type: "OAuth" }], + user_01: [], + }); + + const { identities, failed } = await fetchAllWorkOsIdentities({ + apiKey: API_KEY, + users: [workosUser(0), workosUser(1)], + }); + + expect(identities.get("user_00")).toEqual([ + { provider: "GoogleOAuth", idp_id: "g1", type: "OAuth" }, + ]); + expect(identities.get("user_01")).toEqual([]); + expect(failed).toBe(0); + }); + + // "Lookup failed" and "has no providers" are different facts, and flattening + // the first into the second would put a wrong number in the report. + test("leaves a failed lookup absent rather than empty, and counts it", async () => { + stubWorkOs([[]], { user_00: "fail", user_01: [] }); + + const { identities, failed } = await fetchAllWorkOsIdentities({ + apiKey: API_KEY, + users: [workosUser(0), workosUser(1)], + }); + + expect(identities.has("user_00")).toBe(false); + expect(identities.get("user_01")).toEqual([]); + expect(failed).toBe(1); + }); +}); + +describe("buildIdentityReport", () => { + const rowsOf = (section: { rows: string[] }) => + section.rows.map((row) => stripAnsi(row).trimEnd()); + + test("ranks providers by use, and counts users with none", () => { + const section = buildIdentityReport( + [workosUser(0), workosUser(1), workosUser(2), workosUser(3)], + new Map([ + ["user_00", [{ provider: "GoogleOAuth" }]], + ["user_01", [{ provider: "GoogleOAuth" }, { provider: "MicrosoftOAuth" }]], + ["user_02", []], + ["user_03", []], + ]), + 0, + ); + + expect(section.title).toBe("OAuth providers"); + expect(rowsOf(section)).toEqual([ + " GoogleOAuth 2 users", + " MicrosoftOAuth 1 user", + " no OAuth provider 2 users", + ]); + }); + + // Counting a failed lookup as "no provider" would understate social sign-in. + test("reports unreadable lookups on their own row, with the caveat", () => { + const section = buildIdentityReport( + [workosUser(0), workosUser(1)], + new Map([["user_01", []]]), + 1, + ); + + const rows = rowsOf(section); + expect(rows).toContain(" not readable 1 user"); + expect(rows).toContain(" no OAuth provider 1 user"); + expect(rows.at(-1)).toContain("no `identities` field in the export, rather than an empty one"); + }); + + test("says nothing about unreadable lookups when there were none", () => { + const section = buildIdentityReport([workosUser(0)], new Map([["user_00", []]]), 0); + expect(rowsOf(section)).toEqual([" no OAuth provider 1 user"]); + }); +}); + +describe("mapWorkOsUserToExport", () => { + test("keeps the fields the workos transformer maps from", () => { + expect(mapWorkOsUserToExport(workosUser(0, { created_at: "2025-01-01" }))).toEqual({ + id: "user_00", + email: "a0@x.dev", + first_name: "Given0", + last_name: "Family0", + created_at: "2025-01-01", + email_verified: true, + }); + }); + + // Dropping a false flag would import an unconfirmed address as verified. + test("keeps email_verified when it is false", () => { + expect(mapWorkOsUserToExport(workosUser(0, { email_verified: false })).email_verified).toBe( + false, + ); + }); + + test("drops tenant fields the import has no use for", () => { + const mapped = mapWorkOsUserToExport( + workosUser(0, { + locale: "en-GB", + profile_picture_url: "https://x.dev/a.png", + last_sign_in_at: "2026-01-01", + updated_at: "2026-01-01", + }), + ); + for (const noise of ["locale", "profile_picture_url", "last_sign_in_at", "updated_at"]) { + expect(noise in mapped).toBe(false); + } + }); + + test("keeps the tenant's own external_id", () => { + expect(mapWorkOsUserToExport(workosUser(0, { external_id: "cust_1" })).external_id).toBe( + "cust_1", + ); + }); + + test("omits empty metadata", () => { + expect("metadata" in mapWorkOsUserToExport(workosUser(0, { metadata: {} }))).toBe(false); + expect(mapWorkOsUserToExport(workosUser(0, { metadata: { plan: "pro" } })).metadata).toEqual({ + plan: "pro", + }); + }); + + test("carries identities only when they were fetched", () => { + expect("identities" in mapWorkOsUserToExport(workosUser(0))).toBe(false); + expect(mapWorkOsUserToExport(workosUser(0), [{ provider: "GoogleOAuth" }]).identities).toEqual([ + { provider: "GoogleOAuth" }, + ]); + }); +}); + +describe("buildWorkOsExport", () => { + test("counts coverage and records each user", () => { + const lines: UserLine[] = []; + const { users, coverage } = buildWorkOsExport( + [workosUser(0), workosUser(1, { first_name: undefined })], + (line) => lines.push(line), + ); + + expect(users).toHaveLength(2); + const byLabel = Object.fromEntries(coverage.map((c) => [c.label, c.count])); + expect(byLabel["have an email address"]).toBe(2); + expect(byLabel["have a first name"]).toBe(1); + + expect(lines.map((line) => line.status)).toEqual(["exported", "exported"]); + }); + + // Always shown, always zero: seeing it before the import is the point. + test("reports the password row even though it can only ever be zero", () => { + const { coverage } = buildWorkOsExport([workosUser(0)]); + expect(coverage.at(-1)).toEqual({ + label: "have a password (WorkOS returns none)", + count: 0, + }); + }); + + // Providers get their own block: a coverage row means "N of M users have + // this field", and a provider count can exceed M. + test("keeps providers out of the coverage table", () => { + const { coverage } = buildWorkOsExport( + [workosUser(0)], + undefined, + new Map([["user_00", [{ provider: "GoogleOAuth" }]]]), + ); + expect(coverage.some((row) => row.label.toLowerCase().includes("oauth"))).toBe(false); + }); +}); + +/** The envelope the one export run in this project wrote. */ +function onlyExportFile(): string { + const dir = path.join(workDir, ".clerk", "migrate"); + const entries = fs.readdirSync(dir); + expect(entries).toHaveLength(1); + return path.join(dir, entries[0] as string, "export.json"); +} + +/** The users inside that envelope. */ +function exportedUsers(): Record[] { + return ( + JSON.parse(fs.readFileSync(onlyExportFile(), "utf-8")) as { users: Record[] } + ).users; +} + +describe("exportWorkOs", () => { + test("writes the default path and reports coverage", async () => { + stubWorkOs([[workosUser(0)]]); + + await exportWorkOs({ apiKey: API_KEY }); + expect(JSON.parse(fs.readFileSync(onlyExportFile(), "utf-8"))).toMatchObject({ + source: "workos", + }); + const written = exportedUsers(); + expect(written[0]?.id).toBe("user_00"); + expect(captured.err).toContain("Field coverage"); + }); + + test("names the command that consumes the file", async () => { + stubWorkOs([[workosUser(0)]]); + // The suggestion rides the gutter's Next steps block, which only renders + // in human mode. + process.env.CLERK_MODE = "human"; + // --output answers the destination prompt, and --with-identities answers + // the providers question, so human mode stops on neither. + await exportWorkOs({ apiKey: API_KEY, output: "exports/mine.json", withIdentities: true }); + expect(captured.err).toMatch(/clerk migrate import \d{8}-\d{6}-[0-9a-f]{4}/); + }); + + test("--output controls the destination", async () => { + stubWorkOs([[workosUser(0)]]); + + await exportWorkOs({ apiKey: API_KEY, output: "tenant.json" }); + + expect(fs.existsSync(path.join(workDir, "tenant.json"))).toBe(true); + }); + + test("skips the per-user identity fan-out unless asked", async () => { + stubWorkOs([[workosUser(0), workosUser(1)]]); + + await exportWorkOs({ apiKey: API_KEY, output: "plain.json" }); + + expect(requests.filter((url) => url.includes("/identities"))).toHaveLength(0); + }); + + test("--with-identities records each user's providers in the file", async () => { + stubWorkOs([[workosUser(0)]], { user_00: [{ provider: "GoogleOAuth", idp_id: "g1" }] }); + + await exportWorkOs({ apiKey: API_KEY, output: "rich.json", withIdentities: true }); + + const written = ( + JSON.parse(fs.readFileSync(path.join(workDir, "rich.json"), "utf-8")) as { + users: Record[]; + } + ).users; + expect(written[0]?.identities).toEqual([{ provider: "GoogleOAuth", idp_id: "g1" }]); + }); + + // Finding this out after the import means nobody can sign in. + test("says plainly that no credentials are in the file", async () => { + stubWorkOs([[workosUser(0)]]); + await exportWorkOs({ apiKey: API_KEY, output: "warned.json" }); + expect(captured.err).toContain("does not return password hashes or TOTP secrets"); + }); +}); + +describe("fetchWorkOsIdentities", () => { + test("accepts the bare array the endpoint returns", async () => { + stubWorkOs([[]], { user_00: [{ provider: "GoogleOAuth" }] }); + expect(await fetchWorkOsIdentities(API_KEY, "user_00")).toEqual([{ provider: "GoogleOAuth" }]); + }); + + // A move to WorkOS's usual envelope must not read as "no providers". + test("accepts a { data } envelope too", async () => { + globalThis.fetch = (async () => + Response.json({ data: [{ provider: "AppleOAuth" }] })) as unknown as typeof fetch; + expect(await fetchWorkOsIdentities(API_KEY, "user_00")).toEqual([{ provider: "AppleOAuth" }]); + }); +}); diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts new file mode 100644 index 0000000000..ee002cfe36 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -0,0 +1,497 @@ +/** + * `clerk migrate export workos` — pull users out of a WorkOS tenant. + * + * Structurally the Auth0 case, and written against the same shape: two REST + * calls through `loggedFetch` rather than the `@workos-inc/node` SDK, so + * everything the command sends shows up under `--verbose`. See the header of + * `auth0.ts` and `.claude/rules/debug-logging.md`. + * + * **WorkOS is API-only, and no credential leaves it.** There is no + * bring-your-own-database option — apps mirror WorkOS users into their own + * store through webhooks, but that mirror is a derived copy holding no secrets, + * which is why this has no `--db-url` sibling. Password hashes are accepted on + * import and never returned; TOTP secrets come back on enrol only, never on + * list or get. So a WorkOS migration moves identities, and every password user + * signs in again by reset. The run says so rather than leaving it to be + * discovered when nobody can sign in. + */ + +import { throwUsageError } from "../../../lib/errors.ts"; +import { loggedFetch } from "../../../lib/fetch.ts"; +import { dim } from "../../../lib/color.ts"; +import { log } from "../../../lib/log.ts"; +import { confirm, password as passwordPrompt } from "../../../lib/prompts.ts"; +import { withGutter, withSpinner, type SpinnerControls } from "../../../lib/spinner.ts"; +import { isAgent, isHuman } from "../../../mode.ts"; +import type { UserLine } from "../lib/run-store.ts"; +import { printTarget } from "../lib/target.ts"; +import { isAssumeYes } from "../lib/assume-yes.ts"; +import { throwApiFailure, withInputRetry } from "../lib/input-retry.ts"; +import { createApiScheduler } from "../lib/scheduler.ts"; +import { finishExport, startExportRun, type ExportSection } from "./shared.ts"; + +const API_BASE = "https://api.workos.com/user_management"; + +/** WorkOS caps `limit` at 100. */ +const PAGE_SIZE = 100; + +/** + * Pacing for the per-user identity fan-out. + * + * WorkOS allows 6,000 requests a minute, so this is nowhere near the ceiling — + * it is here so a 50,000-user tenant does not open 50,000 sockets at once. + */ +const IDENTITY_CONCURRENCY = 10; +const IDENTITY_RATE_PER_SECOND = 20; + +/** + * How often a non-interactive run says where it has got to. + * + * `withSpinner` hands a no-op `update` to anything that is not a TTY, so an + * agent exporting 50,000 users with `--with-identities` would otherwise see + * nothing at all for the ten minutes the fan-out takes. These two print + * through `log.info` instead, which a non-TTY does get. + */ +const IDENTITY_PROGRESS_EVERY = 500; +const USER_PROGRESS_EVERY_PAGES = 10; + +const NO_PROVIDER_LABEL = "no OAuth provider"; +const NOT_READABLE_LABEL = "not readable"; + +const DOCS_URL = "https://clerk.com/docs/guides/development/migrating/overview"; + +export type ExportWorkOsOptions = { + apiKey?: string; + /** Unset means "ask"; `--no-with-identities` sets it to false. */ + withIdentities?: boolean; + output?: string; + /** Where runs are kept; overrides `CLERK_MIGRATE_DIR`. */ + runsDir?: string; + /** Print the result as JSON on stdout; never prompts. */ + json?: boolean; +}; + +export type WorkOsUser = Record & { id?: string }; + +export type WorkOsIdentity = { idp_id?: string; type?: string; provider?: string }; + +/** + * Resolves the API key: flag, then environment, then a prompt. + * + * One value rather than Auth0's three, so there is no "name everything that is + * missing" pass — there is only ever the one thing. + * + * @throws CliError in agent mode when nothing supplied it. + */ +export async function resolveWorkOsApiKey( + options: ExportWorkOsOptions, + env: Record = process.env, +): Promise { + const resolved = options.apiKey ?? env.WORKOS_API_KEY; + + if (resolved) return resolved.trim(); + + if (isAgent() || !isHuman()) { + throwUsageError( + "`clerk migrate export workos` needs a WorkOS API key and cannot prompt here.\n" + + "Missing: --api-key (or WORKOS_API_KEY).", + DOCS_URL, + undefined, + [ + { + command: "clerk migrate export workos --api-key sk_…", + description: "Export with an explicit API key", + }, + ], + ); + } + + log.info( + "WorkOS needs a secret API key, the one starting `sk_`. Find it in the WorkOS dashboard under API Keys.", + ); + + return promptWorkOsApiKey(); +} + +export async function promptWorkOsApiKey(): Promise { + const key = await passwordPrompt({ + message: "WorkOS secret API key (sk_…)", + validate: (value) => (value?.trim() ? undefined : "An API key is required"), + }); + return key.trim(); +} + +/** + * Whatever WorkOS put in an error body, in one string. + * + * Read as text and parsed from that, rather than `response.json()` with a text + * fallback: the failed parse disturbs the stream, so the fallback could never + * actually run. A non-JSON body — a proxy's HTML error page — is the case worth + * surfacing verbatim. + */ +async function describeFailure(response: Response): Promise { + const raw = (await response.text().catch(() => "")).trim(); + if (!raw) return "no detail returned"; + + try { + const body = JSON.parse(raw) as { + message?: string; + error_description?: string; + error?: string; + }; + return body.message ?? body.error_description ?? body.error ?? raw; + } catch { + return raw; + } +} + +export type WorkOsPage = { users: WorkOsUser[]; after?: string }; + +/** + * Fetches one page of users. + * + * Cursor pagination, so unlike Auth0's offset endpoint there is no record + * ceiling to warn about — `after` runs to the end of the tenant. + */ +export async function fetchWorkOsPage(apiKey: string, after?: string): Promise { + const url = new URL(`${API_BASE}/users`); + url.searchParams.set("limit", String(PAGE_SIZE)); + url.searchParams.set("order", "asc"); + if (after) url.searchParams.set("after", after); + + const response = await loggedFetch(url, { + tag: "workos", + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + }); + + if (!response.ok) { + throwApiFailure( + response.status, + `WorkOS returned ${response.status} listing users: ${await describeFailure(response)}\n` + + "Check that the key is a secret key (`sk_…`) for the right environment, and that it has not been revoked.", + DOCS_URL, + ); + } + + const body = (await response.json()) as { + data?: WorkOsUser[]; + list_metadata?: { after?: string | null }; + }; + + return { users: body.data ?? [], after: body.list_metadata?.after ?? undefined }; +} + +/** + * Pages through the tenant's users. + * + * @param firstPage - A page already fetched, so the request that proved the API + * key is not sent twice. + */ +export async function fetchAllWorkOsUsers(options: { + apiKey: string; + firstPage?: WorkOsPage; + spinner?: SpinnerControls; +}): Promise { + let page = options.firstPage ?? (await fetchWorkOsPage(options.apiKey)); + const all = [...page.users]; + options.spinner?.update(`Fetching users from WorkOS: ${all.length} so far...`); + + // Counted in pages rather than users: a short page would knock a + // `users % N` check off its multiple and silence every later one. + for (let pages = 1; page.after; pages++) { + page = await fetchWorkOsPage(options.apiKey, page.after); + all.push(...page.users); + options.spinner?.update(`Fetching users from WorkOS: ${all.length} so far...`); + if (!isHuman() && pages % USER_PROGRESS_EVERY_PAGES === 0) { + log.info(`Fetched ${all.length} users from WorkOS so far...`); + } + } + + return all; +} + +/** + * Whether to spend one request per user on OAuth providers. + * + * Off unless asked for, both times. WorkOS has no bulk identities endpoint, so + * this is the difference between ten requests and one per user — and nothing it + * returns can be imported, because `POST /v1/users` has no external-accounts + * field. It is a line in the coverage report, and a record kept in the file. + * + * Agent mode gets the flag's answer and no question: there is nobody to ask. + * `-y` answers the question the way a `yes` would, so `--no-with-identities` + * is the way to say no without being asked. + */ +export async function resolveWithIdentities( + options: ExportWorkOsOptions, + userCount: number, +): Promise { + if (options.withIdentities !== undefined) return options.withIdentities; + if (userCount === 0) return false; + if (isAssumeYes()) return true; + if (isAgent() || !isHuman()) return false; + + return confirm({ + message: `Also fetch each user's OAuth providers? That is ${userCount} extra request${userCount === 1 ? "" : "s"}, and the result is report-only — Clerk's import cannot take external accounts.`, + default: false, + }); +} + +/** Fetches one user's OAuth identities. */ +export async function fetchWorkOsIdentities( + apiKey: string, + userId: string, +): Promise { + const response = await loggedFetch(new URL(`${API_BASE}/users/${userId}/identities`), { + tag: "workos", + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + }); + + if (!response.ok) { + throwApiFailure( + response.status, + `WorkOS returned ${response.status} listing identities for ${userId}: ${await describeFailure(response)}`, + DOCS_URL, + ); + } + + // The endpoint returns a bare array; the envelope is handled too so a future + // move to WorkOS's usual `{ data }` shape does not read as "no providers". + const body = (await response.json()) as WorkOsIdentity[] | { data?: WorkOsIdentity[] }; + return Array.isArray(body) ? body : (body.data ?? []); +} + +/** + * Fetches identities for every user. + * + * A user missing from the returned map is one whose lookup **failed**, which is + * not the same as one with no providers — so failures are counted and returned + * separately rather than flattened into an empty list. + */ +export async function fetchAllWorkOsIdentities(options: { + apiKey: string; + users: WorkOsUser[]; + spinner?: SpinnerControls; +}): Promise<{ identities: Map; failed: number }> { + const schedule = createApiScheduler(IDENTITY_CONCURRENCY, IDENTITY_RATE_PER_SECOND); + const total = options.users.length; + const identities = new Map(); + let failed = 0; + let done = 0; + + await Promise.all( + options.users.map(async (user) => + schedule(async () => { + const userId = String(user.id ?? ""); + try { + if (userId) identities.set(userId, await fetchWorkOsIdentities(options.apiKey, userId)); + } catch { + failed++; + } + done++; + options.spinner?.update(`Fetching OAuth providers: ${done}/${total}...`); + if (!isHuman() && done % IDENTITY_PROGRESS_EVERY === 0) { + log.info(`Fetched OAuth providers for ${done}/${total} users...`); + } + }), + ), + ); + + return { identities, failed }; +} + +/** + * The OAuth provider breakdown, as its own block under the coverage table. + * + * Kept out of coverage on purpose: a coverage row means "N of the M users have + * this field", and these rows do not. One user can hold two providers, so the + * counts can sum past the user count, and "not readable" is not a property of + * the user at all. Two kinds of row under one heading would make both harder + * to read. + */ +export function buildIdentityReport( + users: WorkOsUser[], + identities: Map, + failed: number, +): ExportSection { + const byProvider = new Map(); + let none = 0; + + for (const user of users) { + const found = identities.get(String(user.id ?? "")); + // Absent means the lookup failed; `failed` already counts it. + if (!found) continue; + if (found.length === 0) { + none++; + continue; + } + for (const identity of found) { + const provider = identity.provider ?? "unknown"; + byProvider.set(provider, (byProvider.get(provider) ?? 0) + 1); + } + } + + // Busiest provider first; alphabetical within a tie so two runs of the same + // tenant print the same order. + const entries = [...byProvider].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + + const labels = [ + ...entries.map(([provider]) => provider), + NO_PROVIDER_LABEL, + ...(failed > 0 ? [NOT_READABLE_LABEL] : []), + ]; + const width = Math.max(...labels.map((label) => label.length)); + const row = (label: string, count: number) => + ` ${label.padEnd(width)} ${dim(`${count} user${count === 1 ? "" : "s"}`)}`; + + const rows = [ + ...entries.map(([provider, count]) => row(provider, count)), + row(NO_PROVIDER_LABEL, none), + ]; + + if (failed > 0) { + rows.push(row(NOT_READABLE_LABEL, failed)); + rows.push( + dim(" Those users have no `identities` field in the export, rather than an empty one."), + ); + } + + return { title: "OAuth providers", rows }; +} + +/** + * Keeps the fields the `workos` transformer maps from, plus `identities` when + * they were fetched. + * + * A copy rather than the raw record: WorkOS also returns `locale`, + * `profile_picture_url`, `last_sign_in_at` and `updated_at`, which the import + * does not carry. `identities` has no target field either and is + * dropped at validation, so it rides along purely as a record for whoever runs + * the migration. + */ +export function mapWorkOsUserToExport( + user: WorkOsUser, + identities?: WorkOsIdentity[], +): Record { + const exported: Record = {}; + + for (const field of [ + "id", + "email", + "first_name", + "last_name", + "external_id", + "created_at", + ] as const) { + if (user[field]) exported[field] = user[field]; + } + + // Meaningful when false: dropping it would import an address WorkOS never + // confirmed as a verified one. + if (user.email_verified !== undefined) exported.email_verified = user.email_verified; + + const metadata = user.metadata; + if (metadata && typeof metadata === "object" && Object.keys(metadata).length > 0) { + exported.metadata = metadata; + } + + if (identities && identities.length > 0) exported.identities = identities; + + return exported; +} + +export type WorkOsExportResult = { + users: Record[]; + coverage: { label: string; count: number }[]; +}; + +export function buildWorkOsExport( + users: WorkOsUser[], + record: (line: UserLine) => void = () => {}, + identities?: Map, +): WorkOsExportResult { + const exported: Record[] = []; + const counts = { email: 0, firstName: 0, lastName: 0, metadata: 0 }; + + for (const user of users) { + const userId = String(user.id ?? ""); + try { + const mapped = mapWorkOsUserToExport(user, identities?.get(userId)); + exported.push(mapped); + + if (mapped.email) counts.email++; + if (mapped.first_name) counts.firstName++; + if (mapped.last_name) counts.lastName++; + if (mapped.metadata) counts.metadata++; + + record({ sourceId: userId, status: "exported" }); + } catch (error) { + record({ sourceId: userId, status: "skipped", error: (error as Error).message }); + } + } + + return { + users: exported, + coverage: [ + { label: "have an email address", count: counts.email }, + { label: "have a first name", count: counts.firstName }, + { label: "have a last name", count: counts.lastName }, + { label: "have metadata", count: counts.metadata }, + // Always present, always zero. WorkOS returns no digest for anyone, and + // seeing that before the import is the whole reason the row is here. + { label: "have a password (WorkOS returns none)", count: 0 }, + ], + }; +} + +export async function exportWorkOs(options: ExportWorkOsOptions): Promise { + const resolved = await resolveWorkOsApiKey(options); + + await withGutter("Exporting users from WorkOS", async () => { + if (!options.json) printTarget({ platform: "workos" }); + // Only WorkOS can say whether the key is live, for the right environment, + // and not revoked — so a rejected key is asked for again here. The page it + // fetches is kept and reused, so proving the key costs no extra request. + const { value: firstPage, input: apiKey } = await withInputRetry( + resolved, + async () => promptWorkOsApiKey(), + async (candidate) => + withSpinner("Authenticating with WorkOS...", async () => fetchWorkOsPage(candidate)), + ); + + const users = await withSpinner("Fetching users from WorkOS...", async (spinner) => + fetchAllWorkOsUsers({ apiKey, firstPage, spinner }), + ); + + const providers = (await resolveWithIdentities(options, users.length)) + ? await withSpinner("Fetching OAuth providers...", async (spinner) => + fetchAllWorkOsIdentities({ apiKey, users, spinner }), + ) + : undefined; + + const run = await startExportRun(options, { platform: "workos" }); + const { users: exported, coverage } = buildWorkOsExport( + users, + run.append, + providers?.identities, + ); + finishExport({ + run, + options, + users: exported, + coverage, + sections: providers + ? [buildIdentityReport(users, providers.identities, providers.failed)] + : [], + }); + + if (exported.length > 0) { + log.warn( + "WorkOS does not return password hashes or TOTP secrets, and there is no export that does. Imported users who signed in with a password must reset it on their first Clerk sign-in, and anyone using an authenticator app has to re-enrol. Users on SSO or social sign-in are unaffected.", + ); + log.info(dim(`See ${DOCS_URL}`)); + } + }); +} diff --git a/packages/cli-core/src/commands/migrate/index.test.ts b/packages/cli-core/src/commands/migrate/index.test.ts index 65f59bb349..f8803b6314 100644 --- a/packages/cli-core/src/commands/migrate/index.test.ts +++ b/packages/cli-core/src/commands/migrate/index.test.ts @@ -124,6 +124,20 @@ describe("registerMigrate", () => { ).toEqual(["--service-account", "--output", "--yes", "--runs-dir", "--json"]); }); + test("migrate export workos takes exactly its flags", () => { + expect( + findCommand(["migrate", "export", "workos"])?.options.map((option) => option.long), + ).toEqual([ + "--api-key", + "--with-identities", + "--no-with-identities", + "--output", + "--yes", + "--runs-dir", + "--json", + ]); + }); + test.each(exportPlatformKeys())( "migrate export %s names the run folder as the default output", (platform) => { diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 1e7ede25c2..3d43312e1e 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -2045,6 +2045,7 @@ describe("run", () => { ], "sb1", ], + ["workos", [{ id: "user_01ABC", email: "a@x.dev", email_verified: true }], "user_01ABC"], ]; test.each(PLATFORMS)( @@ -2176,7 +2177,7 @@ describe("run", () => { )) as CliError; expect(error.exitCode).toBe(EXIT_CODE.USAGE); expect(error.message).toContain( - 'Unknown source "nope". Valid sources: clerk, auth0, firebase, supabase.', + 'Unknown source "nope". Valid sources: clerk, auth0, firebase, supabase, workos.', ); expect(requests).toHaveLength(0); }); diff --git a/packages/cli-core/src/commands/migrate/sources/registry.ts b/packages/cli-core/src/commands/migrate/sources/registry.ts index 9ecfc0ba47..4bbd0b4378 100644 --- a/packages/cli-core/src/commands/migrate/sources/registry.ts +++ b/packages/cli-core/src/commands/migrate/sources/registry.ts @@ -13,8 +13,15 @@ import auth0Source from "./auth0.ts"; import clerkSource from "./clerk.ts"; import firebaseSource from "./firebase.ts"; import supabaseSource from "./supabase.ts"; +import workosSource from "./workos.ts"; -export const sources: SourceEntry[] = [clerkSource, auth0Source, firebaseSource, supabaseSource]; +export const sources: SourceEntry[] = [ + clerkSource, + auth0Source, + firebaseSource, + supabaseSource, + workosSource, +]; export const ACCOUNT_LINKING_URL = "https://clerk.com/docs/guides/configure/auth-strategies/social-connections/account-linking"; diff --git a/packages/cli-core/src/commands/migrate/sources/sources.test.ts b/packages/cli-core/src/commands/migrate/sources/sources.test.ts index c32a407c9b..e50955c168 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -46,7 +46,7 @@ const one = (key: string, record: Record, context = {}) => describe("registry", () => { test("registers the built-in platforms", () => { - expect(sourceKeys()).toEqual(["clerk", "auth0", "firebase", "supabase"]); + expect(sourceKeys()).toEqual(["clerk", "auth0", "firebase", "supabase", "workos"]); }); test.each([...sources])("$key maps a source field to userId", (source) => { @@ -193,6 +193,66 @@ describe("auth0", () => { }); }); +describe("workos", () => { + const base = { id: "user_01ABC", email: "a@x.dev" }; + + test("maps identity, name and metadata onto the Clerk schema", async () => { + const { users } = await load("workos", [ + { ...base, email_verified: true, first_name: "Ada", last_name: "Lovelace" }, + ]); + expect(users[0]).toMatchObject({ + userId: "user_01ABC", + email: "a@x.dev", + firstName: "Ada", + lastName: "Lovelace", + }); + }); + + test.each([ + [true, "email", undefined], + [false, undefined, "a@x.dev"], + [undefined, undefined, "a@x.dev"], + ])("email_verified=%p routes the address correctly", (verified, kept, unverified) => { + const user = one("workos", { ...base, email_verified: verified }); + expect(user?.email).toBe(kept ? "a@x.dev" : undefined); + expect(user?.unverifiedEmailAddresses).toBe(unverified); + }); + + test("sends metadata to unsafe metadata", async () => { + const { users } = await load("workos", [ + { ...base, email_verified: true, metadata: { plan: "pro" } }, + ]); + expect(users[0]?.unsafeMetadata).toEqual({ plan: "pro" }); + }); + + // The WorkOS id stays the Clerk external_id; the tenant's own ID is kept + // where users cannot edit it. + test("puts WorkOS's external_id in private metadata", async () => { + const { users } = await load("workos", [ + { ...base, email_verified: true, external_id: "cust_1" }, + ]); + expect(users[0]?.userId).toBe("user_01ABC"); + expect(users[0]?.privateMetadata).toEqual({ workosExternalId: "cust_1" }); + expect("workosExternalId" in (users[0] ?? {})).toBe(false); + }); + + // No other transformer omits it. WorkOS never returns a digest, so naming a + // hasher would imply a password column that cannot exist. + test("names no password hasher, because WorkOS returns no hashes", () => { + expect(getSource("workos").defaults).toBeUndefined(); + }); + + // The export carries these so whoever runs the migration can see who used + // social sign-in; Clerk's import has no field for them. + test("drops OAuth identities carried through from the export", async () => { + const { users } = await load("workos", [ + { ...base, email_verified: true, identities: [{ provider: "GoogleOAuth", idp_id: "1" }] }, + ]); + expect(users[0]?.userId).toBe("user_01ABC"); + expect("identities" in (users[0] ?? {})).toBe(false); + }); +}); + describe("firebase", () => { const base = { localId: "fb1", email: "a@x.dev", emailVerified: true }; diff --git a/packages/cli-core/src/commands/migrate/sources/workos.ts b/packages/cli-core/src/commands/migrate/sources/workos.ts new file mode 100644 index 0000000000..beab06bd8e --- /dev/null +++ b/packages/cli-core/src/commands/migrate/sources/workos.ts @@ -0,0 +1,59 @@ +import type { SourceEntry } from "../types.ts"; +import { routeByVerification } from "./shared.ts"; + +/** + * WorkOS → Clerk transformer. + * + * Works with WorkOS's User Management API. `id` is a `user_…` string and is + * carried through as the Clerk user's `external_id`. WorkOS's own `external_id` + * — the tenant's app ID, when they set one — has nowhere else to go, so it lands + * in private metadata as `workosExternalId`: private because users can edit + * unsafe metadata, and an ID other records key on must not be editable. + * + * **There is no `passwordHasher` default here, and that is deliberate.** Every + * other source names the hasher its platform ships so a digest can be + * verified; WorkOS returns no digest to verify. It accepts password hashes on + * import and never gives them back, and its TOTP secrets are returned on enrol + * only — so a WorkOS migration moves identities, not credentials. Naming a + * hasher here would imply a password column that cannot exist. + * + * WorkOS has no phone number and no username, which is why the map is short: + * those fields have nothing to come from. + */ +const workosSource = { + key: "workos", + label: "WorkOS", + description: + "Works with WorkOS's User Management API. WorkOS returns no password hashes, so imported users sign in by reset or SSO.", + carries: { + passwords: { + level: "no", + note: "WorkOS never returns password hashes. Users reset their password, or sign in with SSO.", + }, + mfa: { level: "no", note: "WorkOS returns TOTP secrets at enrolment only." }, + metadata: { + level: "yes", + note: "`metadata` → unsafe metadata. WorkOS's `external_id` → private metadata `workosExternalId`; the WorkOS `id` becomes the Clerk external ID.", + }, + }, + transformer: { + id: "userId", + email: "email", + email_verified: "emailVerified", + first_name: "firstName", + last_name: "lastName", + metadata: "unsafeMetadata", + external_id: "workosExternalId", + created_at: "createdAt", + }, + postTransform: (user) => { + routeByVerification(user, "email", "emailVerified", "boolean"); + + if (user.workosExternalId) { + user.privateMetadata = { workosExternalId: String(user.workosExternalId) }; + } + delete user.workosExternalId; + }, +} satisfies SourceEntry; + +export default workosSource; diff --git a/packages/cli-core/src/commands/migrate/wizard.test.ts b/packages/cli-core/src/commands/migrate/wizard.test.ts index 6906f0c3d9..77b24deb14 100644 --- a/packages/cli-core/src/commands/migrate/wizard.test.ts +++ b/packages/cli-core/src/commands/migrate/wizard.test.ts @@ -66,6 +66,7 @@ describe("promptForSource", () => { "auth0", "firebase", "supabase", + "workos", ]); }); diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index 1eb4ecb749..764b3f3e5e 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -230,7 +230,7 @@ describe("generateCompletions", () => { "--source", "", ]).completions.map((c) => c.name); - expect(names).toEqual(["clerk", "auth0", "firebase", "supabase"]); + expect(names).toEqual(["clerk", "auth0", "firebase", "supabase", "workos"]); } finally { if (saved === undefined) delete process.env.CLERK_EXPERIMENTAL; else process.env.CLERK_EXPERIMENTAL = saved; From 596028c760cd34be022900acddff6d609373f457 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 12:55:38 -0400 Subject: [PATCH 02/21] docs(changeset): Add clerk migrate export workos and WorkOS imports Co-Authored-By: Claude Opus 5.5 --- .changeset/migrate-workos.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/migrate-workos.md diff --git a/.changeset/migrate-workos.md b/.changeset/migrate-workos.md new file mode 100644 index 0000000000..1232d1da84 --- /dev/null +++ b/.changeset/migrate-workos.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk migrate export workos`, and let `clerk migrate import` read WorkOS exports. WorkOS returns no password hashes, so imported users reset their password or sign in with SSO. `clerk init` now also flags a WorkOS install and links the migration guide. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`. From 9aab4d9e73d60251a10450811ba4e1e04bec292f Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 14:47:37 -0400 Subject: [PATCH 03/21] fix(migrate): never prompt for a WorkOS key or identities under --json Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/workos.test.ts | 10 ++++++++++ .../cli-core/src/commands/migrate/export/workos.ts | 8 +++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/workos.test.ts b/packages/cli-core/src/commands/migrate/export/workos.test.ts index 89e34a2a11..81fed5b38c 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.test.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.test.ts @@ -114,6 +114,11 @@ describe("resolveWorkOsApiKey", () => { /Missing: --api-key \(or WORKOS_API_KEY\)\./, ); }); + + test("does not prompt under --json, even with a human at the TTY", async () => { + process.env.CLERK_MODE = "human"; + await expect(resolveWorkOsApiKey({ json: true }, {})).rejects.toThrow(/cannot prompt here/); + }); }); describe("fetchWorkOsPage", () => { @@ -202,6 +207,11 @@ describe("resolveWithIdentities", () => { } }); + test("is off under --json, even with a human at the TTY", async () => { + process.env.CLERK_MODE = "human"; + expect(await resolveWithIdentities({ json: true }, 10)).toBe(false); + }); + test("does not ask when there are no users to ask about", async () => { process.env.CLERK_MODE = "human"; expect(await resolveWithIdentities({}, 0)).toBe(false); diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts index ee002cfe36..9c66cf533c 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -91,7 +91,7 @@ export async function resolveWorkOsApiKey( if (resolved) return resolved.trim(); - if (isAgent() || !isHuman()) { + if (options.json || isAgent() || !isHuman()) { throwUsageError( "`clerk migrate export workos` needs a WorkOS API key and cannot prompt here.\n" + "Missing: --api-key (or WORKOS_API_KEY).", @@ -219,7 +219,8 @@ export async function fetchAllWorkOsUsers(options: { * returns can be imported, because `POST /v1/users` has no external-accounts * field. It is a line in the coverage report, and a record kept in the file. * - * Agent mode gets the flag's answer and no question: there is nobody to ask. + * Agent mode and `--json` get the flag's answer and no question: there is + * nobody to ask. * `-y` answers the question the way a `yes` would, so `--no-with-identities` * is the way to say no without being asked. */ @@ -230,7 +231,7 @@ export async function resolveWithIdentities( if (options.withIdentities !== undefined) return options.withIdentities; if (userCount === 0) return false; if (isAssumeYes()) return true; - if (isAgent() || !isHuman()) return false; + if (options.json || isAgent() || !isHuman()) return false; return confirm({ message: `Also fetch each user's OAuth providers? That is ${userCount} extra request${userCount === 1 ? "" : "s"}, and the result is report-only — Clerk's import cannot take external accounts.`, @@ -459,6 +460,7 @@ export async function exportWorkOs(options: ExportWorkOsOptions): Promise async () => promptWorkOsApiKey(), async (candidate) => withSpinner("Authenticating with WorkOS...", async () => fetchWorkOsPage(candidate)), + options, ); const users = await withSpinner("Fetching users from WorkOS...", async (spinner) => From d4c121cec7176e1bad246e2baf8bbe9aa297cfe6 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 14:47:59 -0400 Subject: [PATCH 04/21] fix(migrate): stop paging WorkOS users when the cursor does not move Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/workos.test.ts | 12 ++++++++++++ .../cli-core/src/commands/migrate/export/workos.ts | 9 +++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/workos.test.ts b/packages/cli-core/src/commands/migrate/export/workos.test.ts index 81fed5b38c..2a83fb4323 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.test.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.test.ts @@ -166,6 +166,18 @@ describe("fetchAllWorkOsUsers", () => { expect(requests).toHaveLength(1); }); + test("stops when WorkOS hands back the cursor it was given", async () => { + globalThis.fetch = (async (input: string | URL | Request) => { + requests.push(input.toString()); + return Response.json({ data: [workosUser(0)], list_metadata: { after: "stuck" } }); + }) as unknown as typeof fetch; + + await expect(fetchAllWorkOsUsers({ apiKey: API_KEY })).rejects.toThrow( + /same pagination cursor twice \(stuck\)/, + ); + expect(requests).toHaveLength(2); + }); + test("reuses a page already fetched rather than asking twice", async () => { stubWorkOs([[workosUser(0)]]); const firstPage = await fetchWorkOsPage(API_KEY); diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts index 9c66cf533c..983d348a2b 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -16,7 +16,7 @@ * discovered when nobody can sign in. */ -import { throwUsageError } from "../../../lib/errors.ts"; +import { CliError, throwUsageError } from "../../../lib/errors.ts"; import { loggedFetch } from "../../../lib/fetch.ts"; import { dim } from "../../../lib/color.ts"; import { log } from "../../../lib/log.ts"; @@ -200,7 +200,12 @@ export async function fetchAllWorkOsUsers(options: { // Counted in pages rather than users: a short page would knock a // `users % N` check off its multiple and silence every later one. for (let pages = 1; page.after; pages++) { - page = await fetchWorkOsPage(options.apiKey, page.after); + const cursor = page.after; + page = await fetchWorkOsPage(options.apiKey, cursor); + // A cursor that does not move would refetch the same page forever. + if (page.after === cursor) { + throw new CliError(`WorkOS returned the same pagination cursor twice (${cursor}).`); + } all.push(...page.users); options.spinner?.update(`Fetching users from WorkOS: ${all.length} so far...`); if (!isHuman() && pages % USER_PROGRESS_EVERY_PAGES === 0) { From 438b046dda247fc0ee84a58febd1b9a25977a1ff Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 16:41:30 -0400 Subject: [PATCH 05/21] fix(migrate): stop the WorkOS identity fan-out on Ctrl-C or a rejected key Every lookup error counted as an unreadable user, so a key revoked partway marked the rest unreadable and the export finished as if it had worked, and a Ctrl-C left the queued requests running. Both now stop the fan-out and are thrown; other failures are still counted. Co-Authored-By: Claude Opus 5.5 --- .../commands/migrate/export/workos.test.ts | 19 +++++++++++++++++++ .../src/commands/migrate/export/workos.ts | 17 +++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/workos.test.ts b/packages/cli-core/src/commands/migrate/export/workos.test.ts index 2a83fb4323..62ce24dcb8 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.test.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; // Colour is on or off depending on the runner, so rows are compared bare. import { stripVTControlCharacters as stripAnsi } from "node:util"; import type { UserLine } from "../lib/run-store.ts"; +import { CliError, EXIT_CODE } from "../../../lib/errors.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; import { setAssumeYes } from "../lib/assume-yes.ts"; import { @@ -265,6 +266,24 @@ describe("fetchAllWorkOsIdentities", () => { }); }); +// A key revoked partway would otherwise mark every later user unreadable and +// let the export finish as if it had worked. +test("fetchAllWorkOsIdentities stops on a rejected key rather than counting it", async () => { + globalThis.fetch = (async (input: string | URL | Request) => { + requests.push(input.toString()); + return Response.json({ message: "Unauthorized" }, { status: 401 }); + }) as unknown as typeof fetch; + const users = Array.from({ length: 50 }, (_, i) => workosUser(i)); + + const error = (await fetchAllWorkOsIdentities({ apiKey: API_KEY, users }).catch( + (e: unknown) => e, + )) as CliError; + + expect(error).toBeInstanceOf(CliError); + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(requests.length).toBeLessThan(users.length); +}); + describe("buildIdentityReport", () => { const rowsOf = (section: { rows: string[] }) => section.rows.map((row) => stripAnsi(row).trimEnd()); diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts index 983d348a2b..61dc46cfdb 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -16,10 +16,11 @@ * discovered when nobody can sign in. */ -import { CliError, throwUsageError } from "../../../lib/errors.ts"; +import { CliError, EXIT_CODE, throwUsageError } from "../../../lib/errors.ts"; import { loggedFetch } from "../../../lib/fetch.ts"; import { dim } from "../../../lib/color.ts"; import { log } from "../../../lib/log.ts"; +import { isCancelled } from "../../../lib/signals.ts"; import { confirm, password as passwordPrompt } from "../../../lib/prompts.ts"; import { withGutter, withSpinner, type SpinnerControls } from "../../../lib/spinner.ts"; import { isAgent, isHuman } from "../../../mode.ts"; @@ -275,6 +276,9 @@ export async function fetchWorkOsIdentities( * A user missing from the returned map is one whose lookup **failed**, which is * not the same as one with no providers — so failures are counted and returned * separately rather than flattened into an empty list. + * + * A Ctrl-C, or a key WorkOS stops accepting partway, is not a failed lookup: + * every later one would fail the same way. It stops the fan-out and is thrown. */ export async function fetchAllWorkOsIdentities(options: { apiKey: string; @@ -286,14 +290,22 @@ export async function fetchAllWorkOsIdentities(options: { const identities = new Map(); let failed = 0; let done = 0; + let fatal: unknown; await Promise.all( options.users.map(async (user) => schedule(async () => { + if (fatal) return; const userId = String(user.id ?? ""); try { if (userId) identities.set(userId, await fetchWorkOsIdentities(options.apiKey, userId)); - } catch { + } catch (error) { + const stops = + isCancelled(error) || (error instanceof CliError && error.exitCode === EXIT_CODE.USAGE); + if (stops) { + fatal ??= error; + return; + } failed++; } done++; @@ -305,6 +317,7 @@ export async function fetchAllWorkOsIdentities(options: { ), ); + if (fatal) throw fatal; return { identities, failed }; } From b1d4c4d617ec1e18960482f71b357c9290e8e93e Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 16:41:49 -0400 Subject: [PATCH 06/21] fix(migrate): encode the WorkOS user ID in the identities path Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/workos.test.ts | 8 ++++++++ .../cli-core/src/commands/migrate/export/workos.ts | 13 ++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/workos.test.ts b/packages/cli-core/src/commands/migrate/export/workos.test.ts index 62ce24dcb8..acb25fe49a 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.test.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.test.ts @@ -266,6 +266,14 @@ describe("fetchAllWorkOsIdentities", () => { }); }); +test("fetchWorkOsIdentities keeps the user ID inside its path segment", async () => { + stubWorkOs([[]]); + await fetchWorkOsIdentities(API_KEY, "user_01/../x?y#z"); + expect(requests[0]).toBe( + "https://api.workos.com/user_management/users/user_01%2F..%2Fx%3Fy%23z/identities", + ); +}); + // A key revoked partway would otherwise mark every later user unreadable and // let the export finish as if it had worked. test("fetchAllWorkOsIdentities stops on a rejected key rather than counting it", async () => { diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts index 61dc46cfdb..572b6633e6 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -250,11 +250,14 @@ export async function fetchWorkOsIdentities( apiKey: string, userId: string, ): Promise { - const response = await loggedFetch(new URL(`${API_BASE}/users/${userId}/identities`), { - tag: "workos", - method: "GET", - headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, - }); + const response = await loggedFetch( + new URL(`${API_BASE}/users/${encodeURIComponent(userId)}/identities`), + { + tag: "workos", + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + }, + ); if (!response.ok) { throwApiFailure( From 852ad3701c75e93b2640919ff07b726b28ebddbb Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 19:35:17 -0400 Subject: [PATCH 07/21] test(migrate): check the email each platform's import sends, not just its source ID Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/run.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 3d43312e1e..d0924628cf 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -2056,6 +2056,11 @@ describe("run", () => { await run({ ...baseOptions, source: key }); expect(created()).toEqual([externalId]); + // Each export's verified email is the one the create sends. + const create = requests.find( + (request) => request.method === "POST" && new URL(request.url).pathname === "/v1/users", + ); + expect(create?.body).toMatchObject({ email_address: ["a@x.dev"] }); }, ); From c069f80ba86a7dca370279ea9d099b3ff952cdd7 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 20:44:21 -0400 Subject: [PATCH 08/21] fix(migrate): do not prompt for a missing WorkOS key under -y Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/workos.test.ts | 11 +++++++++++ .../cli-core/src/commands/migrate/export/workos.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/export/workos.test.ts b/packages/cli-core/src/commands/migrate/export/workos.test.ts index acb25fe49a..3908979d2c 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.test.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.test.ts @@ -120,6 +120,17 @@ describe("resolveWorkOsApiKey", () => { process.env.CLERK_MODE = "human"; await expect(resolveWorkOsApiKey({ json: true }, {})).rejects.toThrow(/cannot prompt here/); }); + + // `-y` is "do not prompt", at a terminal too, as for every other export. + test("does not prompt for a missing key under -y", async () => { + process.env.CLERK_MODE = "human"; + setAssumeYes(true); + try { + await expect(resolveWorkOsApiKey({}, {})).rejects.toThrow(/cannot prompt here/); + } finally { + setAssumeYes(false); + } + }); }); describe("fetchWorkOsPage", () => { diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts index 572b6633e6..5b6c4208c1 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -92,7 +92,7 @@ export async function resolveWorkOsApiKey( if (resolved) return resolved.trim(); - if (options.json || isAgent() || !isHuman()) { + if (options.json || isAssumeYes() || isAgent() || !isHuman()) { throwUsageError( "`clerk migrate export workos` needs a WorkOS API key and cannot prompt here.\n" + "Missing: --api-key (or WORKOS_API_KEY).", From 8fc29532239da9b6b7d09daa36e7b93330a8561e Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 13:42:48 -0400 Subject: [PATCH 09/21] fix(migrate): send WorkOS metadata to private metadata, not the user-editable unsafe Only a backend with the secret key can set WorkOS metadata, and apps trust it for a plan or a tier. In unsafe_metadata the user could change it straight after the migration. Private matches who can read it too: the client sees none of it unless a JWT template puts a key in the token, and those keys belong in public metadata, which the README says to move them to. external_id is merged in as workosExternalId rather than replacing it. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 13 +++++++++---- .../src/commands/migrate/sources/sources.test.ts | 8 +++++--- .../cli-core/src/commands/migrate/sources/workos.ts | 13 ++++++++++--- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index c756757b89..67ab6f69c2 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -1059,10 +1059,15 @@ to enable any that are off. Auth0's come from each `user_id`'s prefix ### Metadata Metadata a user can edit on the source platform — Auth0's `user_metadata`, -Supabase's `raw_user_meta_data`, WorkOS's `metadata` — goes to Clerk's -`unsafe_metadata`, which is the user-editable one. Public metadata is read-only -to the user, so putting it there would take away an edit the user had. Auth0's -`app_metadata` goes to `private_metadata`. A Clerk Dashboard CSV carries no metadata. +Supabase's `raw_user_meta_data` — goes to Clerk's `unsafe_metadata`, which is +the user-editable one. Public metadata is read-only to the user, so putting it +there would take away an edit the user had. Metadata only the source's backend +can set — Auth0's `app_metadata`, WorkOS's `metadata` — goes to +`private_metadata`, which the client cannot read either. Apps often trust these +for a plan or a role, and `unsafe_metadata` would let the user change them. A +key an app put in its tokens (an Auth0 Action claim, a WorkOS JWT template) +belongs in `public_metadata`, and in the Clerk session token: move it after the +import. A Clerk Dashboard CSV carries no metadata. ### Verified vs unverified identifiers diff --git a/packages/cli-core/src/commands/migrate/sources/sources.test.ts b/packages/cli-core/src/commands/migrate/sources/sources.test.ts index e50955c168..afa8ceeefb 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -218,11 +218,13 @@ describe("workos", () => { expect(user?.unverifiedEmailAddresses).toBe(unverified); }); - test("sends metadata to unsafe metadata", async () => { + // Backend-set on WorkOS, so never where the user could change it in Clerk. + test("sends metadata to private metadata, with external_id merged in", async () => { const { users } = await load("workos", [ - { ...base, email_verified: true, metadata: { plan: "pro" } }, + { ...base, email_verified: true, metadata: { plan: "pro" }, external_id: "cust_1" }, ]); - expect(users[0]?.unsafeMetadata).toEqual({ plan: "pro" }); + expect(users[0]?.privateMetadata).toEqual({ plan: "pro", workosExternalId: "cust_1" }); + expect(users[0]?.unsafeMetadata).toBeUndefined(); }); // The WorkOS id stays the Clerk external_id; the tenant's own ID is kept diff --git a/packages/cli-core/src/commands/migrate/sources/workos.ts b/packages/cli-core/src/commands/migrate/sources/workos.ts index beab06bd8e..3348de8776 100644 --- a/packages/cli-core/src/commands/migrate/sources/workos.ts +++ b/packages/cli-core/src/commands/migrate/sources/workos.ts @@ -33,7 +33,7 @@ const workosSource = { mfa: { level: "no", note: "WorkOS returns TOTP secrets at enrolment only." }, metadata: { level: "yes", - note: "`metadata` → unsafe metadata. WorkOS's `external_id` → private metadata `workosExternalId`; the WorkOS `id` becomes the Clerk external ID.", + note: "`metadata` → private metadata, with WorkOS's `external_id` as `workosExternalId`; the WorkOS `id` becomes the Clerk external ID. Keys an app exposed through a WorkOS JWT template belong in public metadata: move them after the import.", }, }, transformer: { @@ -42,7 +42,10 @@ const workosSource = { email_verified: "emailVerified", first_name: "firstName", last_name: "lastName", - metadata: "unsafeMetadata", + // Only a backend with the secret key can set it, and the client never sees + // it unless a JWT template puts a key in the token: private, not the + // user-editable unsafe metadata, which an app could be trusting for a plan. + metadata: "privateMetadata", external_id: "workosExternalId", created_at: "createdAt", }, @@ -50,7 +53,11 @@ const workosSource = { routeByVerification(user, "email", "emailVerified", "boolean"); if (user.workosExternalId) { - user.privateMetadata = { workosExternalId: String(user.workosExternalId) }; + const metadata = + user.privateMetadata && typeof user.privateMetadata === "object" + ? user.privateMetadata + : {}; + user.privateMetadata = { ...metadata, workosExternalId: String(user.workosExternalId) }; } delete user.workosExternalId; }, From 284a1e05c76e7676a75826b4c9ba770e0e71cdd1 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 13:44:21 -0400 Subject: [PATCH 10/21] fix(migrate): stop the WorkOS identity fan-out at once on a rejected key, and leave it off under -y - A rejected key stopped later lookups only once each had waited out its paced slot: 40 minutes for a 50,000-user export. The scheduler's stop now drains the queue as soon as the key is refused. - `-y` turned the fan-out on, one request per user for data the import cannot use. It now takes the prompt's default, no, so the lookups need --with-identities. - A user with no id is counted as failed, so the report's totals add up. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 22 +++---- .../src/commands/migrate/export/index.ts | 2 +- .../commands/migrate/export/workos.test.ts | 34 ++++++++++- .../src/commands/migrate/export/workos.ts | 58 +++++++++++-------- 4 files changed, 77 insertions(+), 39 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 67ab6f69c2..ed1b294333 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -552,8 +552,8 @@ becomes 1,010 for a thousand users. Nothing it returns can be imported — breakdown in the coverage report, and an `identities` array kept in the export file for whoever runs the migration. The interactive path asks once, after the user count is known, defaulting to no; agent mode takes the flag's answer and -asks nothing. `-y` answers the question `yes`, so pass -`--no-with-identities` to skip the fan-out without being asked. +asks nothing. So does `-y`, which takes the prompt's default, no: pass +`--with-identities` to fetch them without being asked. The breakdown prints as its own **OAuth providers** block under the coverage table, not as extra coverage rows: @@ -1214,15 +1214,15 @@ instance settings: the checks print the `clerk config patch` to run instead. Three exports talk to their own platform rather than to Clerk: -| Method | Path | Used by | -| ------ | ---------------------------------------------- | -------------------------------------------------------- | -| `POST` | `https:///oauth/token` | `export auth0` — Management API access token | -| `GET` | `https:///api/v2/users` | `export auth0` — 100 per page, 1000 users maximum | -| `POST` | `https://oauth2.googleapis.com/token` | `export firebase` — RS256 assertion → access token | -| `GET` | `…/v1/projects/{project_id}/accounts:batchGet` | `export firebase` — pages users, 1000 at a time | -| `GET` | `…/admin/v2/projects/{project_id}/config` | `export firebase` — reads the scrypt hash parameters | -| `GET` | `…/user_management/users` | `export workos` — 100 per page, cursor-paginated | -| `GET` | `…/user_management/users/{id}/identities` | `export workos` — `--with-identities` only, one per user | +| Method | Path | Used by | +| ------ | ---------------------------------------------- | ----------------------------------------------------------------- | +| `POST` | `https:///oauth/token` | `export auth0` — Management API access token | +| `GET` | `https:///api/v2/users` | `export auth0` — 100 per page, 1000 users maximum | +| `POST` | `https://oauth2.googleapis.com/token` | `export firebase` — RS256 assertion → access token | +| `GET` | `…/v1/projects/{project_id}/accounts:batchGet` | `export firebase` — pages users, 1000 at a time | +| `GET` | `…/admin/v2/projects/{project_id}/config` | `export firebase` — reads the scrypt hash parameters | +| `GET` | `…/user_management/users` | `export workos` — 100 per page, cursor-paginated | +| `GET` | `…/user_management/users/{id}/identities` | `export workos` — when identity lookups are enabled, one per user | The two Identity Toolkit paths are on `identitytoolkit.googleapis.com`, or on `FIREBASE_AUTH_EMULATOR_HOST` when that is set. The emulator needs no token, so diff --git a/packages/cli-core/src/commands/migrate/export/index.ts b/packages/cli-core/src/commands/migrate/export/index.ts index c33e1779a9..b6c2a2f8fa 100644 --- a/packages/cli-core/src/commands/migrate/export/index.ts +++ b/packages/cli-core/src/commands/migrate/export/index.ts @@ -184,7 +184,7 @@ export function registerMigrateExport(migrateCommand: Command<[], Record", "Write the export here instead of the run folder") .option( "-y, --yes", - "Do not prompt: fail on a rejected credential, and assume --with-identities", + "Do not prompt: fail on a rejected credential, and skip the OAuth provider fan-out unless --with-identities", ) .option(RUNS_DIR_FLAG, RUNS_DIR_DESCRIPTION) .option("--json", "Print the result as JSON; never prompts") diff --git a/packages/cli-core/src/commands/migrate/export/workos.test.ts b/packages/cli-core/src/commands/migrate/export/workos.test.ts index 3908979d2c..43cb1c155f 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.test.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.test.ts @@ -220,12 +220,13 @@ describe("resolveWithIdentities", () => { } }); - // `-y` is "answer the prompts yes", and the prompt is "also fetch providers?". - test("is on under -y, which answers the question rather than asking it", async () => { + // One request per user, for nothing the import can use: `-y` takes the + // prompt's default rather than committing to it unasked. + test("is off under -y, which takes the prompt's default", async () => { process.env.CLERK_MODE = "human"; setAssumeYes(true); try { - expect(await resolveWithIdentities({}, 10)).toBe(true); + expect(await resolveWithIdentities({}, 10)).toBe(false); } finally { setAssumeYes(false); } @@ -303,6 +304,33 @@ test("fetchAllWorkOsIdentities stops on a rejected key rather than counting it", expect(requests.length).toBeLessThan(users.length); }); +// At 20 a second, 400 queued users would otherwise spend 20 seconds taking +// paced turns only to be skipped. +test("fetchAllWorkOsIdentities stops at once on a rejected key, without pacing the rest", async () => { + globalThis.fetch = (async (input: string | URL | Request) => { + requests.push(input.toString()); + return Response.json({ message: "Unauthorized" }, { status: 401 }); + }) as unknown as typeof fetch; + const users = Array.from({ length: 400 }, (_, i) => workosUser(i)); + + const started = performance.now(); + await fetchAllWorkOsIdentities({ apiKey: API_KEY, users }).catch(() => {}); + + expect(performance.now() - started).toBeLessThan(3000); +}); + +test("fetchAllWorkOsIdentities counts a user with no id as failed", async () => { + stubWorkOs([[]], { user_00: [] }); + + const { identities, failed } = await fetchAllWorkOsIdentities({ + apiKey: API_KEY, + users: [workosUser(0), { ...workosUser(1), id: undefined }], + }); + + expect(identities.size).toBe(1); + expect(failed).toBe(1); +}); + describe("buildIdentityReport", () => { const rowsOf = (section: { rows: string[] }) => section.rows.map((row) => stripAnsi(row).trimEnd()); diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts index 5b6c4208c1..1abff98eff 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -225,10 +225,9 @@ export async function fetchAllWorkOsUsers(options: { * returns can be imported, because `POST /v1/users` has no external-accounts * field. It is a line in the coverage report, and a record kept in the file. * - * Agent mode and `--json` get the flag's answer and no question: there is - * nobody to ask. - * `-y` answers the question the way a `yes` would, so `--no-with-identities` - * is the way to say no without being asked. + * Agent mode, `--json` and `-y` get the flag's answer and no question: there + * is nobody to ask, and `-y` takes the prompt's default, no, rather than + * committing to one request per user unasked. */ export async function resolveWithIdentities( options: ExportWorkOsOptions, @@ -236,7 +235,7 @@ export async function resolveWithIdentities( ): Promise { if (options.withIdentities !== undefined) return options.withIdentities; if (userCount === 0) return false; - if (isAssumeYes()) return true; + if (isAssumeYes()) return false; if (options.json || isAgent() || !isHuman()) return false; return confirm({ @@ -294,29 +293,40 @@ export async function fetchAllWorkOsIdentities(options: { let failed = 0; let done = 0; let fatal: unknown; + // Aborted with `fatal`: each user still queued then runs at once and + // returns, rather than spending a paced slot first. A Ctrl-C reaches here as + // a cancelled request, and sets `fatal` like a rejected key. + const halt = new AbortController(); await Promise.all( options.users.map(async (user) => - schedule(async () => { - if (fatal) return; - const userId = String(user.id ?? ""); - try { - if (userId) identities.set(userId, await fetchWorkOsIdentities(options.apiKey, userId)); - } catch (error) { - const stops = - isCancelled(error) || (error instanceof CliError && error.exitCode === EXIT_CODE.USAGE); - if (stops) { - fatal ??= error; - return; + schedule( + async () => { + if (fatal) return; + const userId = String(user.id ?? ""); + try { + // Counted, so the report's totals still add up. + if (!userId) failed++; + else identities.set(userId, await fetchWorkOsIdentities(options.apiKey, userId)); + } catch (error) { + const stops = + isCancelled(error) || + (error instanceof CliError && error.exitCode === EXIT_CODE.USAGE); + if (stops) { + fatal ??= error; + halt.abort(); + return; + } + failed++; } - failed++; - } - done++; - options.spinner?.update(`Fetching OAuth providers: ${done}/${total}...`); - if (!isHuman() && done % IDENTITY_PROGRESS_EVERY === 0) { - log.info(`Fetched OAuth providers for ${done}/${total} users...`); - } - }), + done++; + options.spinner?.update(`Fetching OAuth providers: ${done}/${total}...`); + if (!isHuman() && done % IDENTITY_PROGRESS_EVERY === 0) { + log.info(`Fetched OAuth providers for ${done}/${total} users...`); + } + }, + { stop: halt.signal }, + ), ), ); From 6eb3fc198908f66752b2771d70d7e3d496beb4f3 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 13:45:06 -0400 Subject: [PATCH 11/21] fix(init): detect WorkOS by its AuthKit packages, and link the current migration guide `@workos-inc/node` also serves WorkOS SSO, Directory Sync and Audit Logs for apps that sign users in with something else, so it no longer counts. The WorkOS entry links /docs/guides/development/migrating/overview, which the old /docs/migrations/overview redirects to. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/init/scan.test.ts | 14 ++++++++++++++ packages/cli-core/src/commands/init/scan.ts | 12 ++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/init/scan.test.ts b/packages/cli-core/src/commands/init/scan.test.ts index a047eeb027..45781c5d18 100644 --- a/packages/cli-core/src/commands/init/scan.test.ts +++ b/packages/cli-core/src/commands/init/scan.test.ts @@ -70,6 +70,20 @@ describe("detectAuthLibraries", () => { expect(output).toContain("Kinde"); }); + test.each(["@workos-inc/authkit-nextjs", "@workos-inc/authkit-react"])( + "detects WorkOS via %s", + (pkg) => { + runDetectAuthLibraries({ [pkg]: "1.0.0" }); + expect(captured.out).toContain("WorkOS"); + }, + ); + + // The Node SDK also serves WorkOS SSO and Directory Sync alone. + test("does not call @workos-inc/node alone WorkOS auth", () => { + runDetectAuthLibraries({ "@workos-inc/node": "7.0.0" }); + expect(captured.out).not.toContain("WorkOS"); + }); + test("detects multiple auth libraries", () => { runDetectAuthLibraries({ "next-auth": "5.0.0", firebase: "11.0.0" }); const output = captured.out; diff --git a/packages/cli-core/src/commands/init/scan.ts b/packages/cli-core/src/commands/init/scan.ts index 3aba1a8485..5d46735893 100644 --- a/packages/cli-core/src/commands/init/scan.ts +++ b/packages/cli-core/src/commands/init/scan.ts @@ -55,9 +55,17 @@ const AUTH_LIBRARY_SCANS: AuthLibraryScan[] = [ docsUrl: "https://clerk.com/docs/migrations/overview", }, { - packages: ["@workos-inc/authkit-nextjs", "@workos-inc/node"], + // AuthKit only: `@workos-inc/node` also serves SSO, Directory Sync and + // Audit Logs for apps that authenticate with something else. + packages: [ + "@workos-inc/authkit-nextjs", + "@workos-inc/authkit-react", + "@workos-inc/authkit-react-router", + "@workos-inc/authkit-remix", + "@workos-inc/authkit-js", + ], name: "WorkOS", - docsUrl: "https://clerk.com/docs/migrations/overview", + docsUrl: "https://clerk.com/docs/guides/development/migrating/overview", }, { packages: ["@kinde-oss/kinde-auth-nextjs"], From 084b83f148abdd3af1d82f428e6942c81839649a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 16:16:25 -0400 Subject: [PATCH 12/21] fix(migrate): let a blank --api-key fall back to WORKOS_API_KEY A whitespace-only --api-key won over a set WORKOS_API_KEY and went out as an empty bearer token. The key is now trimmed before the fallback. The README says the identities prompt appears only when there are users, and how a WorkOS user's id and external_id map into Clerk. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 9 ++++++--- .../cli-core/src/commands/migrate/export/workos.test.ts | 6 ++++++ packages/cli-core/src/commands/migrate/export/workos.ts | 5 +++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index ed1b294333..90390fa6bd 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -551,9 +551,10 @@ becomes 1,010 for a thousand users. Nothing it returns can be imported — `POST /v1/users` has no external-accounts field — so it buys a provider breakdown in the coverage report, and an `identities` array kept in the export file for whoever runs the migration. The interactive path asks once, after the -user count is known, defaulting to no; agent mode takes the flag's answer and -asks nothing. So does `-y`, which takes the prompt's default, no: pass -`--with-identities` to fetch them without being asked. +user count is known and only if it isn't zero, defaulting to no; agent mode +takes the flag's answer and asks nothing. So does `-y`, which takes the +prompt's default, no: pass `--with-identities` to fetch them without being +asked. The breakdown prints as its own **OAuth providers** block under the coverage table, not as extra coverage rows: @@ -1065,6 +1066,8 @@ there would take away an edit the user had. Metadata only the source's backend can set — Auth0's `app_metadata`, WorkOS's `metadata` — goes to `private_metadata`, which the client cannot read either. Apps often trust these for a plan or a role, and `unsafe_metadata` would let the user change them. A +WorkOS user's `id` becomes Clerk's `external_id`, and its own `external_id`, if +it has one, is kept as `private_metadata.workosExternalId`. A key an app put in its tokens (an Auth0 Action claim, a WorkOS JWT template) belongs in `public_metadata`, and in the Clerk session token: move it after the import. A Clerk Dashboard CSV carries no metadata. diff --git a/packages/cli-core/src/commands/migrate/export/workos.test.ts b/packages/cli-core/src/commands/migrate/export/workos.test.ts index 43cb1c155f..e66c7c8ba7 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.test.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.test.ts @@ -109,6 +109,12 @@ describe("resolveWorkOsApiKey", () => { expect(await resolveWorkOsApiKey({}, { WORKOS_API_KEY: "sk_env" })).toBe("sk_env"); }); + test("a blank flag falls back to the environment", async () => { + expect(await resolveWorkOsApiKey({ apiKey: " " }, { WORKOS_API_KEY: "sk_env" })).toBe( + "sk_env", + ); + }); + // Tests run non-TTY, the same signal an agent gives. test("names the flag and the variable when neither supplied one", async () => { await expect(resolveWorkOsApiKey({}, {})).rejects.toThrow( diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts index 1abff98eff..2d74a23511 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -88,9 +88,10 @@ export async function resolveWorkOsApiKey( options: ExportWorkOsOptions, env: Record = process.env, ): Promise { - const resolved = options.apiKey ?? env.WORKOS_API_KEY; + // Trimmed before the fallback: a blank flag must not hide a set variable. + const resolved = options.apiKey?.trim() || env.WORKOS_API_KEY?.trim(); - if (resolved) return resolved.trim(); + if (resolved) return resolved; if (options.json || isAssumeYes() || isAgent() || !isHuman()) { throwUsageError( From 45cca2bb1cb16dc4607023bbe35e321c2f573afd Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 19:00:20 -0400 Subject: [PATCH 13/21] docs(migrate): say only an interactive terminal prompts for the WorkOS key Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 90390fa6bd..ce7356ae41 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -537,7 +537,8 @@ not fix it, and it exits 1. Needs a secret API key — the one starting `sk_`, from the WorkOS dashboard under API Keys. Resolved from `--api-key`, then `WORKOS_API_KEY`, then a -prompt; agent mode exits naming both instead. +prompt at an interactive terminal; `--json`, `-y` and agent mode exit naming +both instead. There is no `--db-url` sibling because there is no database to point it at. WorkOS is API-only: apps commonly mirror users into their own store through From aae72da1d852de09b38ad5d66384b0aafbb1807a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 19:32:35 -0400 Subject: [PATCH 14/21] feat(init): flag AuthKit used through the WorkOS Node SDK The package scan names only the AuthKit packages, since @workos-inc/node also serves SSO and Directory Sync for apps that sign in with something else. A code scan for workos.userManagement now catches an AuthKit app built on the Node SDK alone. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/init/scan.test.ts | 13 +++++++++++++ packages/cli-core/src/commands/init/scan.ts | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/packages/cli-core/src/commands/init/scan.test.ts b/packages/cli-core/src/commands/init/scan.test.ts index 45781c5d18..65e50c01e2 100644 --- a/packages/cli-core/src/commands/init/scan.test.ts +++ b/packages/cli-core/src/commands/init/scan.test.ts @@ -197,6 +197,19 @@ describe("scanForIssues", () => { expect(findings.some((f) => f.message.includes("Passport"))).toBe(true); }); + // `@workos-inc/node` alone also serves SSO and Directory Sync, so the code + // names AuthKit, not the package. + test("detects AuthKit through the WorkOS Node SDK", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/auth.ts"), + "const user = await workos.userManagement.authenticateWithCode({ code });\n", + ); + + const findings = await scanForIssues(tempDir, "next"); + expect(findings.some((f) => f.message.includes("WorkOS AuthKit"))).toBe(true); + }); + test("returns correct line number", async () => { await mkdir(join(tempDir, "src"), { recursive: true }); await Bun.write( diff --git a/packages/cli-core/src/commands/init/scan.ts b/packages/cli-core/src/commands/init/scan.ts index 5d46735893..ec3e09ae68 100644 --- a/packages/cli-core/src/commands/init/scan.ts +++ b/packages/cli-core/src/commands/init/scan.ts @@ -124,6 +124,12 @@ const CODE_SCANS: CodeScan[] = [ pattern: "import\\s.*from\\s+['\"]passport['\"]", message: "Passport import still in use", }, + { + // AuthKit through the general Node SDK, which the package scan leaves out. + pattern: "\\bworkos\\.userManagement\\b", + message: "WorkOS AuthKit usage still in use", + docsUrl: "https://clerk.com/docs/guides/development/migrating/overview", + }, { pattern: "getServerSession\\s*\\(", message: "NextAuth getServerSession() call still in use", From e63bd0cb94d1ebf8a1ecba77462432c89b8a9711 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 23:05:47 -0400 Subject: [PATCH 15/21] fix(init): call the WorkOS Node SDK finding User Management, not AuthKit workos.userManagement also serves OAuth and SSO sign-in, and every such user is one export workos reads from /user_management/users. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/init/scan.test.ts | 17 ++++++++++++++--- packages/cli-core/src/commands/init/scan.ts | 5 +++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/init/scan.test.ts b/packages/cli-core/src/commands/init/scan.test.ts index 65e50c01e2..97dbc47161 100644 --- a/packages/cli-core/src/commands/init/scan.test.ts +++ b/packages/cli-core/src/commands/init/scan.test.ts @@ -198,8 +198,8 @@ describe("scanForIssues", () => { }); // `@workos-inc/node` alone also serves SSO and Directory Sync, so the code - // names AuthKit, not the package. - test("detects AuthKit through the WorkOS Node SDK", async () => { + // names User Management, not the package. + test("detects User Management through the WorkOS Node SDK", async () => { await mkdir(join(tempDir, "src"), { recursive: true }); await Bun.write( join(tempDir, "src/auth.ts"), @@ -207,7 +207,18 @@ describe("scanForIssues", () => { ); const findings = await scanForIssues(tempDir, "next"); - expect(findings.some((f) => f.message.includes("WorkOS AuthKit"))).toBe(true); + expect(findings.map((f) => f.message)).toContain("WorkOS User Management usage still in use"); + }); + + test("does not flag WorkOS SSO alone", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write( + join(tempDir, "src/auth.ts"), + "const { profile } = await workos.sso.getProfileAndToken({ code, clientId });\n", + ); + + const findings = await scanForIssues(tempDir, "next"); + expect(findings.some((f) => f.message.includes("WorkOS"))).toBe(false); }); test("returns correct line number", async () => { diff --git a/packages/cli-core/src/commands/init/scan.ts b/packages/cli-core/src/commands/init/scan.ts index ec3e09ae68..b9acf5ca27 100644 --- a/packages/cli-core/src/commands/init/scan.ts +++ b/packages/cli-core/src/commands/init/scan.ts @@ -125,9 +125,10 @@ const CODE_SCANS: CodeScan[] = [ message: "Passport import still in use", }, { - // AuthKit through the general Node SDK, which the package scan leaves out. + // WorkOS users through the general Node SDK, which the package scan leaves + // out. The `sso` and `directorySync` modules alone hold no users. pattern: "\\bworkos\\.userManagement\\b", - message: "WorkOS AuthKit usage still in use", + message: "WorkOS User Management usage still in use", docsUrl: "https://clerk.com/docs/guides/development/migrating/overview", }, { From 3c63c5a886a00cff8e155b80c2cdf95566bf21d5 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 20 Oct 2026 17:03:14 -0400 Subject: [PATCH 16/21] fix(migrate): back off on a WorkOS 429 instead of failing the export A 429 while paging ended the export and dropped the pages already read, and one during `--with-identities` counted that user as not readable on the first try. Both reads now go through `retryOn429`, honouring Retry-After, as the Auth0 and Clerk exports do. Paging that runs out of retries stops with the reason; an identity lookup that does is the only kind still counted as not readable. Co-Authored-By: Claude Opus 5.5 --- .../commands/migrate/export/workos.test.ts | 65 +++++++++++++++++++ .../src/commands/migrate/export/workos.ts | 49 ++++++++++---- 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/workos.test.ts b/packages/cli-core/src/commands/migrate/export/workos.test.ts index e66c7c8ba7..85e7ede92e 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.test.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.test.ts @@ -100,6 +100,23 @@ function stubWorkOs( }) as unknown as typeof fetch; } +/** + * Wraps the stubbed fetch so the first `times` requests matching `pattern` + * answer 429, with a short Retry-After so the backoff takes milliseconds. + */ +function throttle(pattern: RegExp, times: number) { + const served = globalThis.fetch; + let throttled = 0; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + if (pattern.test(input.toString()) && throttled < times) { + throttled++; + requests.push(input.toString()); + return new Response("Too Many Requests", { status: 429, headers: { "retry-after": "0.01" } }); + } + return served(input, init); + }) as unknown as typeof fetch; +} + describe("resolveWorkOsApiKey", () => { test("prefers the flag", async () => { expect(await resolveWorkOsApiKey({ apiKey: "sk_flag" }, {})).toBe("sk_flag"); @@ -176,6 +193,26 @@ describe("fetchAllWorkOsUsers", () => { expect(requests[1]).toContain("after=cursor1"); }); + // A 429 used to end the export and drop the pages already read. + test("backs off on a 429 while paging, and carries on", async () => { + stubWorkOs([[workosUser(0)], [workosUser(1)]]); + throttle(/after=cursor1/, 2); + + const all = await fetchAllWorkOsUsers({ apiKey: API_KEY }); + + expect(all).toHaveLength(2); + expect(requests.filter((url) => url.includes("after=cursor1"))).toHaveLength(3); + }); + + test("stops with the reason once paging runs out of retries", async () => { + stubWorkOs([[workosUser(0)], [workosUser(1)]]); + throttle(/after=cursor1/, 100); + + await expect(fetchAllWorkOsUsers({ apiKey: API_KEY })).rejects.toThrow( + /WorkOS kept rate-limiting the API \(429, 5 retries\)\. Nothing was exported\./, + ); + }); + // Cursor pagination has no record ceiling, so a full page that happens to be // the last one must not read as "there is more". test("stops on a full final page", async () => { @@ -282,6 +319,34 @@ describe("fetchAllWorkOsIdentities", () => { expect(identities.get("user_01")).toEqual([]); expect(failed).toBe(1); }); + + // A 429 used to count the user as not readable on the first try. + test("backs off on a 429 rather than counting the user as not readable", async () => { + stubWorkOs([[]], { user_00: [{ provider: "GitHubOAuth", idp_id: "g", type: "OAuth" }] }); + throttle(/user_00\/identities/, 2); + + const { identities, failed } = await fetchAllWorkOsIdentities({ + apiKey: API_KEY, + users: [workosUser(0)], + }); + + expect(identities.get("user_00")).toHaveLength(1); + expect(failed).toBe(0); + }); + + test("counts a user not readable only once its retries run out", async () => { + stubWorkOs([[]], { user_01: [] }); + throttle(/user_00\/identities/, 100); + + const { identities, failed } = await fetchAllWorkOsIdentities({ + apiKey: API_KEY, + users: [workosUser(0), workosUser(1)], + }); + + expect(identities.has("user_00")).toBe(false); + expect(identities.get("user_01")).toEqual([]); + expect(failed).toBe(1); + }); }); test("fetchWorkOsIdentities keeps the user ID inside its path segment", async () => { diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts index 2d74a23511..c8a0b771d2 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -16,7 +16,7 @@ * discovered when nobody can sign in. */ -import { CliError, EXIT_CODE, throwUsageError } from "../../../lib/errors.ts"; +import { ApiError, CliError, EXIT_CODE, throwUsageError } from "../../../lib/errors.ts"; import { loggedFetch } from "../../../lib/fetch.ts"; import { dim } from "../../../lib/color.ts"; import { log } from "../../../lib/log.ts"; @@ -28,6 +28,7 @@ import type { UserLine } from "../lib/run-store.ts"; import { printTarget } from "../lib/target.ts"; import { isAssumeYes } from "../lib/assume-yes.ts"; import { throwApiFailure, withInputRetry } from "../lib/input-retry.ts"; +import { RateLimitExceededError, retryOn429 } from "../lib/retry.ts"; import { createApiScheduler } from "../lib/scheduler.ts"; import { finishExport, startExportRun, type ExportSection } from "./shared.ts"; @@ -149,6 +150,26 @@ async function describeFailure(response: Response): Promise { export type WorkOsPage = { users: WorkOsUser[]; after?: string }; +/** + * One WorkOS read, backing off on a 429 as the Auth0 and Clerk exports do. + * WorkOS allows 1,000 reads per 10 seconds per environment, shared with the + * live app, so a busy tenant can push an export over it. + */ +async function getFromWorkOs(apiKey: string, url: URL): Promise { + return retryOn429(async () => { + const response = await loggedFetch(url, { + tag: "workos", + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + }); + // Thrown as an ApiError so `retryOn429` backs off, honouring Retry-After. + if (response.status === 429) { + throw new ApiError(429, await response.text(), response.headers); + } + return response; + }); +} + /** * Fetches one page of users. * @@ -161,11 +182,17 @@ export async function fetchWorkOsPage(apiKey: string, after?: string): Promise { - const response = await loggedFetch( + // Retries run out as a RateLimitExceededError, which the fan-out counts as + // one user not readable, like any other failed lookup. + const response = await getFromWorkOs( + apiKey, new URL(`${API_BASE}/users/${encodeURIComponent(userId)}/identities`), - { - tag: "workos", - method: "GET", - headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, - }, ); if (!response.ok) { From 60529d58f3b785e0782272b9b239f8d62fb85352 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 20 Oct 2026 17:05:04 -0400 Subject: [PATCH 17/21] fix(migrate): give --json an export section's values, not its terminal rows An export section held its rows pre-formatted, so `--json`'s `sections` came back padded and wrapped in color codes. A section now holds `{ label, count }` items and an optional note, `--json` carries them as they are, and the terminal formats them when it prints. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 3 ++ .../commands/migrate/export/shared.test.ts | 11 +++++++ .../src/commands/migrate/export/shared.ts | 21 +++++++++++-- .../commands/migrate/export/workos.test.ts | 11 +++++-- .../src/commands/migrate/export/workos.ts | 31 +++++++------------ 5 files changed, 53 insertions(+), 24 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index ce7356ae41..3156139194 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -583,6 +583,9 @@ A lookup that fails is counted on its own row rather than folded into `no OAuth provider`. "Lookup failed" and "has no providers" are different facts, and flattening the first into the second would understate social sign-in. +A lookup is retried on a `429`, honouring `Retry-After`, and counted as not +readable only once its retries run out. `--json` carries the block as +`sections: [{ title, items: [{ label, count }], note? }]`. Non-interactive runs get progress on stderr every 500 users during the fan-out, and every 10 pages during the user fetch. `withSpinner` hands a no-op to diff --git a/packages/cli-core/src/commands/migrate/export/shared.test.ts b/packages/cli-core/src/commands/migrate/export/shared.test.ts index fbe57ebec5..3c2a03aa44 100644 --- a/packages/cli-core/src/commands/migrate/export/shared.test.ts +++ b/packages/cli-core/src/commands/migrate/export/shared.test.ts @@ -59,6 +59,17 @@ describe("finishExport", () => { expect(readRun(runsDir, record.id)).toMatchObject({ status: "partial", truncated: true }); }); + // Padding and color codes belong to the terminal, not to --json. + test("carries a section's items into --json as plain values", async () => { + const run = await startExportRun({ runsDir }, { platform: "workos" }); + const sections = [{ title: "OAuth providers", items: [{ label: "GoogleOAuth", count: 2 }] }]; + + finishExport({ run, options: { json: true }, users, coverage, sections }); + + expect(JSON.parse(captured.out).sections).toEqual(sections); + expect(captured.out).not.toContain("\u001b"); + }); + // The file holds password hashes and PII, so no other local account reads it. test("writes the file owner-only, in an owner-only run folder", async () => { const run = await startExportRun({ runsDir }, { platform: "supabase" }); diff --git a/packages/cli-core/src/commands/migrate/export/shared.ts b/packages/cli-core/src/commands/migrate/export/shared.ts index 0a92447297..b3dc4bfac9 100644 --- a/packages/cli-core/src/commands/migrate/export/shared.ts +++ b/packages/cli-core/src/commands/migrate/export/shared.ts @@ -101,7 +101,24 @@ export function formatFieldCoverage(fields: CoverageField[], total: number): str * is not the user count. Folding that into the coverage table would put rows * of two different kinds under one heading. */ -export type ExportSection = { title: string; rows: string[] }; +export type ExportSection = { + title: string; + /** Printed as a padded table; `--json` carries them as they are. */ + items: { label: string; count: number }[]; + /** A caveat printed under the rows. */ + note?: string; +}; + +/** A section's terminal lines: labels padded to one width, counts dimmed. */ +export function formatSection(section: ExportSection): string[] { + const width = Math.max(0, ...section.items.map((item) => item.label.length)); + const lines = section.items.map( + ({ label, count }) => + ` ${label.padEnd(width)} ${dim(`${count} user${count === 1 ? "" : "s"}`)}`, + ); + if (section.note) lines.push(dim(` ${section.note}`)); + return lines; +} /** * The import command for an export run, and what it will target. @@ -218,7 +235,7 @@ export function finishExport(input: FinishExportInput): FinishedExport { for (const section of input.sections ?? []) { log.blank(); log.info(section.title); - for (const row of section.rows) log.info(row); + for (const line of formatSection(section)) log.info(line); } log.blank(); diff --git a/packages/cli-core/src/commands/migrate/export/workos.test.ts b/packages/cli-core/src/commands/migrate/export/workos.test.ts index 85e7ede92e..2c98243aea 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.test.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; // Colour is on or off depending on the runner, so rows are compared bare. import { stripVTControlCharacters as stripAnsi } from "node:util"; import type { UserLine } from "../lib/run-store.ts"; +import { formatSection } from "./shared.ts"; import { CliError, EXIT_CODE } from "../../../lib/errors.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; import { setAssumeYes } from "../lib/assume-yes.ts"; @@ -403,8 +404,8 @@ test("fetchAllWorkOsIdentities counts a user with no id as failed", async () => }); describe("buildIdentityReport", () => { - const rowsOf = (section: { rows: string[] }) => - section.rows.map((row) => stripAnsi(row).trimEnd()); + const rowsOf = (section: Parameters[0]) => + formatSection(section).map((row) => stripAnsi(row).trimEnd()); test("ranks providers by use, and counts users with none", () => { const section = buildIdentityReport( @@ -419,6 +420,12 @@ describe("buildIdentityReport", () => { ); expect(section.title).toBe("OAuth providers"); + // Plain values for --json; the terminal pads and dims them. + expect(section.items).toEqual([ + { label: "GoogleOAuth", count: 2 }, + { label: "MicrosoftOAuth", count: 1 }, + { label: "no OAuth provider", count: 2 }, + ]); expect(rowsOf(section)).toEqual([ " GoogleOAuth 2 users", " MicrosoftOAuth 1 user", diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts index c8a0b771d2..3c20092b52 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -395,28 +395,19 @@ export function buildIdentityReport( // tenant print the same order. const entries = [...byProvider].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); - const labels = [ - ...entries.map(([provider]) => provider), - NO_PROVIDER_LABEL, - ...(failed > 0 ? [NOT_READABLE_LABEL] : []), + const items = [ + ...entries.map(([label, count]) => ({ label, count })), + { label: NO_PROVIDER_LABEL, count: none }, + ...(failed > 0 ? [{ label: NOT_READABLE_LABEL, count: failed }] : []), ]; - const width = Math.max(...labels.map((label) => label.length)); - const row = (label: string, count: number) => - ` ${label.padEnd(width)} ${dim(`${count} user${count === 1 ? "" : "s"}`)}`; - const rows = [ - ...entries.map(([provider, count]) => row(provider, count)), - row(NO_PROVIDER_LABEL, none), - ]; - - if (failed > 0) { - rows.push(row(NOT_READABLE_LABEL, failed)); - rows.push( - dim(" Those users have no `identities` field in the export, rather than an empty one."), - ); - } - - return { title: "OAuth providers", rows }; + return { + title: "OAuth providers", + items, + ...(failed > 0 + ? { note: "Those users have no `identities` field in the export, rather than an empty one." } + : {}), + }; } /** From ee46a5c7a98f6f8e0db02b6696c0d8910db1d3e2 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 20 Oct 2026 17:05:15 -0400 Subject: [PATCH 18/21] fix(migrate): scope the WorkOS export warning to what the API and import do "There is no export that does" went past WorkOS's docs, which say only that the API returns no hashes. "Unaffected" overpromised: social users link only once that provider is on in Clerk, and SSO users need an enterprise connection first. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/export/workos.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts index 3c20092b52..a29f06d976 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -539,7 +539,7 @@ export async function exportWorkOs(options: ExportWorkOsOptions): Promise if (exported.length > 0) { log.warn( - "WorkOS does not return password hashes or TOTP secrets, and there is no export that does. Imported users who signed in with a password must reset it on their first Clerk sign-in, and anyone using an authenticator app has to re-enrol. Users on SSO or social sign-in are unaffected.", + "WorkOS's API does not return password hashes or TOTP secrets. Imported users who signed in with a password must reset it on their first Clerk sign-in, and anyone using an authenticator app has to re-enrol. SSO and social users don't need a reset, but set up those providers in Clerk first: social accounts link by verified email, and SSO needs an enterprise connection.", ); log.info(dim(`See ${DOCS_URL}`)); } From 48dcc04e05070f4bc47a178ecd4819204a8301dc Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 20 Oct 2026 17:05:24 -0400 Subject: [PATCH 19/21] docs(migrate): state the WorkOS fan-out's rate rather than a duration At 20 users a second, 50,000 users take well over the ten minutes the comment said. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/export/workos.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/migrate/export/workos.ts b/packages/cli-core/src/commands/migrate/export/workos.ts index a29f06d976..190677d2fb 100644 --- a/packages/cli-core/src/commands/migrate/export/workos.ts +++ b/packages/cli-core/src/commands/migrate/export/workos.ts @@ -51,7 +51,7 @@ const IDENTITY_RATE_PER_SECOND = 20; * * `withSpinner` hands a no-op `update` to anything that is not a TTY, so an * agent exporting 50,000 users with `--with-identities` would otherwise see - * nothing at all for the ten minutes the fan-out takes. These two print + * nothing at all while the fan-out runs, at 20 users a second. These two print * through `log.info` instead, which a non-TTY does get. */ const IDENTITY_PROGRESS_EVERY = 500; From c5e2a1ebfaf8d718720dbe8e21d2a177ca12668e Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 20 Oct 2026 17:05:28 -0400 Subject: [PATCH 20/21] fix(init): drop the doubled word from the WorkOS scan message Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/init/scan.test.ts | 2 +- packages/cli-core/src/commands/init/scan.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/init/scan.test.ts b/packages/cli-core/src/commands/init/scan.test.ts index 97dbc47161..0f3714afff 100644 --- a/packages/cli-core/src/commands/init/scan.test.ts +++ b/packages/cli-core/src/commands/init/scan.test.ts @@ -207,7 +207,7 @@ describe("scanForIssues", () => { ); const findings = await scanForIssues(tempDir, "next"); - expect(findings.map((f) => f.message)).toContain("WorkOS User Management usage still in use"); + expect(findings.map((f) => f.message)).toContain("WorkOS User Management still in use"); }); test("does not flag WorkOS SSO alone", async () => { diff --git a/packages/cli-core/src/commands/init/scan.ts b/packages/cli-core/src/commands/init/scan.ts index b9acf5ca27..25a2465178 100644 --- a/packages/cli-core/src/commands/init/scan.ts +++ b/packages/cli-core/src/commands/init/scan.ts @@ -128,7 +128,7 @@ const CODE_SCANS: CodeScan[] = [ // WorkOS users through the general Node SDK, which the package scan leaves // out. The `sso` and `directorySync` modules alone hold no users. pattern: "\\bworkos\\.userManagement\\b", - message: "WorkOS User Management usage still in use", + message: "WorkOS User Management still in use", docsUrl: "https://clerk.com/docs/guides/development/migrating/overview", }, { From b09f92f79a98e6f750040c4eb2002a0120c62d9e Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 20 Oct 2026 17:06:25 -0400 Subject: [PATCH 21/21] docs(migrate): scope WorkOS's missing password hashes to its API "No support-request escape hatch" went past what WorkOS documents, which covers importing hashes but says nothing either way about getting them back out. The README and the source's carries note now say the API returns none, and that WorkOS documents no other way. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 6 +++--- packages/cli-core/src/commands/migrate/sources/workos.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 3156139194..df844d2398 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -371,9 +371,9 @@ alongside imports. `auth0|` plus `alt_id`, or `_id` when there's no `alt_id`. Copy each `passwordHash` onto the matching user before importing, or migrate without passwords. -- **WorkOS** returns neither password hashes nor TOTP secrets, and has no - support-request escape hatch: hashes go in on import and never come back, and - `totp.secret` is returned on enrol only. There is nothing to add to the file. +- **WorkOS** returns neither password hashes nor TOTP secrets over the API: + hashes go in on import and never come back, and `totp.secret` is returned on + enrol only. WorkOS documents no other way to get them out. All three print a warning whenever they export anyone. `clerk`'s and `workos`'s coverage also counts users who _have_ a password, so the size of the gap is diff --git a/packages/cli-core/src/commands/migrate/sources/workos.ts b/packages/cli-core/src/commands/migrate/sources/workos.ts index 3348de8776..5952d8b222 100644 --- a/packages/cli-core/src/commands/migrate/sources/workos.ts +++ b/packages/cli-core/src/commands/migrate/sources/workos.ts @@ -28,7 +28,7 @@ const workosSource = { carries: { passwords: { level: "no", - note: "WorkOS never returns password hashes. Users reset their password, or sign in with SSO.", + note: "WorkOS's API never returns password hashes. Users reset their password, or sign in with SSO.", }, mfa: { level: "no", note: "WorkOS returns TOTP secrets at enrolment only." }, metadata: {