From 901e63f1ffe7932b6a3a0ae66e6431d3622095e6 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 14 Apr 2026 21:26:31 -0300 Subject: [PATCH 01/11] feat(errors): add AUTOCLAIM_FAILED error code Add machine-readable error code for autoclaim failures, available for agent mode and CI error classification. --- packages/cli-core/src/lib/errors.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 247322e9..3d6abf57 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -43,6 +43,8 @@ export const ERROR_CODE = { CATALOG_ERROR: "catalog_error", /** Doctor checks found issues. */ DOCTOR_FAILED: "doctor_failed", + /** Autoclaim of a keyless application failed. */ + AUTOCLAIM_FAILED: "autoclaim_failed", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; From ad9402afd589f51e14405ed533285b6780b2cd99 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 14 Apr 2026 21:26:36 -0300 Subject: [PATCH 02/11] feat(plapi): add claimApplication endpoint Add PLAPI client function for POST /v1/platform/accountless_applications/claim. Sends claim token and app name, returns the claimed Application. --- packages/cli-core/src/lib/plapi.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index caa5a743..13ae2e31 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -177,6 +177,27 @@ export async function createApplication(name: string): Promise { return response.json() as Promise; } +export async function claimApplication(token: string, name: string): Promise { + const authToken = await getAuthToken(); + const url = new URL("/v1/platform/accountless_applications/claim", getPlapiBaseUrl()); + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ token, name }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new PlapiError(response.status, body, url.toString()); + } + + return response.json() as Promise; +} + export async function listApplications(): Promise { const url = new URL("/v1/platform/applications", getPlapiBaseUrl()); const response = await plapiFetch("GET", url); From 289d40272297690282c858807bcb2f6d3f53367a Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 14 Apr 2026 21:26:40 -0300 Subject: [PATCH 03/11] refactor(autolink): extract shared linkApp function Extract profile-building logic from autolink() into a reusable linkApp() function. This allows both autolink (key detection) and autoclaim (keyless claim) to share the same profile persistence code. --- packages/cli-core/src/lib/autolink.ts | 60 ++++++++++++++----------- packages/cli-core/src/test/lib/stubs.ts | 1 + 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/packages/cli-core/src/lib/autolink.ts b/packages/cli-core/src/lib/autolink.ts index aa5a4b12..3f317242 100644 --- a/packages/cli-core/src/lib/autolink.ts +++ b/packages/cli-core/src/lib/autolink.ts @@ -76,6 +76,38 @@ export function matchKeyToApp( return undefined; } +/** + * Builds a Profile from an Application and persists it. + * Shared by autolink (key detection) and autoclaim (keyless claim). + * Returns the profile and its key, or undefined if no development instance exists. + */ +export async function linkApp( + app: Application, + cwd: string, +): Promise<{ path: string; profile: Profile } | undefined> { + const devInstance = app.instances.find((i) => i.environment_type === "development"); + if (!devInstance) return undefined; + + const prodInstance = app.instances.find((i) => i.environment_type === "production"); + + const normalizedRemote = await getGitNormalizedRemote(cwd); + const repoId = await getGitRepoIdentifier(cwd); + const profileKey = normalizedRemote ?? repoId ?? cwd; + + const profile: Profile = { + workspaceId: "", + appId: app.application_id, + appName: app.name, + instances: { + development: devInstance.instance_id, + ...(prodInstance ? { production: prodInstance.instance_id } : {}), + }, + }; + + await setProfile(profileKey, profile); + return { path: profileKey, profile }; +} + export async function autolink( cwd: string, ): Promise<{ path: string; profile: Profile } | undefined> { @@ -109,33 +141,11 @@ export async function autolink( `autolink: matched key from ${match.source} → app ${match.app.application_id} (${match.app.name ?? "unnamed"})`, ); - const normalizedRemote = await getGitNormalizedRemote(cwd); - const repoId = await getGitRepoIdentifier(cwd); - const profileKey = normalizedRemote ?? repoId ?? cwd; - - const devInstance = match.app.instances.find((i) => i.environment_type === "development"); - const prodInstance = match.app.instances.find((i) => i.environment_type === "production"); - - if (!devInstance) return undefined; - - const instances: Profile["instances"] = { - development: devInstance.instance_id, - }; - if (prodInstance) { - instances.production = prodInstance.instance_id; - } - - const profile: Profile = { - workspaceId: "", - appId: match.app.application_id, - appName: match.app.name, - instances, - }; - - await setProfile(profileKey, profile); + const result = await linkApp(match.app, cwd); + if (!result) return undefined; const label = match.app.name || match.app.application_id; log.info(`Auto-linked to ${cyan(label)} ${dim(`(detected key in ${match.source})`)}`); - return { path: profileKey, profile }; + return result; } diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index c357a413..b38a3d2a 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -56,6 +56,7 @@ export const autolinkStubs = { findClerkKeys: async () => [], matchKeyToApp: () => undefined, autolink: async () => undefined, + linkApp: async () => undefined, }; export const credentialStoreStubs = { From dcd382624fd48e66be1769037c3a99aceb96be35 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 14 Apr 2026 21:26:47 -0300 Subject: [PATCH 04/11] feat(keyless): add keyless application lifecycle module Add keyless.ts for managing accountless Clerk applications: - createAccountlessApp(): calls BAPI to create app with no auth - writeKeysToEnvFile(): writes framework-specific keys to .env.local - parseClaimToken(): extracts token from claim URL - Breadcrumb I/O: read/write/clear .clerk/keyless.json Includes 15 unit tests covering all functions and edge cases. --- packages/cli-core/src/lib/keyless.test.ts | 206 ++++++++++++++++++++++ packages/cli-core/src/lib/keyless.ts | 128 ++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 packages/cli-core/src/lib/keyless.test.ts create mode 100644 packages/cli-core/src/lib/keyless.ts diff --git a/packages/cli-core/src/lib/keyless.test.ts b/packages/cli-core/src/lib/keyless.test.ts new file mode 100644 index 00000000..fa82f3bd --- /dev/null +++ b/packages/cli-core/src/lib/keyless.test.ts @@ -0,0 +1,206 @@ +import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { stubFetch, captureLog } from "../test/lib/stubs.ts"; + +const { + parseClaimToken, + writeKeylessBreadcrumb, + readKeylessBreadcrumb, + clearKeylessBreadcrumb, + writeKeysToEnvFile, + createAccountlessApp, +} = await import("./keyless.ts"); + +describe("parseClaimToken", () => { + test("extracts token from relative claim URL", () => { + expect(parseClaimToken("/apps/claim?token=abc123&framework=nextjs")).toBe("abc123"); + }); + + test("extracts token from full URL", () => { + expect(parseClaimToken("https://dashboard.clerk.com/apps/claim?token=xyz789")).toBe("xyz789"); + }); + + test("throws when no token param exists", () => { + expect(() => parseClaimToken("/apps/claim?framework=nextjs")).toThrow("No token parameter"); + }); + + test("throws on empty string", () => { + expect(() => parseClaimToken("")).toThrow(); + }); +}); + +describe("breadcrumb", () => { + let tempDir: string; + let debugSpy: ReturnType; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-keyless-test-")); + debugSpy = spyOn(console, "debug").mockImplementation(() => {}); + }); + + afterEach(async () => { + debugSpy.mockRestore(); + await rm(tempDir, { recursive: true, force: true }); + }); + + test("write then read round-trips", async () => { + await writeKeylessBreadcrumb(tempDir, "token_abc"); + const result = await readKeylessBreadcrumb(tempDir); + + expect(result).toBeDefined(); + expect(result!.claimToken).toBe("token_abc"); + expect(result!.createdAt).toBeTruthy(); + }); + + test("read returns undefined when no breadcrumb exists", async () => { + const result = await readKeylessBreadcrumb(tempDir); + expect(result).toBeUndefined(); + }); + + test("read returns undefined when breadcrumb is malformed JSON", async () => { + const dir = join(tempDir, ".clerk"); + await Bun.write(join(dir, "keyless.json"), "not json{{{"); + + const captured = captureLog(); + const result = await captured.run(() => readKeylessBreadcrumb(tempDir)); + expect(result).toBeUndefined(); + }); + + test("clear removes the breadcrumb file", async () => { + await writeKeylessBreadcrumb(tempDir, "token_abc"); + await clearKeylessBreadcrumb(tempDir); + + const result = await readKeylessBreadcrumb(tempDir); + expect(result).toBeUndefined(); + }); + + test("clear does not throw when file is already gone", async () => { + await clearKeylessBreadcrumb(tempDir); + // Should not throw + }); +}); + +describe("writeKeysToEnvFile", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-keyless-env-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("writes keys to .env.local", async () => { + const captured = captureLog(); + await captured.run(() => + writeKeysToEnvFile(tempDir, { + publishableKey: "pk_test_123", + secretKey: "sk_test_456", + }), + ); + + const content = await Bun.file(join(tempDir, ".env.local")).text(); + expect(content).toContain("CLERK_PUBLISHABLE_KEY=pk_test_123"); + expect(content).toContain("CLERK_SECRET_KEY=sk_test_456"); + }); + + test("merges with existing env file content", async () => { + await Bun.write(join(tempDir, ".env.local"), "EXISTING_VAR=hello\n"); + + const captured = captureLog(); + await captured.run(() => + writeKeysToEnvFile(tempDir, { + publishableKey: "pk_test_abc", + secretKey: "sk_test_def", + }), + ); + + const content = await Bun.file(join(tempDir, ".env.local")).text(); + expect(content).toContain("EXISTING_VAR=hello"); + expect(content).toContain("CLERK_PUBLISHABLE_KEY=pk_test_abc"); + }); + + test("uses framework-specific key names when package.json specifies Next.js", async () => { + await Bun.write( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "latest" } }), + ); + + const captured = captureLog(); + await captured.run(() => + writeKeysToEnvFile(tempDir, { + publishableKey: "pk_test_next", + secretKey: "sk_test_next", + }), + ); + + const content = await Bun.file(join(tempDir, ".env.local")).text(); + expect(content).toContain("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_next"); + expect(content).toContain("CLERK_SECRET_KEY=sk_test_next"); + }); +}); + +describe("createAccountlessApp", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("calls BAPI with correct parameters", async () => { + let capturedUrl: string | undefined; + let capturedInit: RequestInit | undefined; + + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedInit = init; + return new Response( + JSON.stringify({ + publishable_key: "pk_test_new", + secret_key: "sk_test_new", + claim_url: "/apps/claim?token=tok_123", + }), + { status: 201 }, + ); + }); + + const result = await createAccountlessApp("nextjs"); + + expect(capturedUrl).toContain("/v1/accountless_applications"); + expect(capturedInit?.method).toBe("POST"); + expect(new Headers(capturedInit?.headers).get("Clerk-Framework")).toBe("nextjs"); + expect(result.publishable_key).toBe("pk_test_new"); + expect(result.claim_url).toContain("tok_123"); + }); + + test("omits Clerk-Framework header when no framework specified", async () => { + let capturedHeaders: Headers | undefined; + + stubFetch(async (_input, init) => { + capturedHeaders = new Headers(init?.headers); + return new Response( + JSON.stringify({ + publishable_key: "pk", + secret_key: "sk", + claim_url: "/apps/claim?token=t", + }), + { status: 201 }, + ); + }); + + await createAccountlessApp(); + + expect(capturedHeaders?.get("Clerk-Framework")).toBeNull(); + }); + + test("throws on non-OK response", async () => { + stubFetch(async () => new Response("Server Error", { status: 500 })); + + await expect(createAccountlessApp()).rejects.toThrow( + "Failed to create accountless application", + ); + }); +}); diff --git a/packages/cli-core/src/lib/keyless.ts b/packages/cli-core/src/lib/keyless.ts new file mode 100644 index 00000000..9cbb785a --- /dev/null +++ b/packages/cli-core/src/lib/keyless.ts @@ -0,0 +1,128 @@ +/** + * Keyless application lifecycle. + * + * Creates accountless applications via BAPI (no auth required) and manages + * the local breadcrumb file (.clerk/keyless.json) that stores the claim token + * for autoclaim during `clerk auth login`. + */ + +import { join } from "node:path"; +import { mkdir, unlink } from "node:fs/promises"; +import { getBapiBaseUrl } from "./environment.ts"; +import { detectPublishableKeyName, detectSecretKeyName } from "./framework.ts"; +import { parseEnvFile, mergeEnvVars, serializeEnvFile } from "./dotenv.ts"; +import { log } from "./log.ts"; + +// ── Types ────────────────────────────────────────────────────────────────── + +interface AccountlessAppResponse { + publishable_key: string; + secret_key: string; + claim_url: string; +} + +interface KeylessBreadcrumb { + claimToken: string; + createdAt: string; +} + +// ── BAPI: create accountless application ─────────────────────────────────── + +/** + * Creates an accountless (keyless) application via the Backend API. + * This endpoint is public — no authentication required. + */ +export async function createAccountlessApp(framework?: string): Promise { + const url = new URL("/v1/accountless_applications", getBapiBaseUrl()); + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + ...(framework && { "Clerk-Framework": framework }), + }; + + const body = new URLSearchParams({ source: "cli" }); + const response = await fetch(url, { method: "POST", headers, body: body.toString() }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to create accountless application (${response.status}): ${text}`); + } + + return response.json() as Promise; +} + +// ── Env file writing ─────────────────────────────────────────────────────── + +/** Write publishable + secret keys into .env.local. */ +export async function writeKeysToEnvFile( + cwd: string, + keys: { publishableKey: string; secretKey: string }, +): Promise { + const publishableKeyName = await detectPublishableKeyName(cwd); + const secretKeyName = await detectSecretKeyName(cwd); + + const targetFile = join(cwd, ".env.local"); + const file = Bun.file(targetFile); + const existingContent = (await file.exists()) ? await file.text() : ""; + + const merged = mergeEnvVars(parseEnvFile(existingContent), { + [publishableKeyName]: keys.publishableKey, + [secretKeyName]: keys.secretKey, + }); + + await Bun.write(targetFile, serializeEnvFile(merged)); + log.info("Environment variables written to .env.local"); +} + +// ── Claim token extraction ───────────────────────────────────────────────── + +/** Extracts the `token` query parameter from a claim URL. */ +export function parseClaimToken(claimUrl: string): string { + const base = claimUrl.startsWith("http") ? undefined : "https://placeholder.com"; + const token = new URL(claimUrl, base).searchParams.get("token"); + if (!token) throw new Error(`No token parameter in claim URL: ${claimUrl}`); + return token; +} + +// ── Breadcrumb I/O ───────────────────────────────────────────────────────── + +const BREADCRUMB_DIR = ".clerk"; +const BREADCRUMB_FILE = "keyless.json"; + +function breadcrumbPath(cwd: string): string { + return join(cwd, BREADCRUMB_DIR, BREADCRUMB_FILE); +} + +export async function writeKeylessBreadcrumb(cwd: string, claimToken: string): Promise { + await mkdir(join(cwd, BREADCRUMB_DIR), { recursive: true }); + + const breadcrumb: KeylessBreadcrumb = { + claimToken, + createdAt: new Date().toISOString(), + }; + + await Bun.write(breadcrumbPath(cwd), JSON.stringify(breadcrumb, null, 2) + "\n"); + log.debug(`Wrote keyless breadcrumb to ${BREADCRUMB_DIR}/${BREADCRUMB_FILE}`); +} + +export async function readKeylessBreadcrumb(cwd: string): Promise { + const file = Bun.file(breadcrumbPath(cwd)); + if (!(await file.exists())) return undefined; + + try { + return (await file.json()) as KeylessBreadcrumb; + } catch { + log.debug("Failed to parse keyless breadcrumb, ignoring"); + return undefined; + } +} + +export async function clearKeylessBreadcrumb(cwd: string): Promise { + try { + await unlink(breadcrumbPath(cwd)); + log.debug("Cleared keyless breadcrumb"); + } catch { + // File may already be gone + } +} From 2e41d171ab3f53f1c83de10a794a025c458fb0d1 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 14 Apr 2026 21:26:54 -0300 Subject: [PATCH 05/11] feat(autoclaim): add autoclaim orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add autoclaim.ts that detects keyless projects and claims them automatically after clerk auth login. Never throws — returns a discriminated union (claimed|not_found|already_claimed|failed|not_keyless) so the login flow is never interrupted. Key behaviors: - Truncates app name to 50 chars (backend limit) - Transient errors (5xx) preserve breadcrumb for retry - Terminal errors (404/403) clear breadcrumb Includes 8 unit tests covering all status paths. --- packages/cli-core/src/lib/autoclaim.test.ts | 149 ++++++++++++++++++++ packages/cli-core/src/lib/autoclaim.ts | 68 +++++++++ 2 files changed, 217 insertions(+) create mode 100644 packages/cli-core/src/lib/autoclaim.test.ts create mode 100644 packages/cli-core/src/lib/autoclaim.ts diff --git a/packages/cli-core/src/lib/autoclaim.test.ts b/packages/cli-core/src/lib/autoclaim.test.ts new file mode 100644 index 00000000..67df49d4 --- /dev/null +++ b/packages/cli-core/src/lib/autoclaim.test.ts @@ -0,0 +1,149 @@ +import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { stubFetch, captureLog } from "../test/lib/stubs.ts"; + +// Use spyOn instead of mock.module to avoid polluting other test files. +import * as autolinkMod from "./autolink.ts"; +import * as keylessMod from "./keyless.ts"; +import { attemptAutoclaim } from "./autoclaim.ts"; + +const MOCK_APP = { + application_id: "app_claimed", + name: "My Claimed App", + instances: [ + { instance_id: "ins_dev", environment_type: "development", publishable_key: "pk_test_abc" }, + { instance_id: "ins_prod", environment_type: "production", publishable_key: "pk_live_abc" }, + ], +}; + +describe("attemptAutoclaim", () => { + const originalFetch = globalThis.fetch; + let tempDir: string; + let spies: ReturnType[]; + let captured: ReturnType; + let linkAppSpy: ReturnType; + let readBreadcrumbSpy: ReturnType; + let clearBreadcrumbSpy: ReturnType; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-autoclaim-test-")); + process.env.CLERK_PLATFORM_API_KEY = "ak_test_key"; + process.env.CLERK_PLATFORM_API_URL = "https://test-api.clerk.com"; + captured = captureLog(); + + linkAppSpy = spyOn(autolinkMod, "linkApp").mockResolvedValue({ + path: tempDir, + profile: {} as never, + }); + clearBreadcrumbSpy = spyOn(keylessMod, "clearKeylessBreadcrumb").mockResolvedValue(undefined); + // readKeylessBreadcrumb defaults to "no breadcrumb" — tests override this + readBreadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb").mockResolvedValue(undefined); + + spies = [linkAppSpy, readBreadcrumbSpy, clearBreadcrumbSpy]; + }); + + afterEach(async () => { + captured.teardown(); + delete process.env.CLERK_PLATFORM_API_KEY; + delete process.env.CLERK_PLATFORM_API_URL; + globalThis.fetch = originalFetch; + for (const s of spies) s.mockRestore(); + await rm(tempDir, { recursive: true, force: true }); + }); + + function withBreadcrumb(token = "valid_token") { + readBreadcrumbSpy.mockResolvedValue({ claimToken: token, createdAt: new Date().toISOString() }); + } + + function run() { + return captured.run(() => attemptAutoclaim(tempDir)); + } + + test("returns not_keyless when no breadcrumb exists", async () => { + const result = await run(); + expect(result.status).toBe("not_keyless"); + expect(linkAppSpy).not.toHaveBeenCalled(); + }); + + test("claims and calls linkApp on success", async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + + const result = await run(); + + expect(result.status).toBe("claimed"); + if (result.status === "claimed") { + expect(result.app.application_id).toBe("app_claimed"); + } + expect(linkAppSpy).toHaveBeenCalledWith(MOCK_APP, tempDir); + }); + + test("clears breadcrumb after successful claim", async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + + await run(); + + expect(clearBreadcrumbSpy).toHaveBeenCalledWith(tempDir); + }); + + test("returns not_found and clears breadcrumb on 404", async () => { + withBreadcrumb("expired_token"); + stubFetch(async () => new Response("Not Found", { status: 404 })); + + const result = await run(); + + expect(result.status).toBe("not_found"); + expect(clearBreadcrumbSpy).toHaveBeenCalled(); + }); + + test("returns already_claimed and clears breadcrumb on 403", async () => { + withBreadcrumb("forbidden_token"); + stubFetch(async () => new Response("Forbidden", { status: 403 })); + + const result = await run(); + + expect(result.status).toBe("already_claimed"); + expect(clearBreadcrumbSpy).toHaveBeenCalled(); + }); + + test("returns failed on server error without clearing breadcrumb", async () => { + withBreadcrumb("server_error_token"); + stubFetch(async () => new Response("Internal Server Error", { status: 500 })); + + const result = await run(); + + expect(result.status).toBe("failed"); + if (result.status === "failed") { + expect(result.error).toBeInstanceOf(Error); + } + expect(clearBreadcrumbSpy).not.toHaveBeenCalled(); + }); + + test("does not call linkApp on failure", async () => { + withBreadcrumb(); + stubFetch(async () => new Response("Server Error", { status: 500 })); + + await run(); + + expect(linkAppSpy).not.toHaveBeenCalled(); + }); + + test("sends claim token and app name in request body", async () => { + withBreadcrumb("my_claim_token"); + let capturedBody: string | undefined; + stubFetch(async (_input, init) => { + capturedBody = init?.body as string; + return new Response(JSON.stringify(MOCK_APP), { status: 200 }); + }); + + await run(); + + const parsed = JSON.parse(capturedBody!); + expect(parsed.token).toBe("my_claim_token"); + expect(typeof parsed.name).toBe("string"); + expect(parsed.name).toBeTruthy(); + }); +}); diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts new file mode 100644 index 00000000..23a2b136 --- /dev/null +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -0,0 +1,68 @@ +/** + * Autoclaim orchestrator. + * + * After `clerk auth login`, detects whether the current directory is a keyless + * project (has .clerk/keyless.json) and claims the application automatically. + * + * NEVER throws — all errors are returned as status values so the login flow + * is never interrupted by autoclaim failures. + */ + +import { basename } from "node:path"; +import { readKeylessBreadcrumb, clearKeylessBreadcrumb } from "./keyless.ts"; +import { claimApplication, type Application } from "./plapi.ts"; +import { PlapiError } from "./errors.ts"; +import { linkApp } from "./autolink.ts"; +import { log } from "./log.ts"; + +type Claimed = { status: "claimed"; app: Application }; +type Terminal = { status: "not_found" | "already_claimed" }; +type Failed = { status: "failed"; error: Error }; +type Skipped = { status: "not_keyless" }; + +export type AutoclaimResult = Claimed | Terminal | Failed | Skipped; + +export async function attemptAutoclaim(cwd: string): Promise { + const breadcrumb = await readKeylessBreadcrumb(cwd); + if (!breadcrumb) return { status: "not_keyless" }; + + const appName = basename(cwd).slice(0, 50); + const result = await tryClaim(breadcrumb.claimToken, appName); + + // Transient failures keep the breadcrumb so the next login can retry. + // Terminal statuses (not_found, already_claimed, claimed) clear it. + if (result.status === "failed") return result; + + await clearKeylessBreadcrumb(cwd); + + if (result.status === "claimed") { + await linkApp(result.app, cwd); + } + + return result; +} + +async function tryClaim(claimToken: string, name: string): Promise { + try { + const app = await claimApplication(claimToken, name); + return { status: "claimed", app }; + } catch (error) { + return classifyClaimError(error); + } +} + +function classifyClaimError(error: unknown): Terminal | Failed { + if (error instanceof PlapiError && error.status === 404) { + log.debug("Claim token not found (app may have been claimed already or expired)"); + return { status: "not_found" }; + } + + if (error instanceof PlapiError && error.status === 403) { + log.debug("Not authorized to claim (missing active organization)"); + return { status: "already_claimed" }; + } + + const err = error instanceof Error ? error : new Error(String(error)); + log.debug(`Autoclaim failed: ${err.message}`); + return { status: "failed", error: err }; +} From 2c8ea10f2871ef6ab922d81d204e9c5491ad23e2 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 14 Apr 2026 21:27:00 -0300 Subject: [PATCH 06/11] feat(init): set up keyless app when user skips auth When bootstrap mode skips authentication, create an accountless app via BAPI, write keys to .env.local, and store the claim token in .clerk/keyless.json for autoclaim on next login. Also simplifies the keyless info message to promote the autoclaim flow instead of requiring manual clerk link + env pull. --- .../cli-core/src/commands/init/heuristics.ts | 8 ++--- packages/cli-core/src/commands/init/index.ts | 30 ++++++++++++++++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/cli-core/src/commands/init/heuristics.ts b/packages/cli-core/src/commands/init/heuristics.ts index a926fd4c..2bacd2a1 100644 --- a/packages/cli-core/src/commands/init/heuristics.ts +++ b/packages/cli-core/src/commands/init/heuristics.ts @@ -153,12 +153,8 @@ export async function isAuthenticated(): Promise { export function printKeylessInfo(): void { const lines = [ - "\n Your app will work immediately — Clerk generates temporary dev keys automatically.", - ` Look for the ${bold('"Configure your application"')} banner to claim your account.\n`, - " To connect a Clerk account later:", - " clerk auth login", - " clerk link", - " clerk env pull", + "\n Your app is ready with development keys in .env.local.", + ` When you're ready, run ${bold("clerk auth login")} and your app will be claimed automatically.\n`, ]; log.info(lines.map(dim).join("\n")); } diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index b83ae192..da03ca86 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -7,6 +7,12 @@ import { throwUserAbort, CliError } from "../../lib/errors.js"; import { lookupFramework, type FrameworkInfo } from "../../lib/framework.js"; import { resolveProfile } from "../../lib/config.js"; import { log } from "../../lib/log.js"; +import { + createAccountlessApp, + writeKeysToEnvFile, + parseClaimToken, + writeKeylessBreadcrumb, +} from "../../lib/keyless.js"; import { printNextSteps } from "../../lib/next-steps.js"; import { gatherContext, hasPackageJson } from "./context.js"; import { scaffold, enrichProjectContext } from "./scaffold.js"; @@ -117,7 +123,7 @@ export async function init(options: InitOptions = {}) { } else if (!keyless) { await pull({ file: ctx.envFile }); } else { - printKeylessInfo(); + await setupKeylessApp(ctx.cwd, ctx.framework.dep); } if (options.skills !== false) { @@ -276,6 +282,28 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis await link({ skipIfLinked: true, app, cwd }); } +// --- Keyless app setup --- + +async function setupKeylessApp(cwd: string, frameworkDep: 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)); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + log.debug(`Could not create accountless app: ${msg}`); + } + + printKeylessInfo(); +} + // --- Detect & install --- async function detectAndInstall( From 9694f02c10e78599bef20fd6bfe1b33c33ca468e Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 14 Apr 2026 21:27:07 -0300 Subject: [PATCH 07/11] feat(login): integrate autoclaim with contextual error messages Wire autoclaim into the login flow with improved UX: - Specific warning messages per failure cause (expired token, missing org, transient error) instead of generic fallback - Contextual next-steps based on claim result: manual link for terminal failures, retry guidance for transient errors - Mock autoclaim in login tests to isolate from transitive deps --- .../cli-core/src/commands/auth/login.test.ts | 4 ++ packages/cli-core/src/commands/auth/login.ts | 54 +++++++++++++++---- packages/cli-core/src/lib/next-steps.ts | 12 +++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index c95fe3eb..10541552 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -66,6 +66,10 @@ mock.module("../../lib/prompts.ts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), })); +mock.module("../../lib/autoclaim.ts", () => ({ + attemptAutoclaim: async () => ({ status: "not_keyless" }), +})); + const { login } = await import("./login.ts"); describe("login", () => { diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 500caa30..517b3a8e 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -10,6 +10,7 @@ import { isHuman } from "../../mode.ts"; import { throwUserAbort } from "../../lib/errors.ts"; import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; +import { attemptAutoclaim, type AutoclaimResult } from "../../lib/autoclaim.ts"; import { openBrowser } from "../../lib/open.ts"; import { cyan, dim } from "../../lib/color.ts"; import { log } from "../../lib/log.ts"; @@ -113,19 +114,52 @@ export async function login(options: LoginOptions = {}): Promise { bar(); log.success(`Logged in as ${userInfo.email}`); + const claimStatus = await handleAutoclaim(process.cwd()); + if (showNextSteps) { - const linked = await resolveProfile(process.cwd()); - if (linked) { - const appLabel = linked.profile.appName - ? `\`${linked.profile.appName}\` (${linked.profile.appId})` - : `\`${linked.profile.appId}\``; - log.success(`Linked to ${appLabel}`); - outro(NEXT_STEPS.LOGIN_LINKED); - } else { - outro(NEXT_STEPS.LOGIN); - } + outro(await loginNextSteps(claimStatus)); } else { outro("Done"); } + return userInfo; } + +const CLAIM_WARNINGS: Partial> = { + not_found: + "Claim token is no longer valid — the application may have been claimed from the dashboard.", + already_claimed: "Unable to claim — your account does not have an active organization.", + failed: "Auto-claim failed due to a temporary error. It will be retried on your next login.", +}; + +async function handleAutoclaim(cwd: string): Promise { + const result = await attemptAutoclaim(cwd); + + if (result.status === "claimed") { + const label = result.app.name || result.app.application_id; + log.success(`Claimed and linked application: \`${label}\``); + } + + const warning = CLAIM_WARNINGS[result.status]; + if (warning) log.warn(warning); + + return result.status; +} + +async function loginNextSteps(claimStatus: AutoclaimResult["status"]): Promise { + if (claimStatus === "claimed") return NEXT_STEPS.AUTOCLAIMED; + if (claimStatus === "failed") return NEXT_STEPS.AUTOCLAIM_RETRY; + if (claimStatus === "not_found" || claimStatus === "already_claimed") { + return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK; + } + + // not_keyless — normal login, check for existing linked profile + const linked = await resolveProfile(process.cwd()); + if (!linked) return NEXT_STEPS.LOGIN; + + const appLabel = linked.profile.appName + ? `\`${linked.profile.appName}\` (${linked.profile.appId})` + : `\`${linked.profile.appId}\``; + log.success(`Linked to ${appLabel}`); + return NEXT_STEPS.LOGIN_LINKED; +} diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index 609c44ef..f83f9eb3 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -20,6 +20,18 @@ export const NEXT_STEPS = { "Run `clerk env pull --instance prod` to fetch production keys", "Run `clerk doctor` to verify your setup", ], + AUTOCLAIMED: [ + "Run `clerk env pull` to refresh your environment variables", + "Run `clerk doctor` to verify your setup", + ], + AUTOCLAIM_MANUAL_LINK: [ + "Run `clerk link` to connect your Clerk application", + "Run `clerk env pull` to fetch your environment variables", + ], + AUTOCLAIM_RETRY: [ + "Run `clerk auth login` again to retry auto-claim", + "Run `clerk link` to connect your application manually", + ], } as const; /** From 6e495e69c99660bc37f71fea8c12b95f2914673b Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Fri, 17 Apr 2026 14:11:36 -0300 Subject: [PATCH 08/11] feat(autoclaim): auto-pull env vars after claiming app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful autoclaim, automatically run `env pull` to refresh .env.local with the claimed app's keys — no manual step needed. --- packages/cli-core/src/commands/auth/login.ts | 23 +++++++++++++------- packages/cli-core/src/lib/autoclaim.test.ts | 23 ++++++++++++++++++-- packages/cli-core/src/lib/autoclaim.ts | 19 ++++++++++++++-- packages/cli-core/src/lib/next-steps.ts | 3 ++- 4 files changed, 55 insertions(+), 13 deletions(-) diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 517b3a8e..c6c10ead 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -114,10 +114,10 @@ export async function login(options: LoginOptions = {}): Promise { bar(); log.success(`Logged in as ${userInfo.email}`); - const claimStatus = await handleAutoclaim(process.cwd()); + const claimResult = await handleAutoclaim(process.cwd()); if (showNextSteps) { - outro(await loginNextSteps(claimStatus)); + outro(await loginNextSteps(claimResult)); } else { outro("Done"); } @@ -132,24 +132,31 @@ const CLAIM_WARNINGS: Partial> = { failed: "Auto-claim failed due to a temporary error. It will be retried on your next login.", }; -async function handleAutoclaim(cwd: string): Promise { +async function handleAutoclaim(cwd: string): Promise { const result = await attemptAutoclaim(cwd); if (result.status === "claimed") { const label = result.app.name || result.app.application_id; log.success(`Claimed and linked application: \`${label}\``); + if (!result.envPulled) { + log.warn( + "Could not refresh environment variables automatically. Run `clerk env pull` manually.", + ); + } } const warning = CLAIM_WARNINGS[result.status]; if (warning) log.warn(warning); - return result.status; + return result; } -async function loginNextSteps(claimStatus: AutoclaimResult["status"]): Promise { - if (claimStatus === "claimed") return NEXT_STEPS.AUTOCLAIMED; - if (claimStatus === "failed") return NEXT_STEPS.AUTOCLAIM_RETRY; - if (claimStatus === "not_found" || claimStatus === "already_claimed") { +async function loginNextSteps(result: AutoclaimResult): Promise { + if (result.status === "claimed") { + return result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV; + } + if (result.status === "failed") return NEXT_STEPS.AUTOCLAIM_RETRY; + if (result.status === "not_found" || result.status === "already_claimed") { return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK; } diff --git a/packages/cli-core/src/lib/autoclaim.test.ts b/packages/cli-core/src/lib/autoclaim.test.ts index 67df49d4..352b3561 100644 --- a/packages/cli-core/src/lib/autoclaim.test.ts +++ b/packages/cli-core/src/lib/autoclaim.test.ts @@ -7,6 +7,7 @@ import { stubFetch, captureLog } from "../test/lib/stubs.ts"; // Use spyOn instead of mock.module to avoid polluting other test files. import * as autolinkMod from "./autolink.ts"; import * as keylessMod from "./keyless.ts"; +import * as pullMod from "../commands/env/pull.ts"; import { attemptAutoclaim } from "./autoclaim.ts"; const MOCK_APP = { @@ -26,6 +27,7 @@ describe("attemptAutoclaim", () => { let linkAppSpy: ReturnType; let readBreadcrumbSpy: ReturnType; let clearBreadcrumbSpy: ReturnType; + let pullSpy: ReturnType; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "clerk-autoclaim-test-")); @@ -40,8 +42,9 @@ describe("attemptAutoclaim", () => { clearBreadcrumbSpy = spyOn(keylessMod, "clearKeylessBreadcrumb").mockResolvedValue(undefined); // readKeylessBreadcrumb defaults to "no breadcrumb" — tests override this readBreadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb").mockResolvedValue(undefined); + pullSpy = spyOn(pullMod, "pull").mockResolvedValue(undefined); - spies = [linkAppSpy, readBreadcrumbSpy, clearBreadcrumbSpy]; + spies = [linkAppSpy, readBreadcrumbSpy, clearBreadcrumbSpy, pullSpy]; }); afterEach(async () => { @@ -67,7 +70,7 @@ describe("attemptAutoclaim", () => { expect(linkAppSpy).not.toHaveBeenCalled(); }); - test("claims and calls linkApp on success", async () => { + test("claims, calls linkApp, and pulls env on success", async () => { withBreadcrumb(); stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); @@ -76,8 +79,24 @@ describe("attemptAutoclaim", () => { expect(result.status).toBe("claimed"); if (result.status === "claimed") { expect(result.app.application_id).toBe("app_claimed"); + expect(result.envPulled).toBe(true); } expect(linkAppSpy).toHaveBeenCalledWith(MOCK_APP, tempDir); + expect(pullSpy).toHaveBeenCalledWith({}); + }); + + test("returns envPulled false when pull fails", async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + pullSpy.mockRejectedValue(new Error("no profile linked")); + + const result = await run(); + + expect(result.status).toBe("claimed"); + if (result.status === "claimed") { + expect(result.envPulled).toBe(false); + } + expect(linkAppSpy).toHaveBeenCalled(); }); test("clears breadcrumb after successful claim", async () => { diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index 23a2b136..bd3e8601 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -13,15 +13,18 @@ import { readKeylessBreadcrumb, clearKeylessBreadcrumb } from "./keyless.ts"; import { claimApplication, type Application } from "./plapi.ts"; import { PlapiError } from "./errors.ts"; import { linkApp } from "./autolink.ts"; +import { pull } from "../commands/env/pull.ts"; import { log } from "./log.ts"; -type Claimed = { status: "claimed"; app: Application }; +type Claimed = { status: "claimed"; app: Application; envPulled: boolean }; type Terminal = { status: "not_found" | "already_claimed" }; type Failed = { status: "failed"; error: Error }; type Skipped = { status: "not_keyless" }; export type AutoclaimResult = Claimed | Terminal | Failed | Skipped; +type ClaimAttempt = { status: "claimed"; app: Application } | Terminal | Failed; + export async function attemptAutoclaim(cwd: string): Promise { const breadcrumb = await readKeylessBreadcrumb(cwd); if (!breadcrumb) return { status: "not_keyless" }; @@ -37,12 +40,14 @@ export async function attemptAutoclaim(cwd: string): Promise { if (result.status === "claimed") { await linkApp(result.app, cwd); + const envPulled = await tryPullEnv(); + return { ...result, envPulled }; } return result; } -async function tryClaim(claimToken: string, name: string): Promise { +async function tryClaim(claimToken: string, name: string): Promise { try { const app = await claimApplication(claimToken, name); return { status: "claimed", app }; @@ -51,6 +56,16 @@ async function tryClaim(claimToken: string, name: string): Promise { + try { + await pull({}); + return true; + } catch (error) { + log.debug(`Auto env pull failed: ${error instanceof Error ? error.message : String(error)}`); + return false; + } +} + function classifyClaimError(error: unknown): Terminal | Failed { if (error instanceof PlapiError && error.status === 404) { log.debug("Claim token not found (app may have been claimed already or expired)"); diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index f83f9eb3..7a2ad2d8 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -20,7 +20,8 @@ export const NEXT_STEPS = { "Run `clerk env pull --instance prod` to fetch production keys", "Run `clerk doctor` to verify your setup", ], - AUTOCLAIMED: [ + AUTOCLAIMED: ["Run `clerk doctor` to verify your setup"], + AUTOCLAIMED_NO_ENV: [ "Run `clerk env pull` to refresh your environment variables", "Run `clerk doctor` to verify your setup", ], From b254b15f0ad5004052ef80fc2672a93a2d27e742 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Fri, 17 Apr 2026 19:30:53 -0300 Subject: [PATCH 09/11] refactor: simplify autoclaim/keyless/plapi per KISS/DRY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared plapiRequest helper; collapse 6 PLAPI functions to 2-3 lines each (6 near-duplicate fetch blocks → one) - Promote errorMessage() from doctor/checks.ts to lib/errors.ts so it can be shared with autoclaim.ts and init/index.ts - Replace two TOCTOU file.exists()+read patterns in keyless.ts with atomic .text()/.json() catch-ENOENT variants - Parallelize detectPublishableKeyName + detectSecretKeyName in writeKeysToEnvFile (independent I/O) - Table-drive classifyClaimError (404/403 status → result mapping) - Drop file-level docstrings, section-header banners, and WHAT/ narration comments across new files per house style --- packages/cli-core/src/commands/auth/login.ts | 1 - .../cli-core/src/commands/doctor/checks.ts | 6 +-- .../cli-core/src/commands/doctor/index.ts | 3 +- packages/cli-core/src/commands/init/index.ts | 5 +- packages/cli-core/src/lib/autoclaim.test.ts | 13 +++-- packages/cli-core/src/lib/autoclaim.ts | 38 ++++++------- packages/cli-core/src/lib/autolink.ts | 6 +-- packages/cli-core/src/lib/errors.ts | 5 ++ packages/cli-core/src/lib/keyless.ts | 53 ++++++------------- packages/cli-core/src/lib/plapi.ts | 23 +------- 10 files changed, 47 insertions(+), 106 deletions(-) diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index c6c10ead..fc6e5cd5 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -160,7 +160,6 @@ async function loginNextSteps(result: AutoclaimResult): Promise await writeKeylessBreadcrumb(cwd, parseClaimToken(app.claim_url)); } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - log.debug(`Could not create accountless app: ${msg}`); + log.debug(`Could not create accountless app: ${errorMessage(error)}`); } printKeylessInfo(); diff --git a/packages/cli-core/src/lib/autoclaim.test.ts b/packages/cli-core/src/lib/autoclaim.test.ts index 352b3561..6321b76e 100644 --- a/packages/cli-core/src/lib/autoclaim.test.ts +++ b/packages/cli-core/src/lib/autoclaim.test.ts @@ -4,10 +4,10 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { stubFetch, captureLog } from "../test/lib/stubs.ts"; -// Use spyOn instead of mock.module to avoid polluting other test files. import * as autolinkMod from "./autolink.ts"; import * as keylessMod from "./keyless.ts"; import * as pullMod from "../commands/env/pull.ts"; +import type { Profile } from "./config.ts"; import { attemptAutoclaim } from "./autoclaim.ts"; const MOCK_APP = { @@ -22,7 +22,6 @@ const MOCK_APP = { describe("attemptAutoclaim", () => { const originalFetch = globalThis.fetch; let tempDir: string; - let spies: ReturnType[]; let captured: ReturnType; let linkAppSpy: ReturnType; let readBreadcrumbSpy: ReturnType; @@ -37,14 +36,11 @@ describe("attemptAutoclaim", () => { linkAppSpy = spyOn(autolinkMod, "linkApp").mockResolvedValue({ path: tempDir, - profile: {} as never, + profile: {} as Profile, }); clearBreadcrumbSpy = spyOn(keylessMod, "clearKeylessBreadcrumb").mockResolvedValue(undefined); - // readKeylessBreadcrumb defaults to "no breadcrumb" — tests override this readBreadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb").mockResolvedValue(undefined); pullSpy = spyOn(pullMod, "pull").mockResolvedValue(undefined); - - spies = [linkAppSpy, readBreadcrumbSpy, clearBreadcrumbSpy, pullSpy]; }); afterEach(async () => { @@ -52,7 +48,10 @@ describe("attemptAutoclaim", () => { delete process.env.CLERK_PLATFORM_API_KEY; delete process.env.CLERK_PLATFORM_API_URL; globalThis.fetch = originalFetch; - for (const s of spies) s.mockRestore(); + linkAppSpy.mockRestore(); + readBreadcrumbSpy.mockRestore(); + clearBreadcrumbSpy.mockRestore(); + pullSpy.mockRestore(); await rm(tempDir, { recursive: true, force: true }); }); diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index bd3e8601..8a772655 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -1,17 +1,7 @@ -/** - * Autoclaim orchestrator. - * - * After `clerk auth login`, detects whether the current directory is a keyless - * project (has .clerk/keyless.json) and claims the application automatically. - * - * NEVER throws — all errors are returned as status values so the login flow - * is never interrupted by autoclaim failures. - */ - import { basename } from "node:path"; import { readKeylessBreadcrumb, clearKeylessBreadcrumb } from "./keyless.ts"; import { claimApplication, type Application } from "./plapi.ts"; -import { PlapiError } from "./errors.ts"; +import { PlapiError, errorMessage } from "./errors.ts"; import { linkApp } from "./autolink.ts"; import { pull } from "../commands/env/pull.ts"; import { log } from "./log.ts"; @@ -25,15 +15,21 @@ export type AutoclaimResult = Claimed | Terminal | Failed | Skipped; type ClaimAttempt = { status: "claimed"; app: Application } | Terminal | Failed; +const APP_NAME_MAX = 50; + +const TERMINAL_BY_STATUS: Record = { + 404: "not_found", + 403: "already_claimed", +}; + +/** Orchestrates post-login claim of a keyless app. Never throws. */ export async function attemptAutoclaim(cwd: string): Promise { const breadcrumb = await readKeylessBreadcrumb(cwd); if (!breadcrumb) return { status: "not_keyless" }; - const appName = basename(cwd).slice(0, 50); + const appName = basename(cwd).slice(0, APP_NAME_MAX); const result = await tryClaim(breadcrumb.claimToken, appName); - // Transient failures keep the breadcrumb so the next login can retry. - // Terminal statuses (not_found, already_claimed, claimed) clear it. if (result.status === "failed") return result; await clearKeylessBreadcrumb(cwd); @@ -61,20 +57,16 @@ async function tryPullEnv(): Promise { await pull({}); return true; } catch (error) { - log.debug(`Auto env pull failed: ${error instanceof Error ? error.message : String(error)}`); + log.debug(`Auto env pull failed: ${errorMessage(error)}`); return false; } } function classifyClaimError(error: unknown): Terminal | Failed { - if (error instanceof PlapiError && error.status === 404) { - log.debug("Claim token not found (app may have been claimed already or expired)"); - return { status: "not_found" }; - } - - if (error instanceof PlapiError && error.status === 403) { - log.debug("Not authorized to claim (missing active organization)"); - return { status: "already_claimed" }; + if (error instanceof PlapiError && error.status in TERMINAL_BY_STATUS) { + const status = TERMINAL_BY_STATUS[error.status]!; + log.debug(`Claim returned ${error.status}: classified as ${status}`); + return { status }; } const err = error instanceof Error ? error : new Error(String(error)); diff --git a/packages/cli-core/src/lib/autolink.ts b/packages/cli-core/src/lib/autolink.ts index 3f317242..00881a5d 100644 --- a/packages/cli-core/src/lib/autolink.ts +++ b/packages/cli-core/src/lib/autolink.ts @@ -76,11 +76,7 @@ export function matchKeyToApp( return undefined; } -/** - * Builds a Profile from an Application and persists it. - * Shared by autolink (key detection) and autoclaim (keyless claim). - * Returns the profile and its key, or undefined if no development instance exists. - */ +/** Returns undefined when the app has no development instance. */ export async function linkApp( app: Application, cwd: string, diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 3d6abf57..4ec25c87 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -247,3 +247,8 @@ export function withApiContext(promise: Promise, context: string): Promise throw error; }); } + +/** Normalize an unknown thrown value to a string message. */ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/cli-core/src/lib/keyless.ts b/packages/cli-core/src/lib/keyless.ts index 9cbb785a..1fa6fc4e 100644 --- a/packages/cli-core/src/lib/keyless.ts +++ b/packages/cli-core/src/lib/keyless.ts @@ -1,11 +1,3 @@ -/** - * Keyless application lifecycle. - * - * Creates accountless applications via BAPI (no auth required) and manages - * the local breadcrumb file (.clerk/keyless.json) that stores the claim token - * for autoclaim during `clerk auth login`. - */ - import { join } from "node:path"; import { mkdir, unlink } from "node:fs/promises"; import { getBapiBaseUrl } from "./environment.ts"; @@ -13,7 +5,9 @@ import { detectPublishableKeyName, detectSecretKeyName } from "./framework.ts"; import { parseEnvFile, mergeEnvVars, serializeEnvFile } from "./dotenv.ts"; import { log } from "./log.ts"; -// ── Types ────────────────────────────────────────────────────────────────── +const ENV_LOCAL = ".env.local"; +const BREADCRUMB_DIR = ".clerk"; +const BREADCRUMB_FILE = "keyless.json"; interface AccountlessAppResponse { publishable_key: string; @@ -26,12 +20,7 @@ interface KeylessBreadcrumb { createdAt: string; } -// ── BAPI: create accountless application ─────────────────────────────────── - -/** - * Creates an accountless (keyless) application via the Backend API. - * This endpoint is public — no authentication required. - */ +/** Creates an accountless Clerk application via the public BAPI endpoint. */ export async function createAccountlessApp(framework?: string): Promise { const url = new URL("/v1/accountless_applications", getBapiBaseUrl()); @@ -52,19 +41,19 @@ export async function createAccountlessApp(framework?: string): Promise; } -// ── Env file writing ─────────────────────────────────────────────────────── - -/** Write publishable + secret keys into .env.local. */ export async function writeKeysToEnvFile( cwd: string, keys: { publishableKey: string; secretKey: string }, ): Promise { - const publishableKeyName = await detectPublishableKeyName(cwd); - const secretKeyName = await detectSecretKeyName(cwd); + const [publishableKeyName, secretKeyName] = await Promise.all([ + detectPublishableKeyName(cwd), + detectSecretKeyName(cwd), + ]); - const targetFile = join(cwd, ".env.local"); - const file = Bun.file(targetFile); - const existingContent = (await file.exists()) ? await file.text() : ""; + const targetFile = join(cwd, ENV_LOCAL); + const existingContent = await Bun.file(targetFile) + .text() + .catch(() => ""); const merged = mergeEnvVars(parseEnvFile(existingContent), { [publishableKeyName]: keys.publishableKey, @@ -72,12 +61,9 @@ export async function writeKeysToEnvFile( }); await Bun.write(targetFile, serializeEnvFile(merged)); - log.info("Environment variables written to .env.local"); + log.info(`Environment variables written to ${ENV_LOCAL}`); } -// ── Claim token extraction ───────────────────────────────────────────────── - -/** Extracts the `token` query parameter from a claim URL. */ export function parseClaimToken(claimUrl: string): string { const base = claimUrl.startsWith("http") ? undefined : "https://placeholder.com"; const token = new URL(claimUrl, base).searchParams.get("token"); @@ -85,11 +71,6 @@ export function parseClaimToken(claimUrl: string): string { return token; } -// ── Breadcrumb I/O ───────────────────────────────────────────────────────── - -const BREADCRUMB_DIR = ".clerk"; -const BREADCRUMB_FILE = "keyless.json"; - function breadcrumbPath(cwd: string): string { return join(cwd, BREADCRUMB_DIR, BREADCRUMB_FILE); } @@ -107,13 +88,9 @@ export async function writeKeylessBreadcrumb(cwd: string, claimToken: string): P } export async function readKeylessBreadcrumb(cwd: string): Promise { - const file = Bun.file(breadcrumbPath(cwd)); - if (!(await file.exists())) return undefined; - try { - return (await file.json()) as KeylessBreadcrumb; + return (await Bun.file(breadcrumbPath(cwd)).json()) as KeylessBreadcrumb; } catch { - log.debug("Failed to parse keyless breadcrumb, ignoring"); return undefined; } } @@ -123,6 +100,6 @@ export async function clearKeylessBreadcrumb(cwd: string): Promise { await unlink(breadcrumbPath(cwd)); log.debug("Cleared keyless breadcrumb"); } catch { - // File may already be gone + // idempotent } } diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 13ae2e31..4e2fa331 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -9,10 +9,6 @@ import { CliError, PlapiError, ERROR_CODE } from "./errors.ts"; import { loggedFetch } from "./fetch.ts"; import { log } from "./log.ts"; -/** - * Validate that a key has the expected prefix and suggest the correct key type - * if the user mixed them up. - */ export function validateKeyPrefix(key: string, expected: "ak_" | "sk_"): void { if (key.startsWith(expected)) return; @@ -30,7 +26,6 @@ export function validateKeyPrefix(key: string, expected: "ak_" | "sk_"): void { } export async function getAuthToken(): Promise { - // Prefer platform API key (OAuth token doesn't have platform scopes yet) const key = process.env.CLERK_PLATFORM_API_KEY; if (key) { validateKeyPrefix(key, "ak_"); @@ -40,7 +35,6 @@ export async function getAuthToken(): Promise { return key; } - // Fall back to OAuth access token from `clerk auth login` const oauthToken = await getToken(); if (oauthToken) { log.debug( @@ -178,23 +172,8 @@ export async function createApplication(name: string): Promise { } export async function claimApplication(token: string, name: string): Promise { - const authToken = await getAuthToken(); const url = new URL("/v1/platform/accountless_applications/claim", getPlapiBaseUrl()); - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${authToken}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ token, name }), - }); - - if (!response.ok) { - const body = await response.text(); - throw new PlapiError(response.status, body, url.toString()); - } - + const response = await plapiFetch("POST", url, { body: JSON.stringify({ token, name }) }); return response.json() as Promise; } From bf66e604f2abd7edb2bdb5a1d07bf95d7aabf4c2 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 21 Apr 2026 11:25:24 -0300 Subject: [PATCH 10/11] fix(review): address all PR #157 code review comments - Use loggedFetch in createAccountlessApp (bapi tag, --verbose support) - Write .gitignore entry before breadcrumb file to prevent exposure on interrupted writes - Run autoclaim on agent-mode existing-session path, not just post-OAuth - Narrow 4xx terminal classification to explicit 404/403 only; all other errors preserve the breadcrumb for retry on next login - Drop duplicate log.warn for failed env pull (next-steps covers it) - Use Intl.Segmenter for grapheme-safe app name truncation - Remove unused AUTOCLAIM_FAILED error code - Pass envFile from ctx to printKeylessInfo and setupKeylessApp so the correct env file is shown per framework (Next.js/.env vs .env.local) - Corrupt keyless.json now cleared on read so fresh setup can proceed - Add isAuthenticated() credential-presence check for init keyless flow - Update init/README.md and auth/README.md with keyless API endpoints and breadcrumb lifecycle documentation --- .changeset/auto-claim.md | 5 ++ packages/cli-core/src/commands/auth/README.md | 27 +++++--- packages/cli-core/src/commands/auth/login.ts | 17 ++--- packages/cli-core/src/commands/init/README.md | 17 +++++ .../cli-core/src/commands/init/heuristics.ts | 4 +- packages/cli-core/src/commands/init/index.ts | 10 +-- packages/cli-core/src/lib/autoclaim.test.ts | 30 +++++++-- packages/cli-core/src/lib/autoclaim.ts | 31 ++++++++-- packages/cli-core/src/lib/errors.ts | 2 - packages/cli-core/src/lib/keyless.test.ts | 41 +++++++++--- packages/cli-core/src/lib/keyless.ts | 62 ++++++++++++++++--- 11 files changed, 195 insertions(+), 51 deletions(-) create mode 100644 .changeset/auto-claim.md diff --git a/.changeset/auto-claim.md b/.changeset/auto-claim.md new file mode 100644 index 00000000..c1eb2cfb --- /dev/null +++ b/.changeset/auto-claim.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Automatically claim and link keyless applications on `clerk auth login`, and write temporary dev keys during `clerk init` when skipping authentication. diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index 735fa1af..bfcd52d8 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -6,25 +6,36 @@ Manage authentication with Clerk. ### `clerk auth login` (aliases: `signup`, `signin`, `sign-in`) -Authenticates the user via an OAuth 2.0 PKCE flow. +Authenticates the user via an OAuth 2.0 PKCE flow. After a successful login (or when an existing session is detected in agent mode), the command attempts to automatically claim any keyless application previously created by `clerk init`. -1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips login silently) +1. Checks for an existing valid token — if found, prompts to re-authenticate (in agent mode, skips and runs autoclaim immediately) 2. Generates PKCE parameters (code verifier, challenge, state) 3. Starts a local HTTP callback server on `127.0.0.1` 4. Opens the browser to the Clerk OAuth authorization URL 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 + +#### 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`: + +- **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. #### API Endpoints -All requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`). +OAuth requests are made against the Clerk OAuth system instance (default `https://clerk.clerk.com`, overridable via `CLERK_OAUTH_BASE_URL`). Autoclaim requests are made against the Platform API (default `https://api.clerk.com`, overridable via `CLERK_PLATFORM_API_URL`). -| Step | Method | Endpoint | Description | -| -------------- | ------ | ------------------ | --------------------------------------------------------------------------------- | -| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` | -| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token | -| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token | +| Step | Method | Endpoint | Description | +| -------------- | ------ | --------------------------------------------- | --------------------------------------------------------------------------------- | +| Authorize | `GET` | `/oauth/authorize` | Browser redirect with PKCE `code_challenge`, `state`, `client_id`, `redirect_uri` | +| Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token | +| User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token | +| Autoclaim | `POST` | `/v1/platform/accountless_applications/claim` | Claims a keyless application by token; returns the full `Application` object | ### `clerk auth logout` (aliases: `signout`, `sign-out`) diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index fc6e5cd5..682726aa 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -95,6 +95,12 @@ export async function login(options: LoginOptions = {}): Promise { if (existingSession && !isHuman()) { log.success(`Logged in as ${existingSession.email}`); + const claimResult = await handleAutoclaim(process.cwd()); + if (showNextSteps) { + outro(await loginNextSteps(claimResult)); + } else { + outro("Done"); + } return existingSession; } @@ -127,8 +133,8 @@ export async function login(options: LoginOptions = {}): Promise { const CLAIM_WARNINGS: Partial> = { not_found: - "Claim token is no longer valid — the application may have been claimed from the dashboard.", - already_claimed: "Unable to claim — your account does not have an active organization.", + "Claim token is no longer valid - the application may have been claimed from the dashboard.", + no_organization: "Unable to claim - your account does not have an active organization.", failed: "Auto-claim failed due to a temporary error. It will be retried on your next login.", }; @@ -138,11 +144,6 @@ async function handleAutoclaim(cwd: string): Promise { if (result.status === "claimed") { const label = result.app.name || result.app.application_id; log.success(`Claimed and linked application: \`${label}\``); - if (!result.envPulled) { - log.warn( - "Could not refresh environment variables automatically. Run `clerk env pull` manually.", - ); - } } const warning = CLAIM_WARNINGS[result.status]; @@ -156,7 +157,7 @@ async function loginNextSteps(result: AutoclaimResult): Promise", + "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). diff --git a/packages/cli-core/src/commands/init/heuristics.ts b/packages/cli-core/src/commands/init/heuristics.ts index 2bacd2a1..3a0e3933 100644 --- a/packages/cli-core/src/commands/init/heuristics.ts +++ b/packages/cli-core/src/commands/init/heuristics.ts @@ -151,9 +151,9 @@ export async function isAuthenticated(): Promise { return (await getToken()) != null; } -export function printKeylessInfo(): void { +export function printKeylessInfo(envFile: string): void { const lines = [ - "\n Your app is ready with development keys in .env.local.", + `\n Your app is ready with development keys in ${envFile}.`, ` When you're ready, run ${bold("clerk auth login")} and your app will be claimed automatically.\n`, ]; log.info(lines.map(dim).join("\n")); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index eab329f5..99a9581d 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -123,7 +123,7 @@ export async function init(options: InitOptions = {}) { } else if (!keyless) { await pull({ file: ctx.envFile }); } else { - await setupKeylessApp(ctx.cwd, ctx.framework.dep); + await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile); } if (options.skills !== false) { @@ -284,7 +284,7 @@ async function authenticateAndLink(cwd: string, app: string | undefined): Promis // --- Keyless app setup --- -async function setupKeylessApp(cwd: string, frameworkDep: string): Promise { +async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise { try { const app = await withSpinner("Creating development application...", () => createAccountlessApp(frameworkDep), @@ -296,11 +296,13 @@ async function setupKeylessApp(cwd: string, frameworkDep: string): Promise }); await writeKeylessBreadcrumb(cwd, parseClaimToken(app.claim_url)); + printKeylessInfo(envFile); } catch (error) { log.debug(`Could not create accountless app: ${errorMessage(error)}`); + log.warn( + "Could not set up development keys. Run `clerk auth login` then `clerk link` to connect your app manually.", + ); } - - printKeylessInfo(); } // --- Detect & install --- diff --git a/packages/cli-core/src/lib/autoclaim.test.ts b/packages/cli-core/src/lib/autoclaim.test.ts index 6321b76e..f8e2d4b6 100644 --- a/packages/cli-core/src/lib/autoclaim.test.ts +++ b/packages/cli-core/src/lib/autoclaim.test.ts @@ -1,6 +1,6 @@ import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; +import { join, basename } from "node:path"; import { tmpdir } from "node:os"; import { stubFetch, captureLog } from "../test/lib/stubs.ts"; @@ -117,16 +117,36 @@ describe("attemptAutoclaim", () => { expect(clearBreadcrumbSpy).toHaveBeenCalled(); }); - test("returns already_claimed and clears breadcrumb on 403", async () => { + test("returns no_organization and clears breadcrumb on 403", async () => { withBreadcrumb("forbidden_token"); stubFetch(async () => new Response("Forbidden", { status: 403 })); const result = await run(); - expect(result.status).toBe("already_claimed"); + expect(result.status).toBe("no_organization"); expect(clearBreadcrumbSpy).toHaveBeenCalled(); }); + test("returns failed (preserves breadcrumb) on 400 — could be recoverable (e.g. 401 re-login)", async () => { + withBreadcrumb("bad_token"); + stubFetch(async () => new Response("Bad Request", { status: 400 })); + + const result = await run(); + + expect(result.status).toBe("failed"); + expect(clearBreadcrumbSpy).not.toHaveBeenCalled(); + }); + + test("returns failed (preserves breadcrumb) on 429 rate limit", async () => { + withBreadcrumb("rate_limited_token"); + stubFetch(async () => new Response("Too Many Requests", { status: 429 })); + + const result = await run(); + + expect(result.status).toBe("failed"); + expect(clearBreadcrumbSpy).not.toHaveBeenCalled(); + }); + test("returns failed on server error without clearing breadcrumb", async () => { withBreadcrumb("server_error_token"); stubFetch(async () => new Response("Internal Server Error", { status: 500 })); @@ -161,7 +181,7 @@ describe("attemptAutoclaim", () => { const parsed = JSON.parse(capturedBody!); expect(parsed.token).toBe("my_claim_token"); - expect(typeof parsed.name).toBe("string"); - expect(parsed.name).toBeTruthy(); + // no package.json in tempDir, so basename is used; assert exact value + expect(parsed.name).toBe(basename(tempDir)); }); }); diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index 8a772655..2e502b3b 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -1,4 +1,4 @@ -import { basename } from "node:path"; +import { basename, join } from "node:path"; import { readKeylessBreadcrumb, clearKeylessBreadcrumb } from "./keyless.ts"; import { claimApplication, type Application } from "./plapi.ts"; import { PlapiError, errorMessage } from "./errors.ts"; @@ -7,7 +7,7 @@ import { pull } from "../commands/env/pull.ts"; import { log } from "./log.ts"; type Claimed = { status: "claimed"; app: Application; envPulled: boolean }; -type Terminal = { status: "not_found" | "already_claimed" }; +type Terminal = { status: "not_found" | "no_organization" }; type Failed = { status: "failed"; error: Error }; type Skipped = { status: "not_keyless" }; @@ -15,19 +15,40 @@ export type AutoclaimResult = Claimed | Terminal | Failed | Skipped; type ClaimAttempt = { status: "claimed"; app: Application } | Terminal | Failed; -const APP_NAME_MAX = 50; +const APP_NAME_MAX_CHARS = 50; const TERMINAL_BY_STATUS: Record = { 404: "not_found", - 403: "already_claimed", + 403: "no_organization", }; +async function deriveAppName(cwd: string): Promise { + try { + const pkg: { name?: unknown } = await Bun.file(join(cwd, "package.json")).json(); + if (typeof pkg.name === "string" && pkg.name.trim()) return pkg.name.trim(); + } catch { + // fall through + } + return basename(cwd); +} + +function truncateToChars(str: string, max: number): string { + const segments = [...new Intl.Segmenter().segment(str)]; + return segments.length <= max + ? str + : segments + .slice(0, max) + .map((s) => s.segment) + .join(""); +} + /** Orchestrates post-login claim of a keyless app. Never throws. */ export async function attemptAutoclaim(cwd: string): Promise { const breadcrumb = await readKeylessBreadcrumb(cwd); if (!breadcrumb) return { status: "not_keyless" }; - const appName = basename(cwd).slice(0, APP_NAME_MAX); + const rawName = await deriveAppName(cwd); + const appName = truncateToChars(rawName, APP_NAME_MAX_CHARS); const result = await tryClaim(breadcrumb.claimToken, appName); if (result.status === "failed") return result; diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 4ec25c87..16f43956 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -43,8 +43,6 @@ export const ERROR_CODE = { CATALOG_ERROR: "catalog_error", /** Doctor checks found issues. */ DOCTOR_FAILED: "doctor_failed", - /** Autoclaim of a keyless application failed. */ - AUTOCLAIM_FAILED: "autoclaim_failed", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; diff --git a/packages/cli-core/src/lib/keyless.test.ts b/packages/cli-core/src/lib/keyless.test.ts index fa82f3bd..edc55496 100644 --- a/packages/cli-core/src/lib/keyless.test.ts +++ b/packages/cli-core/src/lib/keyless.test.ts @@ -60,14 +60,23 @@ describe("breadcrumb", () => { }); test("read returns undefined when breadcrumb is malformed JSON", async () => { - const dir = join(tempDir, ".clerk"); - await Bun.write(join(dir, "keyless.json"), "not json{{{"); + await Bun.write(join(tempDir, ".clerk", "keyless.json"), "not json{{{"); const captured = captureLog(); const result = await captured.run(() => readKeylessBreadcrumb(tempDir)); expect(result).toBeUndefined(); }); + test("read returns undefined and clears file when breadcrumb has wrong shape", async () => { + const breadcrumbFile = join(tempDir, ".clerk", "keyless.json"); + await Bun.write(breadcrumbFile, JSON.stringify({ claimToken: 12345, createdAt: "2024-01-01" })); + + const captured = captureLog(); + const result = await captured.run(() => readKeylessBreadcrumb(tempDir)); + expect(result).toBeUndefined(); + expect(await Bun.file(breadcrumbFile).exists()).toBe(false); + }); + test("clear removes the breadcrumb file", async () => { await writeKeylessBreadcrumb(tempDir, "token_abc"); await clearKeylessBreadcrumb(tempDir); @@ -80,6 +89,20 @@ describe("breadcrumb", () => { await clearKeylessBreadcrumb(tempDir); // Should not throw }); + + test("writeKeylessBreadcrumb adds .clerk/ to .gitignore", async () => { + await writeKeylessBreadcrumb(tempDir, "token_abc"); + const gitignore = await Bun.file(join(tempDir, ".gitignore")).text(); + expect(gitignore).toContain(".clerk/"); + }); + + test("writeKeylessBreadcrumb does not duplicate .clerk/ entry if already present", async () => { + await Bun.write(join(tempDir, ".gitignore"), ".clerk/\n"); + await writeKeylessBreadcrumb(tempDir, "token_abc"); + const gitignore = await Bun.file(join(tempDir, ".gitignore")).text(); + const matches = gitignore.split("\n").filter((l) => l.trim() === ".clerk/"); + expect(matches.length).toBe(1); + }); }); describe("writeKeysToEnvFile", () => { @@ -123,7 +146,7 @@ describe("writeKeysToEnvFile", () => { expect(content).toContain("CLERK_PUBLISHABLE_KEY=pk_test_abc"); }); - test("uses framework-specific key names when package.json specifies Next.js", async () => { + test("uses framework-specific key names and env file when package.json specifies Next.js", async () => { await Bun.write( join(tempDir, "package.json"), JSON.stringify({ dependencies: { next: "latest" } }), @@ -137,7 +160,8 @@ describe("writeKeysToEnvFile", () => { }), ); - const content = await Bun.file(join(tempDir, ".env.local")).text(); + // Next.js declares envFile: ".env" in FRAMEWORK_MAP + const content = await Bun.file(join(tempDir, ".env")).text(); expect(content).toContain("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_next"); expect(content).toContain("CLERK_SECRET_KEY=sk_test_next"); }); @@ -196,11 +220,12 @@ describe("createAccountlessApp", () => { expect(capturedHeaders?.get("Clerk-Framework")).toBeNull(); }); - test("throws on non-OK response", async () => { + test("throws BapiError on non-OK response", async () => { stubFetch(async () => new Response("Server Error", { status: 500 })); - await expect(createAccountlessApp()).rejects.toThrow( - "Failed to create accountless application", - ); + await expect(createAccountlessApp()).rejects.toMatchObject({ + name: "BapiError", + status: 500, + }); }); }); diff --git a/packages/cli-core/src/lib/keyless.ts b/packages/cli-core/src/lib/keyless.ts index 1fa6fc4e..c41a5469 100644 --- a/packages/cli-core/src/lib/keyless.ts +++ b/packages/cli-core/src/lib/keyless.ts @@ -1,13 +1,15 @@ import { join } from "node:path"; import { mkdir, unlink } from "node:fs/promises"; import { getBapiBaseUrl } from "./environment.ts"; -import { detectPublishableKeyName, detectSecretKeyName } from "./framework.ts"; +import { detectPublishableKeyName, detectSecretKeyName, detectEnvFile } from "./framework.ts"; import { parseEnvFile, mergeEnvVars, serializeEnvFile } from "./dotenv.ts"; +import { BapiError } from "./errors.ts"; +import { loggedFetch } from "./fetch.ts"; import { log } from "./log.ts"; -const ENV_LOCAL = ".env.local"; const BREADCRUMB_DIR = ".clerk"; const BREADCRUMB_FILE = "keyless.json"; +const CREATE_TIMEOUT_MS = 15_000; interface AccountlessAppResponse { publishable_key: string; @@ -20,6 +22,15 @@ interface KeylessBreadcrumb { createdAt: string; } +function isKeylessBreadcrumb(value: unknown): value is KeylessBreadcrumb { + return ( + typeof value === "object" && + value !== null && + typeof (value as KeylessBreadcrumb).claimToken === "string" && + typeof (value as KeylessBreadcrumb).createdAt === "string" + ); +} + /** Creates an accountless Clerk application via the public BAPI endpoint. */ export async function createAccountlessApp(framework?: string): Promise { const url = new URL("/v1/accountless_applications", getBapiBaseUrl()); @@ -31,11 +42,25 @@ export async function createAccountlessApp(framework?: string): Promise controller.abort(), CREATE_TIMEOUT_MS); + + let response: Response; + try { + response = await loggedFetch(url, { + tag: "bapi", + method: "POST", + headers, + body: body.toString(), + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } if (!response.ok) { const text = await response.text(); - throw new Error(`Failed to create accountless application (${response.status}): ${text}`); + throw new BapiError(response.status, text, response.headers); } return response.json() as Promise; @@ -45,12 +70,13 @@ export async function writeKeysToEnvFile( cwd: string, keys: { publishableKey: string; secretKey: string }, ): Promise { - const [publishableKeyName, secretKeyName] = await Promise.all([ + const [publishableKeyName, secretKeyName, envFile] = await Promise.all([ detectPublishableKeyName(cwd), detectSecretKeyName(cwd), + detectEnvFile(cwd), ]); - const targetFile = join(cwd, ENV_LOCAL); + const targetFile = join(cwd, envFile); const existingContent = await Bun.file(targetFile) .text() .catch(() => ""); @@ -61,11 +87,12 @@ export async function writeKeysToEnvFile( }); await Bun.write(targetFile, serializeEnvFile(merged)); - log.info(`Environment variables written to ${ENV_LOCAL}`); + log.info(`Environment variables written to ${envFile}`); } export function parseClaimToken(claimUrl: string): string { - const base = claimUrl.startsWith("http") ? undefined : "https://placeholder.com"; + // WHATWG URL rejects bare relative paths without a base; use example.invalid (RFC 6761) + const base = claimUrl.startsWith("http") ? undefined : "https://example.invalid"; const token = new URL(claimUrl, base).searchParams.get("token"); if (!token) throw new Error(`No token parameter in claim URL: ${claimUrl}`); return token; @@ -75,7 +102,20 @@ function breadcrumbPath(cwd: string): string { return join(cwd, BREADCRUMB_DIR, BREADCRUMB_FILE); } +async function ensureGitignoreEntry(cwd: string, entry: string): Promise { + const gitignorePath = join(cwd, ".gitignore"); + const content = await Bun.file(gitignorePath) + .text() + .catch(() => ""); + const lines = content.split("\n").map((l) => l.trim()); + if (lines.includes(entry)) return; + const separator = content && !content.endsWith("\n") ? "\n" : ""; + await Bun.write(gitignorePath, `${content}${separator}${entry}\n`); + log.debug(`Added ${entry} to .gitignore`); +} + export async function writeKeylessBreadcrumb(cwd: string, claimToken: string): Promise { + await ensureGitignoreEntry(cwd, BREADCRUMB_DIR + "/"); await mkdir(join(cwd, BREADCRUMB_DIR), { recursive: true }); const breadcrumb: KeylessBreadcrumb = { @@ -89,7 +129,11 @@ export async function writeKeylessBreadcrumb(cwd: string, claimToken: string): P export async function readKeylessBreadcrumb(cwd: string): Promise { try { - return (await Bun.file(breadcrumbPath(cwd)).json()) as KeylessBreadcrumb; + const data: unknown = await Bun.file(breadcrumbPath(cwd)).json(); + if (isKeylessBreadcrumb(data)) return data; + log.debug("Keyless breadcrumb has wrong shape; clearing it to allow fresh setup"); + await clearKeylessBreadcrumb(cwd); + return undefined; } catch { return undefined; } From d90fd0adac9cd07dec4d13f3c7b662de87852240 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 21 Apr 2026 17:04:15 -0300 Subject: [PATCH 11/11] fix(review): address remaining PR #157 code review comments - autoclaim: wrap linkApp in tryLinkApp helper so a local config write failure after a successful server-side claim cannot surface as a failed login (preserves the never-throws contract) - login: clarify that failed-autoclaim retry happens on next `clerk auth login`, not just any subsequent command - keyless: raise breadcrumb shape-mismatch auto-repair log from debug to warn so users running without --verbose see the fix - init: differentiate AbortError (15s timeout) with a specific "Could not reach api.clerk.com within 15s" message - init tests: stub createAccountlessApp / writeKeysToEnvFile / writeKeylessBreadcrumb so keyless init tests stop minting real accountless apps against production BAPI on every test run --- packages/cli-core/src/commands/auth/login.ts | 3 ++- .../cli-core/src/commands/init/index.test.ts | 8 ++++++++ packages/cli-core/src/commands/init/index.ts | 6 +++++- packages/cli-core/src/lib/autoclaim.ts | 19 +++++++++++++++++-- packages/cli-core/src/lib/keyless.ts | 2 +- 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 682726aa..70f15548 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -135,7 +135,8 @@ const CLAIM_WARNINGS: Partial> = { not_found: "Claim token is no longer valid - the application may have been claimed from the dashboard.", no_organization: "Unable to claim - your account does not have an active organization.", - failed: "Auto-claim failed due to a temporary error. It will be retried on your next login.", + failed: + "Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.", }; async function handleAutoclaim(cwd: string): Promise { diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index 5da7fca0..7eabfa99 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -19,6 +19,7 @@ import * as heuristics from "./heuristics.ts"; 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 { init } from "./index.ts"; const FAKE_CTX = { @@ -91,6 +92,13 @@ describe("init", () => { spyOn(pullMod, "pull").mockResolvedValue(undefined), spyOn(bootstrapMod, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP), spyOn(bootstrapMod, "confirmOverwrite").mockResolvedValue(undefined), + spyOn(keylessMod, "createAccountlessApp").mockResolvedValue({ + publishable_key: "pk_test_stub", + secret_key: "sk_test_stub", + claim_url: "/apps/claim?token=stub_token", + }), + spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined), + spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined), ]; return { gatherContextSpy, captured }; diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 99a9581d..81748f0a 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -299,8 +299,12 @@ async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: strin 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( - "Could not set up development keys. Run `clerk auth login` then `clerk link` to connect your app manually.", + `${prefix} Run \`clerk auth login\` then \`clerk link\` to connect your app manually.`, ); } } diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index 2e502b3b..9df13dc2 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -56,8 +56,8 @@ export async function attemptAutoclaim(cwd: string): Promise { await clearKeylessBreadcrumb(cwd); if (result.status === "claimed") { - await linkApp(result.app, cwd); - const envPulled = await tryPullEnv(); + const linked = await tryLinkApp(result.app, cwd); + const envPulled = linked && (await tryPullEnv()); return { ...result, envPulled }; } @@ -73,6 +73,21 @@ async function tryClaim(claimToken: string, name: string): Promise } } +// Preserves the orchestrator's never-throws contract. Claim has already +// succeeded on the server and the breadcrumb is cleared — a local link +// failure must not surface as a failed login. +async function tryLinkApp(app: Application, cwd: string): Promise { + try { + await linkApp(app, cwd); + return true; + } catch (error) { + log.warn( + `Claim succeeded but linking the project locally failed: ${errorMessage(error)}. Run \`clerk link\` to finish setup.`, + ); + return false; + } +} + async function tryPullEnv(): Promise { try { await pull({}); diff --git a/packages/cli-core/src/lib/keyless.ts b/packages/cli-core/src/lib/keyless.ts index c41a5469..2ccfea89 100644 --- a/packages/cli-core/src/lib/keyless.ts +++ b/packages/cli-core/src/lib/keyless.ts @@ -131,7 +131,7 @@ export async function readKeylessBreadcrumb(cwd: string): Promise