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.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..70f15548 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"; @@ -94,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; } @@ -113,19 +120,54 @@ export async function login(options: LoginOptions = {}): Promise { bar(); log.success(`Logged in as ${userInfo.email}`); + const claimResult = 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(claimResult)); } 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.", + 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 `clerk auth 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; +} + +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 === "no_organization") { + return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK; + } + + 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/commands/doctor/checks.ts b/packages/cli-core/src/commands/doctor/checks.ts index e1682620..5c377c7d 100644 --- a/packages/cli-core/src/commands/doctor/checks.ts +++ b/packages/cli-core/src/commands/doctor/checks.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { fetchUserInfo } from "../../lib/token-exchange.ts"; -import { PlapiError } from "../../lib/errors.ts"; +import { PlapiError, errorMessage } from "../../lib/errors.ts"; import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts"; import { parseEnvFile } from "../../lib/dotenv.ts"; import { @@ -18,10 +18,6 @@ import type { CheckResult, DoctorContext, FixAction } from "./types.ts"; const AUTH_ERROR_STATUS = /\((401|403)\)/; -export function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - interface CheckOptions { remedy?: string; detail?: string; diff --git a/packages/cli-core/src/commands/doctor/index.ts b/packages/cli-core/src/commands/doctor/index.ts index 89cfdb0a..34e82d44 100644 --- a/packages/cli-core/src/commands/doctor/index.ts +++ b/packages/cli-core/src/commands/doctor/index.ts @@ -1,7 +1,7 @@ import { isHuman } from "../../mode.ts"; import { bold, green, red } from "../../lib/color.ts"; import { log } from "../../lib/log.ts"; -import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts"; import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts"; import { createDoctorContext } from "./context.ts"; import { @@ -14,7 +14,6 @@ import { checkConfigFile, checkShellCompletion, checkCliVersion, - errorMessage, } from "./checks.ts"; import { formatCheckResult, formatJson } from "./format.ts"; import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts"; diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 55676090..9e78cea3 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -224,4 +224,21 @@ 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 the keyless bootstrap path. | + 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. + +## Keyless breadcrumb + +In the keyless bootstrap path, 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. + +```json +{ + "claimToken": "", + "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 a926fd4c..3a0e3933 100644 --- a/packages/cli-core/src/commands/init/heuristics.ts +++ b/packages/cli-core/src/commands/init/heuristics.ts @@ -151,14 +151,10 @@ export async function isAuthenticated(): Promise { return (await getToken()) != null; } -export function printKeylessInfo(): void { +export function printKeylessInfo(envFile: string): 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 ${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.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 b83ae192..81748f0a 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -3,10 +3,16 @@ 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 } from "../../lib/errors.js"; +import { throwUserAbort, CliError, errorMessage } 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, ctx.envFile); } if (options.skills !== false) { @@ -276,6 +282,33 @@ 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, 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.`, + ); + } +} + // --- Detect & install --- async function detectAndInstall( 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..f8e2d4b6 --- /dev/null +++ b/packages/cli-core/src/lib/autoclaim.test.ts @@ -0,0 +1,187 @@ +import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { stubFetch, captureLog } from "../test/lib/stubs.ts"; + +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 = { + 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 captured: ReturnType; + let linkAppSpy: ReturnType; + let readBreadcrumbSpy: ReturnType; + let clearBreadcrumbSpy: ReturnType; + let pullSpy: 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 Profile, + }); + clearBreadcrumbSpy = spyOn(keylessMod, "clearKeylessBreadcrumb").mockResolvedValue(undefined); + readBreadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb").mockResolvedValue(undefined); + pullSpy = spyOn(pullMod, "pull").mockResolvedValue(undefined); + }); + + afterEach(async () => { + captured.teardown(); + delete process.env.CLERK_PLATFORM_API_KEY; + delete process.env.CLERK_PLATFORM_API_URL; + globalThis.fetch = originalFetch; + linkAppSpy.mockRestore(); + readBreadcrumbSpy.mockRestore(); + clearBreadcrumbSpy.mockRestore(); + pullSpy.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, calls linkApp, and pulls env 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(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 () => { + 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 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("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 })); + + 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"); + // 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 new file mode 100644 index 00000000..9df13dc2 --- /dev/null +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -0,0 +1,111 @@ +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"; +import { linkApp } from "./autolink.ts"; +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" | "no_organization" }; +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; + +const APP_NAME_MAX_CHARS = 50; + +const TERMINAL_BY_STATUS: Record = { + 404: "not_found", + 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 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; + + await clearKeylessBreadcrumb(cwd); + + if (result.status === "claimed") { + const linked = await tryLinkApp(result.app, cwd); + const envPulled = linked && (await tryPullEnv()); + return { ...result, envPulled }; + } + + 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); + } +} + +// 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({}); + return true; + } catch (error) { + log.debug(`Auto env pull failed: ${errorMessage(error)}`); + return false; + } +} + +function classifyClaimError(error: unknown): Terminal | Failed { + 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)); + log.debug(`Autoclaim failed: ${err.message}`); + return { status: "failed", error: err }; +} diff --git a/packages/cli-core/src/lib/autolink.ts b/packages/cli-core/src/lib/autolink.ts index aa5a4b12..00881a5d 100644 --- a/packages/cli-core/src/lib/autolink.ts +++ b/packages/cli-core/src/lib/autolink.ts @@ -76,6 +76,34 @@ export function matchKeyToApp( return undefined; } +/** Returns undefined when the app has no development instance. */ +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 +137,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/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 247322e9..16f43956 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -245,3 +245,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.test.ts b/packages/cli-core/src/lib/keyless.test.ts new file mode 100644 index 00000000..edc55496 --- /dev/null +++ b/packages/cli-core/src/lib/keyless.test.ts @@ -0,0 +1,231 @@ +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 () => { + 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); + + 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 + }); + + 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", () => { + 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 and env file 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", + }), + ); + + // 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"); + }); +}); + +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 BapiError on non-OK response", async () => { + stubFetch(async () => new Response("Server Error", { status: 500 })); + + 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 new file mode 100644 index 00000000..2ccfea89 --- /dev/null +++ b/packages/cli-core/src/lib/keyless.ts @@ -0,0 +1,149 @@ +import { join } from "node:path"; +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 { loggedFetch } from "./fetch.ts"; +import { log } from "./log.ts"; + +const BREADCRUMB_DIR = ".clerk"; +const BREADCRUMB_FILE = "keyless.json"; +const CREATE_TIMEOUT_MS = 15_000; + +interface AccountlessAppResponse { + publishable_key: string; + secret_key: string; + claim_url: string; +} + +interface KeylessBreadcrumb { + claimToken: string; + 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()); + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + ...(framework && { "Clerk-Framework": framework }), + }; + + const body = new URLSearchParams({ source: "cli" }); + const controller = new AbortController(); + const timer = setTimeout(() => 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 BapiError(response.status, text, response.headers); + } + + return response.json() as Promise; +} + +export async function writeKeysToEnvFile( + cwd: string, + keys: { publishableKey: string; secretKey: string }, +): Promise { + const [publishableKeyName, secretKeyName, envFile] = await Promise.all([ + detectPublishableKeyName(cwd), + detectSecretKeyName(cwd), + detectEnvFile(cwd), + ]); + + const targetFile = join(cwd, envFile); + const existingContent = await Bun.file(targetFile) + .text() + .catch(() => ""); + + const merged = mergeEnvVars(parseEnvFile(existingContent), { + [publishableKeyName]: keys.publishableKey, + [secretKeyName]: keys.secretKey, + }); + + await Bun.write(targetFile, serializeEnvFile(merged)); + log.info(`Environment variables written to ${envFile}`); +} + +export function parseClaimToken(claimUrl: string): string { + // 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; +} + +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 = { + 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 { + try { + const data: unknown = await Bun.file(breadcrumbPath(cwd)).json(); + if (isKeylessBreadcrumb(data)) return data; + log.warn("Keyless breadcrumb file has wrong shape; clearing it to allow fresh setup."); + await clearKeylessBreadcrumb(cwd); + return undefined; + } catch { + return undefined; + } +} + +export async function clearKeylessBreadcrumb(cwd: string): Promise { + try { + await unlink(breadcrumbPath(cwd)); + log.debug("Cleared keyless breadcrumb"); + } catch { + // idempotent + } +} diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index 609c44ef..7a2ad2d8 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -20,6 +20,19 @@ export const NEXT_STEPS = { "Run `clerk env pull --instance prod` to fetch production keys", "Run `clerk doctor` to verify your setup", ], + 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", + ], + 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; /** diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index caa5a743..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( @@ -177,6 +171,12 @@ export async function createApplication(name: string): Promise { return response.json() as Promise; } +export async function claimApplication(token: string, name: string): Promise { + const url = new URL("/v1/platform/accountless_applications/claim", getPlapiBaseUrl()); + const response = await plapiFetch("POST", url, { body: JSON.stringify({ token, name }) }); + return response.json() as Promise; +} + export async function listApplications(): Promise { const url = new URL("/v1/platform/applications", getPlapiBaseUrl()); const response = await plapiFetch("GET", url); 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 = {