diff --git a/.changeset/keyless-bootstrap-keys.md b/.changeset/keyless-bootstrap-keys.md new file mode 100644 index 00000000..4f14e0f4 --- /dev/null +++ b/.changeset/keyless-bootstrap-keys.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Delegate keyless mode to the SDK during `clerk init` instead of writing temporary keys. When an authenticated user runs `clerk init` with an existing SDK keyless breadcrumb, automatically claim the app and pull real keys in one step. diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index bfcd52d8..0fd5e1c1 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -15,16 +15,16 @@ Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or 5. Waits for the redirect callback with an authorization code 6. Exchanges the code for an access token 7. Stores the token and user info in local config -8. **Autoclaim**: if `.clerk/keyless.json` exists in the current directory, claims the temporary application, links it to the project, and pulls environment variables +8. **Autoclaim**: if a keyless breadcrumb exists in the current directory (SDK's `.clerk/.tmp/keyless.json` or CLI's legacy `.clerk/keyless.json`), claims the temporary application, links it to the project, and pulls environment variables #### Keyless autoclaim breadcrumb lifecycle -When `clerk init` runs in keyless mode it writes `.clerk/keyless.json` containing a claim token. On the next `clerk auth login`: +When the Clerk SDK runs in keyless mode (no API keys in `.env`), it writes `.clerk/.tmp/keyless.json` containing the temporary keys and a claim URL. On the next `clerk auth login` (or `clerk init` when already authenticated): -- **404** — claim token expired or application already deleted; breadcrumb is cleared and a warning is shown. -- **403** — authenticated account has no active organization; breadcrumb is cleared and a warning is shown. -- **Any other error** — treated as transient; breadcrumb is preserved so the next login retries. -- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. +- **404** — claim token expired or application already deleted; both breadcrumbs are cleared and a warning is shown. +- **403** — authenticated account has no active organization; both breadcrumbs are cleared and a warning is shown. +- **Any other error** — treated as transient; breadcrumbs are preserved so the next login retries. +- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, both breadcrumbs are deleted. #### API Endpoints diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index bd5f6f5d..d5bff8c9 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -37,8 +37,8 @@ When running in agent mode (`--mode agent` or non-TTY), the command runs the ful - For **new projects** (`--starter` or blank directory): `--framework` is required (no way to auto-detect in an empty dir). Package manager is auto-selected by availability (bun → pnpm → yarn → npm) unless `--pm` is provided - Project name defaults to the framework's default (e.g. `my-clerk-next-app`) unless `--name` is provided - For keyless-capable frameworks with no `--app` and no linked profile: - - When **authenticated**, init creates a real Clerk app named after the project (`package.json#name`, `--name`, or directory basename) and links it. No keyless detour, no second `clerk auth login` to claim. - - When **unauthenticated**, init uses keyless and writes a breadcrumb so the next `clerk auth login` claims the app automatically. + - When **authenticated**, init first attempts to autoclaim any existing SDK keyless breadcrumb (`.clerk/.tmp/keyless.json`). If no breadcrumb exists, it creates a real Clerk app named after the project and links it. + - When **unauthenticated**, init uses keyless mode — the app scaffolds without API keys and the SDK handles keyless mode at runtime. - For frameworks that require API keys, init will not pick or create an app in agent mode; pass `--app ` or link the project first to pull real keys ## Flow @@ -47,11 +47,11 @@ When running in agent mode (`--mode agent` or non-TTY), the command runs the ful 2. Determines auth mode: - **Real app target** (`--app` or linked profile): authenticates, links if needed, and pulls real API keys into `.env` - **Agent + keyless-capable framework + authenticated + no real app target**: creates a real Clerk app named after the project, links it, and pulls real API keys into `.env` - - **Agent + keyless-capable framework + unauthenticated + no real app target**: uses keyless mode — the app runs on auto-generated dev keys and the user can connect a Clerk account later with `clerk auth login` + - **Agent + keyless-capable framework + unauthenticated + no real app target**: uses keyless mode — the SDK handles development keys at runtime and the user can connect a Clerk account later with `clerk auth login` - **Agent + non-keyless framework + no real app target**: scaffolds locally and prints manual setup instructions instead of selecting or creating an app - **Human mode + bootstrap + keyless-capable framework + not authenticated**: uses keyless mode - **Human mode + existing project + not authenticated**: runs the authenticated flow, which triggers an interactive login so real keys can be pulled -3. **Authenticated mode only**: authenticates via `clerk auth login` (skipped if already authenticated) and links the project via `clerk link` (skipped if already linked) +3. **Authenticated mode only**: attempts autoclaim of any SDK keyless breadcrumb; if none found, authenticates via `clerk auth login` (skipped if already authenticated) and links the project via `clerk link` (skipped if already linked) 4. Displays detected framework and variant 5. Detects existing auth libraries (NextAuth, Auth0, Supabase, Firebase, Passport, Better Auth, Kinde) and shows migration guidance 6. Installs the appropriate Clerk SDK (skips if already present) @@ -229,21 +229,18 @@ Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture se ## API Endpoints -| Step | Method | Base URL | Endpoint | Description | -| ---------------------- | ------ | ------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -| Create accountless app | `POST` | `CLERK_BAPI_URL` (default BAPI) | `/v1/accountless_applications` | Creates a temporary keyless Clerk application; returns `publishable_key`, `secret_key`, and `claim_url`. Only called in keyless mode. | - See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), and [env/README.md](../env/README.md) for the API endpoints used by each step. +## Autoclaim during init + +When an authenticated user runs `clerk init` without `--app`, the CLI checks for a keyless breadcrumb before falling through to the normal `authenticateAndLink` flow. If a breadcrumb is found, the CLI claims the temporary application via `POST /v1/platform/accountless_applications/claim`, links it locally, and pulls real API keys — skipping the interactive app picker entirely. + ## Keyless breadcrumb -In keyless mode, after calling `POST /v1/accountless_applications`, `clerk init` writes `.clerk/keyless.json` to the project root. This file records the claim token extracted from `claim_url` so that `clerk auth login` can automatically claim the temporary application the next time the user authenticates. +The CLI reads keyless breadcrumbs from two sources: -```json -{ - "claimToken": "", - "createdAt": "" -} -``` +1. **SDK breadcrumb** (`.clerk/.tmp/keyless.json`) — written by `@clerk/nextjs` (and other Clerk SDKs) at runtime when the app starts without API keys. Contains `publishableKey`, `secretKey`, `claimUrl`, and `apiKeysUrl`. The claim token is extracted from `claimUrl`. + +2. **CLI breadcrumb** (`.clerk/keyless.json`) — legacy format written by older versions of the CLI. Contains `claimToken` and `createdAt`. -`.clerk/` is automatically added to `.gitignore` when the breadcrumb is written. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed). +The SDK breadcrumb is checked first (it represents the most recent keyless state). Both breadcrumbs are cleared after a successful claim or terminal error (404/403). On transient failures (500, 429), breadcrumbs are preserved for retry on the next `clerk auth login`. diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index 6f015ff0..28f6199c 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -20,6 +20,7 @@ import * as skillsMod from "./skills.ts"; import * as bootstrapMod from "./bootstrap.ts"; import * as nextStepsMod from "../../lib/next-steps.ts"; import * as keylessMod from "../../lib/keyless.ts"; +import * as autoclaimMod from "../../lib/autoclaim.ts"; import { init } from "./index.ts"; const FAKE_CTX = { @@ -99,6 +100,7 @@ describe("init", () => { }), spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined), spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined), + spyOn(autoclaimMod, "attemptAutoclaim").mockResolvedValue({ status: "not_keyless" }), ]; return { gatherContextSpy, captured }; @@ -271,7 +273,7 @@ describe("init", () => { await init({}); expect(bootstrapMod.promptAndBootstrap).toHaveBeenCalled(); - expect(heuristics.printKeylessInfo).toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); expect(linkMod.link).not.toHaveBeenCalled(); }); @@ -339,7 +341,7 @@ describe("init", () => { await init({ yes: true }); expect(heuristics.isAuthenticated).toHaveBeenCalled(); - expect(heuristics.printKeylessInfo).toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); expect(linkMod.link).not.toHaveBeenCalled(); }); @@ -359,7 +361,7 @@ describe("init", () => { await init({}); - expect(heuristics.printKeylessInfo).toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); expect(linkMod.link).not.toHaveBeenCalled(); expect(pullMod.pull).not.toHaveBeenCalled(); }); @@ -904,4 +906,144 @@ describe("init", () => { cwd: FAKE_BOOTSTRAP.projectDir, }); }); + + // --- Autoclaim during init --- + + test("authenticated init autoclaims SDK breadcrumb and skips link + pull", async () => { + setup({ email: "user@example.com" }); + + const keylessCtx = { + ...FAKE_CTX, + existingClerk: false, + framework: { ...FAKE_CTX.framework, supportsKeyless: true }, + }; + spyOn(context, "gatherContext").mockResolvedValue(keylessCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }], + postInstructions: [], + }); + spyOn(autoclaimMod, "attemptAutoclaim").mockResolvedValue({ + status: "claimed", + app: { application_id: "app_claimed", name: "My App", instances: [] }, + envPulled: true, + }); + + await init({}); + + expect(autoclaimMod.attemptAutoclaim).toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + }); + + test("autoclaim with envPulled=false still pulls env", async () => { + setup({ email: "user@example.com" }); + + const keylessCtx = { + ...FAKE_CTX, + existingClerk: false, + framework: { ...FAKE_CTX.framework, supportsKeyless: true }, + }; + spyOn(context, "gatherContext").mockResolvedValue(keylessCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }], + postInstructions: [], + }); + spyOn(autoclaimMod, "attemptAutoclaim").mockResolvedValue({ + status: "claimed", + app: { application_id: "app_claimed", name: "My App", instances: [] }, + envPulled: false, + }); + + await init({}); + + expect(autoclaimMod.attemptAutoclaim).toHaveBeenCalled(); + // Claim succeeded so link should not be called again + expect(linkMod.link).not.toHaveBeenCalled(); + // But env pull failed during autoclaim, so init should retry it + expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: keylessCtx.cwd }); + }); + + test("autoclaim returning not_keyless falls through to authenticateAndLink", async () => { + setup({ email: "user@example.com" }); + + const keylessCtx = { + ...FAKE_CTX, + existingClerk: false, + framework: { ...FAKE_CTX.framework, supportsKeyless: true }, + }; + spyOn(context, "gatherContext").mockResolvedValue(keylessCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }], + postInstructions: [], + }); + spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never); + + await init({}); + + expect(autoclaimMod.attemptAutoclaim).toHaveBeenCalled(); + expect(pullMod.pull).toHaveBeenCalled(); + }); + + test("autoclaim returning failed falls through to authenticateAndLink", async () => { + setup({ email: "user@example.com" }); + + const keylessCtx = { + ...FAKE_CTX, + existingClerk: false, + framework: { ...FAKE_CTX.framework, supportsKeyless: true }, + }; + spyOn(context, "gatherContext").mockResolvedValue(keylessCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }], + postInstructions: [], + }); + spyOn(autoclaimMod, "attemptAutoclaim").mockResolvedValue({ + status: "failed", + error: new Error("temporary"), + }); + spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never); + + await init({}); + + expect(linkMod.link).not.toHaveBeenCalled(); + expect(pullMod.pull).toHaveBeenCalled(); + }); + + test("autoclaim is not attempted when --app is provided", async () => { + setup({ email: "user@example.com" }); + + const keylessCtx = { + ...FAKE_CTX, + existingClerk: false, + framework: { ...FAKE_CTX.framework, supportsKeyless: true }, + }; + spyOn(context, "gatherContext").mockResolvedValue(keylessCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }], + postInstructions: [], + }); + + await init({ app: "app_specific" }); + + expect(autoclaimMod.attemptAutoclaim).not.toHaveBeenCalled(); + }); + + test("autoclaim is not attempted when user is unauthenticated", async () => { + setup(); + + const keylessCtx = { + ...FAKE_CTX, + existingClerk: false, + framework: { ...FAKE_CTX.framework, supportsKeyless: true }, + }; + spyOn(context, "gatherContext").mockResolvedValueOnce(null).mockResolvedValueOnce(keylessCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }], + postInstructions: [], + }); + + await init({}); + + expect(autoclaimMod.attemptAutoclaim).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index bb558278..262bd05f 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -3,17 +3,12 @@ import { link } from "../link/index.js"; import { pull } from "../env/pull.js"; import { isAgent } from "../../mode.js"; import { dim, bold } from "../../lib/color.js"; -import { throwUserAbort, CliError, errorMessage } from "../../lib/errors.js"; +import { throwUserAbort, CliError } from "../../lib/errors.js"; import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js"; import { resolveProfile } from "../../lib/config.js"; import { deriveProjectName } from "../../lib/project-name.js"; import { log } from "../../lib/log.js"; -import { - createAccountlessApp, - writeKeysToEnvFile, - parseClaimToken, - writeKeylessBreadcrumb, -} from "../../lib/keyless.js"; +import { attemptAutoclaim } from "../../lib/autoclaim.js"; import { printNextSteps } from "../../lib/next-steps.js"; import { gatherContext, hasPackageJson } from "./context.js"; import { scaffold, enrichProjectContext } from "./scaffold.js"; @@ -26,7 +21,6 @@ import { writePlan, checkGitDirty, printOutro, - printKeylessInfo, getAuthenticatedEmail, isAuthenticated, } from "./heuristics.js"; @@ -107,12 +101,19 @@ export async function init(options: InitOptions = {}) { ? !hasRealAppTarget && !ctx.framework.supportsKeyless : bootstrap != null && overrides.skipConfirm && !authed); + let autoclaimPulled = false; if (!keyless && !manualSetup) { bar(); - const createIfMissing = agent - ? await deriveProjectName(ctx.cwd, bootstrap?.projectName) - : undefined; - await authenticateAndLink(ctx.cwd, options.app, createIfMissing); + + const autoclaim = !options.app && authed && (await tryInitAutoclaim(ctx.cwd)); + if (!autoclaim) { + const createIfMissing = agent + ? await deriveProjectName(ctx.cwd, bootstrap?.projectName) + : undefined; + await authenticateAndLink(ctx.cwd, options.app, createIfMissing); + } else { + autoclaimPulled = autoclaim.envPulled; + } } // Short-circuit on a fully-clean re-run so env pull / skills prompt don't @@ -134,10 +135,8 @@ export async function init(options: InitOptions = {}) { bar(); if (manualSetup) { printBootstrapManualSetupInfo(ctx.framework.name); - } else if (!keyless) { + } else if (!keyless && !autoclaimPulled) { await pull({ file: ctx.envFile, cwd: ctx.cwd }); - } else { - await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile); } if (options.skills !== false) { @@ -318,31 +317,19 @@ async function authenticateAndLink( await link({ skipIfLinked: true, app, cwd, createIfMissing }); } -// --- Keyless app setup --- +// --- Autoclaim --- -async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise { - try { - const app = await withSpinner("Creating development application...", () => - createAccountlessApp(frameworkDep), - ); - - await writeKeysToEnvFile(cwd, { - publishableKey: app.publishable_key, - secretKey: app.secret_key, - }); - - await writeKeylessBreadcrumb(cwd, parseClaimToken(app.claim_url)); - printKeylessInfo(envFile); - } catch (error) { - log.debug(`Could not create accountless app: ${errorMessage(error)}`); - const isTimeout = error instanceof Error && error.name === "AbortError"; - const prefix = isTimeout - ? "Could not reach api.clerk.com within 15s." - : "Could not set up development keys."; - log.warn( - `${prefix} Run \`clerk auth login\` then \`clerk link\` to connect your app manually.`, - ); +async function tryInitAutoclaim(cwd: string): Promise<{ envPulled: boolean } | false> { + const result = await attemptAutoclaim(cwd); + if (result.status === "claimed") { + const label = result.app.name || result.app.application_id; + log.success(`Claimed and linked \`${label}\``); + return { envPulled: result.envPulled }; } + if (result.status !== "not_keyless") { + log.debug(`init: autoclaim returned '${result.status}', falling through to link`); + } + return false; } // --- Detect & install --- diff --git a/packages/cli-core/src/lib/autoclaim.test.ts b/packages/cli-core/src/lib/autoclaim.test.ts index f8e2d4b6..fc5330e4 100644 --- a/packages/cli-core/src/lib/autoclaim.test.ts +++ b/packages/cli-core/src/lib/autoclaim.test.ts @@ -26,6 +26,7 @@ describe("attemptAutoclaim", () => { let linkAppSpy: ReturnType; let readBreadcrumbSpy: ReturnType; let clearBreadcrumbSpy: ReturnType; + let clearSdkBreadcrumbSpy: ReturnType; let pullSpy: ReturnType; beforeEach(async () => { @@ -39,7 +40,10 @@ describe("attemptAutoclaim", () => { profile: {} as Profile, }); clearBreadcrumbSpy = spyOn(keylessMod, "clearKeylessBreadcrumb").mockResolvedValue(undefined); - readBreadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb").mockResolvedValue(undefined); + clearSdkBreadcrumbSpy = spyOn(keylessMod, "clearSdkKeylessBreadcrumb").mockResolvedValue( + undefined, + ); + readBreadcrumbSpy = spyOn(keylessMod, "readAnyKeylessBreadcrumb").mockResolvedValue(undefined); pullSpy = spyOn(pullMod, "pull").mockResolvedValue(undefined); }); @@ -51,12 +55,13 @@ describe("attemptAutoclaim", () => { linkAppSpy.mockRestore(); readBreadcrumbSpy.mockRestore(); clearBreadcrumbSpy.mockRestore(); + clearSdkBreadcrumbSpy.mockRestore(); pullSpy.mockRestore(); await rm(tempDir, { recursive: true, force: true }); }); function withBreadcrumb(token = "valid_token") { - readBreadcrumbSpy.mockResolvedValue({ claimToken: token, createdAt: new Date().toISOString() }); + readBreadcrumbSpy.mockResolvedValue({ claimToken: token, source: "sdk" as const }); } function run() { @@ -98,16 +103,17 @@ describe("attemptAutoclaim", () => { expect(linkAppSpy).toHaveBeenCalled(); }); - test("clears breadcrumb after successful claim", async () => { + test("clears both breadcrumbs after successful claim", async () => { withBreadcrumb(); stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); await run(); expect(clearBreadcrumbSpy).toHaveBeenCalledWith(tempDir); + expect(clearSdkBreadcrumbSpy).toHaveBeenCalledWith(tempDir); }); - test("returns not_found and clears breadcrumb on 404", async () => { + test("returns not_found and clears both breadcrumbs on 404", async () => { withBreadcrumb("expired_token"); stubFetch(async () => new Response("Not Found", { status: 404 })); @@ -115,9 +121,10 @@ describe("attemptAutoclaim", () => { expect(result.status).toBe("not_found"); expect(clearBreadcrumbSpy).toHaveBeenCalled(); + expect(clearSdkBreadcrumbSpy).toHaveBeenCalled(); }); - test("returns no_organization and clears breadcrumb on 403", async () => { + test("returns no_organization and clears both breadcrumbs on 403", async () => { withBreadcrumb("forbidden_token"); stubFetch(async () => new Response("Forbidden", { status: 403 })); @@ -125,9 +132,10 @@ describe("attemptAutoclaim", () => { expect(result.status).toBe("no_organization"); expect(clearBreadcrumbSpy).toHaveBeenCalled(); + expect(clearSdkBreadcrumbSpy).toHaveBeenCalled(); }); - test("returns failed (preserves breadcrumb) on 400 — could be recoverable (e.g. 401 re-login)", async () => { + test("returns failed (preserves breadcrumbs) on 400 — could be recoverable (e.g. 401 re-login)", async () => { withBreadcrumb("bad_token"); stubFetch(async () => new Response("Bad Request", { status: 400 })); @@ -135,9 +143,10 @@ describe("attemptAutoclaim", () => { expect(result.status).toBe("failed"); expect(clearBreadcrumbSpy).not.toHaveBeenCalled(); + expect(clearSdkBreadcrumbSpy).not.toHaveBeenCalled(); }); - test("returns failed (preserves breadcrumb) on 429 rate limit", async () => { + test("returns failed (preserves breadcrumbs) on 429 rate limit", async () => { withBreadcrumb("rate_limited_token"); stubFetch(async () => new Response("Too Many Requests", { status: 429 })); @@ -145,9 +154,10 @@ describe("attemptAutoclaim", () => { expect(result.status).toBe("failed"); expect(clearBreadcrumbSpy).not.toHaveBeenCalled(); + expect(clearSdkBreadcrumbSpy).not.toHaveBeenCalled(); }); - test("returns failed on server error without clearing breadcrumb", async () => { + test("returns failed on server error without clearing breadcrumbs", async () => { withBreadcrumb("server_error_token"); stubFetch(async () => new Response("Internal Server Error", { status: 500 })); @@ -158,6 +168,7 @@ describe("attemptAutoclaim", () => { expect(result.error).toBeInstanceOf(Error); } expect(clearBreadcrumbSpy).not.toHaveBeenCalled(); + expect(clearSdkBreadcrumbSpy).not.toHaveBeenCalled(); }); test("does not call linkApp on failure", async () => { diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index c71bf053..2e68dd49 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -1,4 +1,8 @@ -import { readKeylessBreadcrumb, clearKeylessBreadcrumb } from "./keyless.ts"; +import { + readAnyKeylessBreadcrumb, + clearKeylessBreadcrumb, + clearSdkKeylessBreadcrumb, +} from "./keyless.ts"; import { claimApplication, type Application } from "./plapi.ts"; import { PlapiError, errorMessage } from "./errors.ts"; import { linkApp } from "./autolink.ts"; @@ -22,7 +26,7 @@ const TERMINAL_BY_STATUS: Record = { /** Orchestrates post-login claim of a keyless app. Never throws. */ export async function attemptAutoclaim(cwd: string): Promise { - const breadcrumb = await readKeylessBreadcrumb(cwd); + const breadcrumb = await readAnyKeylessBreadcrumb(cwd); if (!breadcrumb) return { status: "not_keyless" }; const appName = await deriveProjectName(cwd); @@ -31,6 +35,7 @@ export async function attemptAutoclaim(cwd: string): Promise { if (result.status === "failed") return result; await clearKeylessBreadcrumb(cwd); + await clearSdkKeylessBreadcrumb(cwd); if (result.status === "claimed") { const linked = await tryLinkApp(result.app, cwd); diff --git a/packages/cli-core/src/lib/keyless.test.ts b/packages/cli-core/src/lib/keyless.test.ts index b8da3885..cb868526 100644 --- a/packages/cli-core/src/lib/keyless.test.ts +++ b/packages/cli-core/src/lib/keyless.test.ts @@ -9,6 +9,9 @@ const { writeKeylessBreadcrumb, readKeylessBreadcrumb, clearKeylessBreadcrumb, + readSdkKeylessBreadcrumb, + clearSdkKeylessBreadcrumb, + readAnyKeylessBreadcrumb, writeKeysToEnvFile, createAccountlessApp, } = await import("./keyless.ts"); @@ -105,6 +108,150 @@ describe("breadcrumb", () => { }); }); +describe("SDK breadcrumb", () => { + let tempDir: string; + let debugSpy: ReturnType; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-sdk-keyless-test-")); + debugSpy = spyOn(console, "debug").mockImplementation(() => {}); + }); + + afterEach(async () => { + debugSpy.mockRestore(); + await rm(tempDir, { recursive: true, force: true }); + }); + + const SDK_BREADCRUMB = { + publishableKey: "pk_test_sdk", + secretKey: "sk_test_sdk", + claimUrl: "https://dashboard.clerk.com/apps/claim?token=sdk_token_123", + apiKeysUrl: "https://dashboard.clerk.com/apps/app_1/instances/ins_1/api-keys", + }; + + async function writeSdkBreadcrumb(data: object = SDK_BREADCRUMB) { + const dir = join(tempDir, ".clerk", ".tmp"); + const { mkdir } = await import("node:fs/promises"); + await mkdir(dir, { recursive: true }); + await Bun.write(join(dir, "keyless.json"), JSON.stringify(data)); + } + + test("readSdkKeylessBreadcrumb returns data when file is valid", async () => { + await writeSdkBreadcrumb(); + const result = await readSdkKeylessBreadcrumb(tempDir); + expect(result).toBeDefined(); + expect(result!.publishableKey).toBe("pk_test_sdk"); + expect(result!.claimUrl).toContain("sdk_token_123"); + }); + + test("readSdkKeylessBreadcrumb returns undefined when no file exists", async () => { + const result = await readSdkKeylessBreadcrumb(tempDir); + expect(result).toBeUndefined(); + }); + + test("readSdkKeylessBreadcrumb returns undefined when file has wrong shape", async () => { + await writeSdkBreadcrumb({ someOther: "data" }); + const captured = captureLog(); + const result = await captured.run(() => readSdkKeylessBreadcrumb(tempDir)); + expect(result).toBeUndefined(); + }); + + test("clearSdkKeylessBreadcrumb removes the file", async () => { + await writeSdkBreadcrumb(); + const captured = captureLog(); + await captured.run(() => clearSdkKeylessBreadcrumb(tempDir)); + const result = await readSdkKeylessBreadcrumb(tempDir); + expect(result).toBeUndefined(); + }); + + test("clearSdkKeylessBreadcrumb does not throw when file is missing", async () => { + const captured = captureLog(); + await captured.run(() => clearSdkKeylessBreadcrumb(tempDir)); + }); +}); + +describe("readAnyKeylessBreadcrumb", () => { + let tempDir: string; + let debugSpy: ReturnType; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-any-keyless-test-")); + debugSpy = spyOn(console, "debug").mockImplementation(() => {}); + }); + + afterEach(async () => { + debugSpy.mockRestore(); + await rm(tempDir, { recursive: true, force: true }); + }); + + async function writeSdkBreadcrumb(token = "sdk_token") { + const dir = join(tempDir, ".clerk", ".tmp"); + const { mkdir } = await import("node:fs/promises"); + await mkdir(dir, { recursive: true }); + await Bun.write( + join(dir, "keyless.json"), + JSON.stringify({ + publishableKey: "pk_test_sdk", + secretKey: "sk_test_sdk", + claimUrl: `https://dashboard.clerk.com/apps/claim?token=${token}`, + }), + ); + } + + test("returns undefined when neither breadcrumb exists", async () => { + const captured = captureLog(); + const result = await captured.run(() => readAnyKeylessBreadcrumb(tempDir)); + expect(result).toBeUndefined(); + }); + + test("returns SDK breadcrumb token when SDK file exists", async () => { + await writeSdkBreadcrumb("my_sdk_token"); + const captured = captureLog(); + const result = await captured.run(() => readAnyKeylessBreadcrumb(tempDir)); + expect(result).toBeDefined(); + expect(result!.claimToken).toBe("my_sdk_token"); + expect(result!.source).toBe("sdk"); + }); + + test("returns CLI breadcrumb token when CLI file exists", async () => { + await writeKeylessBreadcrumb(tempDir, "my_cli_token"); + const captured = captureLog(); + const result = await captured.run(() => readAnyKeylessBreadcrumb(tempDir)); + expect(result).toBeDefined(); + expect(result!.claimToken).toBe("my_cli_token"); + expect(result!.source).toBe("cli"); + }); + + test("prefers SDK breadcrumb when both exist", async () => { + await writeSdkBreadcrumb("preferred_sdk_token"); + await writeKeylessBreadcrumb(tempDir, "ignored_cli_token"); + const captured = captureLog(); + const result = await captured.run(() => readAnyKeylessBreadcrumb(tempDir)); + expect(result!.claimToken).toBe("preferred_sdk_token"); + expect(result!.source).toBe("sdk"); + }); + + test("falls back to CLI breadcrumb when SDK file has invalid claimUrl", async () => { + const dir = join(tempDir, ".clerk", ".tmp"); + const { mkdir } = await import("node:fs/promises"); + await mkdir(dir, { recursive: true }); + await Bun.write( + join(dir, "keyless.json"), + JSON.stringify({ + publishableKey: "pk_test_sdk", + secretKey: "sk_test_sdk", + claimUrl: "/no-token-param", + }), + ); + await writeKeylessBreadcrumb(tempDir, "fallback_cli_token"); + + const captured = captureLog(); + const result = await captured.run(() => readAnyKeylessBreadcrumb(tempDir)); + expect(result!.claimToken).toBe("fallback_cli_token"); + expect(result!.source).toBe("cli"); + }); +}); + describe("writeKeysToEnvFile", () => { let tempDir: string; diff --git a/packages/cli-core/src/lib/keyless.ts b/packages/cli-core/src/lib/keyless.ts index 2ccfea89..04b84ce0 100644 --- a/packages/cli-core/src/lib/keyless.ts +++ b/packages/cli-core/src/lib/keyless.ts @@ -3,7 +3,7 @@ import { mkdir, unlink } from "node:fs/promises"; import { getBapiBaseUrl } from "./environment.ts"; import { detectPublishableKeyName, detectSecretKeyName, detectEnvFile } from "./framework.ts"; import { parseEnvFile, mergeEnvVars, serializeEnvFile } from "./dotenv.ts"; -import { BapiError } from "./errors.ts"; +import { BapiError, errorMessage } from "./errors.ts"; import { loggedFetch } from "./fetch.ts"; import { log } from "./log.ts"; @@ -147,3 +147,77 @@ export async function clearKeylessBreadcrumb(cwd: string): Promise { // idempotent } } + +// --- SDK breadcrumb (.clerk/.tmp/keyless.json) --- + +const SDK_BREADCRUMB_DIR = ".clerk/.tmp"; + +interface SdkKeylessBreadcrumb { + publishableKey: string; + secretKey: string; + claimUrl: string; + apiKeysUrl?: string; +} + +function isSdkKeylessBreadcrumb(value: unknown): value is SdkKeylessBreadcrumb { + return ( + typeof value === "object" && + value !== null && + typeof (value as SdkKeylessBreadcrumb).publishableKey === "string" && + typeof (value as SdkKeylessBreadcrumb).secretKey === "string" && + typeof (value as SdkKeylessBreadcrumb).claimUrl === "string" + ); +} + +function sdkBreadcrumbPath(cwd: string): string { + return join(cwd, SDK_BREADCRUMB_DIR, BREADCRUMB_FILE); +} + +export async function readSdkKeylessBreadcrumb( + cwd: string, +): Promise { + try { + const data: unknown = await Bun.file(sdkBreadcrumbPath(cwd)).json(); + if (isSdkKeylessBreadcrumb(data)) return data; + log.debug("keyless: SDK breadcrumb has wrong shape, ignoring"); + return undefined; + } catch { + return undefined; + } +} + +export async function clearSdkKeylessBreadcrumb(cwd: string): Promise { + try { + await unlink(sdkBreadcrumbPath(cwd)); + log.debug("Cleared SDK keyless breadcrumb"); + } catch { + // idempotent + } +} + +// --- Unified breadcrumb reader --- + +export type NormalizedBreadcrumb = { claimToken: string; source: "cli" | "sdk" }; + +export async function readAnyKeylessBreadcrumb( + cwd: string, +): Promise { + const sdk = await readSdkKeylessBreadcrumb(cwd); + if (sdk) { + try { + const claimToken = parseClaimToken(sdk.claimUrl); + log.debug("keyless: found SDK breadcrumb (.clerk/.tmp/keyless.json)"); + return { claimToken, source: "sdk" }; + } catch (err) { + log.warn(`SDK keyless breadcrumb has invalid claimUrl: ${errorMessage(err)}`); + } + } + + const cli = await readKeylessBreadcrumb(cwd); + if (cli) { + log.debug("keyless: found CLI breadcrumb (.clerk/keyless.json)"); + return { claimToken: cli.claimToken, source: "cli" }; + } + + return undefined; +}