From f13147de7b5532c4a4429e92cdcc302128ea71c7 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Mon, 2 Nov 2026 15:14:23 -0600 Subject: [PATCH 01/15] Name the app an unlinked folder runs on - `clerk deploy` and `clerk deploy status` now pick their `not_linked` message by what the folder holds: a claim token from a signed-out `clerk init` (`.clerk/keyless.json`) says to run `clerk link` to claim it; an SDK keyless file (`.clerk/.tmp/keyless.json`) says a person claims it with `clerk open` first; anything else keeps today's text. The error code is unchanged. - Agents hit `not_linked` in these folders and ran `clerk link`, which can't list an application no account owns, so they linked the wrong app or gave up. - New helper `lib/unlinked-advice.ts` reads the breadcrumb without side effects, so an error message never deletes a malformed file. - The SDK keyless file path moves into `lib/keyless.ts` so the helper can use it without importing the Backend API client. --- .../cli-core/src/commands/deploy/README.md | 12 +++ .../src/commands/deploy/index.test.ts | 57 +++++++++++- .../cli-core/src/commands/deploy/index.ts | 15 ++-- .../commands/deploy/status-command.test.ts | 72 ++++++++++++++- .../src/commands/deploy/status-command.ts | 8 +- .../src/commands/open/keyless-claim.ts | 4 +- packages/cli-core/src/lib/keyless.ts | 3 + .../cli-core/src/lib/unlinked-advice.test.ts | 90 +++++++++++++++++++ packages/cli-core/src/lib/unlinked-advice.ts | 44 +++++++++ 9 files changed, 287 insertions(+), 18 deletions(-) create mode 100644 packages/cli-core/src/lib/unlinked-advice.test.ts create mode 100644 packages/cli-core/src/lib/unlinked-advice.ts diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index 6e52df080..d4cdd24af 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -63,6 +63,18 @@ The handoff state is one of: Agent-mode `clerk deploy` without `--domain` is informational and exits 0 for linked projects. With `--domain` it exits 0 once the instance is created or resumed and the report is printed, and non-zero when it refuses the domain (`production_instance_exists`, `deploy_domain_unchecked`), when Clerk refuses the create, or when the instance's status can't be read afterwards (`deploy_instance_unresolved`, or Clerk's own code). Either way, the pass/fail gate for the deploy itself is `clerk deploy status`. +### An unlinked folder + +`clerk deploy` and `clerk deploy status` stop with `not_linked` in a folder with no link. The message says which kind of application the folder runs on, because only one kind is listed by `clerk link`: + +| Folder holds | Message tells you to | +| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| `.clerk/keyless.json` with a claim token, written by a signed-out `clerk init` | Run `clerk link`, which claims the application into your account and links the folder, then rerun the command. | +| `.clerk/.tmp/keyless.json`, written by a Clerk SDK in keyless mode | Have a person run `clerk open` to claim the application in the Dashboard, then run `clerk link`, choose it, and rerun the command. | +| Neither file | Run `clerk link`, then rerun the command. | + +The error code is `not_linked` in all three cases. Only the message text differs. + ### `clerk deploy status` `clerk deploy status` is the read-only verification command for agents and automation. It resolves the same live deploy state, triggers a DNS check for active production domains, and reports DNS, SSL, email DNS, and OAuth completeness. Human mode waits with the shared exponential-backoff poll loop; agent mode performs one quick DNS check and returns the resulting status immediately by default. Pass `--wait` in agent mode to wait with the same poll loop: one immediate status read, then up to 5 retries with exponential backoff. diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index e23fd6d62..527745688 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; import { useCaptureLog, listageStubs, captureTelemetryPayload } from "../../test/lib/stubs.ts"; @@ -544,6 +544,61 @@ describe("deploy", () => { ]); }); + describe("not_linked message names the app the folder runs on", () => { + const INIT_APP = + "This folder runs on an application `clerk init` created that no account owns yet. " + + "Run `clerk link` to claim it into your account and link this folder, then rerun `clerk deploy`."; + const SDK_APP = + "This folder runs on an application a Clerk SDK created that no account owns yet. " + + "`clerk link` can't list it until it's claimed: a person must run `clerk open` to claim it in the Dashboard. " + + "Then run `clerk link`, choose it, and rerun `clerk deploy`."; + const PLAIN = + "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`."; + + let cwdSpy: ReturnType | undefined; + + afterEach(() => { + cwdSpy?.mockRestore(); + cwdSpy = undefined; + }); + + /** An unlinked project whose folder (the temp dir) holds the given keyless file. */ + async function unlinkedFolder(state: "init_app" | "sdk_app" | "plain") { + await unlinkedProject(); + cwdSpy = spyOn(process, "cwd").mockReturnValue(tempDir); + if (state === "init_app") { + await mkdir(join(tempDir, ".clerk"), { recursive: true }); + await Bun.write( + join(tempDir, ".clerk", "keyless.json"), + JSON.stringify({ claimToken: "tok_init", createdAt: "2026-11-02T00:00:00.000Z" }), + ); + } + if (state === "sdk_app") { + await mkdir(join(tempDir, ".clerk", ".tmp"), { recursive: true }); + await Bun.write(join(tempDir, ".clerk", ".tmp", "keyless.json"), "{}"); + } + } + + test.each([ + ["init_app", "agent", INIT_APP], + ["sdk_app", "agent", SDK_APP], + ["plain", "agent", PLAIN], + ["init_app", "human", INIT_APP], + ["sdk_app", "human", SDK_APP], + ["plain", "human", PLAIN], + ] as const)("%s folder in %s mode", async (state, mode, expected) => { + await unlinkedFolder(state); + mockIsAgent.mockReturnValue(mode === "agent"); + + const error = await runDeploy({}).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).code).toBe(ERROR_CODE.NOT_LINKED); + expect((error as CliError).message).toBe(expected); + expect(mockFetchApplication).not.toHaveBeenCalled(); + }); + }); + describe("agent mode", () => { test("without --domain, not_started creates nothing and says how to create", async () => { mockIsAgent.mockReturnValue(true); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 942403018..41f41f1e1 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -92,6 +92,7 @@ import { type LiveDeploySnapshot, } from "./status.ts"; import { setTelemetryStage } from "../../lib/telemetry.ts"; +import { notLinkedMessage } from "../../lib/unlinked-advice.ts"; import { resolveActiveReportState, type OAuthSetupFacts } from "./report-state.ts"; import { recordDeployObservation, @@ -163,10 +164,9 @@ export async function deploy(options: DeployOptions = {}) { async function runAgentDeploy(domain: string | undefined): Promise { const ctx = await resolveDeployContext(); if (!ctx.appId || !ctx.developmentInstanceId) { - throw new CliError( - "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`.", - { code: ERROR_CODE.NOT_LINKED }, - ); + throw new CliError(await notLinkedMessage(process.cwd(), "clerk deploy"), { + code: ERROR_CODE.NOT_LINKED, + }); } if (!domain) { @@ -316,10 +316,9 @@ function uncheckedDomainError(requested: string, entered = false): CliError { async function runDeploy(ctx: DeployContext, options: DeployOptions): Promise { if (!ctx.appId || !ctx.developmentInstanceId) { - throw new CliError( - "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`.", - { code: ERROR_CODE.NOT_LINKED }, - ); + throw new CliError(await notLinkedMessage(process.cwd(), "clerk deploy"), { + code: ERROR_CODE.NOT_LINKED, + }); } if (ctx.productionInstanceId) { diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts index 64b9509ef..3ba4c74d6 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -1,8 +1,9 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CliError, ERROR_CODE, EXIT_CODE, PlapiError } from "../../lib/errors.ts"; +import { reportError } from "../../cli-program.ts"; import { captureTelemetryPayload, fakeTelemetryCommand, @@ -800,6 +801,73 @@ describe("deploy status", () => { // would be recorded rather than that the setter ran. The whole path, // including `runProgram` itself, is covered end to end in // `test/integration/telemetry.test.ts`. + describe("not_linked message names the app the folder runs on", () => { + const INIT_APP = + "This folder runs on an application `clerk init` created that no account owns yet. " + + "Run `clerk link` to claim it into your account and link this folder, then rerun `clerk deploy status`."; + const SDK_APP = + "This folder runs on an application a Clerk SDK created that no account owns yet. " + + "`clerk link` can't list it until it's claimed: a person must run `clerk open` to claim it in the Dashboard. " + + "Then run `clerk link`, choose it, and rerun `clerk deploy status`."; + const PLAIN = + "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy status`."; + + let folder = ""; + let cwdSpy: ReturnType | undefined; + + afterEach(async () => { + cwdSpy?.mockRestore(); + cwdSpy = undefined; + if (folder) await rm(folder, { recursive: true, force: true }); + folder = ""; + }); + + /** A folder with no profile, holding the given keyless file, as the working directory. */ + async function unlinkedFolder(state: "init_app" | "sdk_app" | "plain") { + folder = await mkdtemp(join(tmpdir(), "clerk-status-unlinked-test-")); + cwdSpy = spyOn(process, "cwd").mockReturnValue(folder); + if (state === "init_app") { + await mkdir(join(folder, ".clerk"), { recursive: true }); + await Bun.write( + join(folder, ".clerk", "keyless.json"), + JSON.stringify({ claimToken: "tok_init", createdAt: "2026-11-02T00:00:00.000Z" }), + ); + } + if (state === "sdk_app") { + await mkdir(join(folder, ".clerk", ".tmp"), { recursive: true }); + await Bun.write(join(folder, ".clerk", ".tmp", "keyless.json"), "{}"); + } + } + + test.each([ + ["init_app", INIT_APP], + ["sdk_app", SDK_APP], + ["plain", PLAIN], + ] as const)("%s folder: the agent JSON error carries the message", async (state, expected) => { + await unlinkedFolder(state); + + const error = await deployStatus().catch((caught: unknown) => caught); + captured.clear(); + const exitCode = reportError(error, false); + + expect(exitCode).toBe(EXIT_CODE.GENERAL); + expect(JSON.parse(captured.err.trim())).toEqual({ + error: { code: "not_linked", message: expected }, + }); + }); + + test("init_app folder in human mode prints the same message", async () => { + setMode("human"); + await unlinkedFolder("init_app"); + + const error = await deployStatus().catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).code).toBe(ERROR_CODE.NOT_LINKED); + expect((error as CliError).message).toBe(INIT_APP); + }); + }); + describe("telemetry", () => { function recordedResult() { return telemetryResultForSoftExit(Number(process.exitCode ?? EXIT_CODE.SUCCESS)); diff --git a/packages/cli-core/src/commands/deploy/status-command.ts b/packages/cli-core/src/commands/deploy/status-command.ts index 718b0f75a..c82360311 100644 --- a/packages/cli-core/src/commands/deploy/status-command.ts +++ b/packages/cli-core/src/commands/deploy/status-command.ts @@ -28,6 +28,7 @@ import { } from "./status.ts"; import { recordDeployObservation, recordDeployPoll } from "./telemetry.ts"; import type { DeployContext } from "./state.ts"; +import { notLinkedMessage } from "../../lib/unlinked-advice.ts"; type DeployStatusOptions = { wait?: boolean; @@ -52,10 +53,9 @@ export async function deployStatus(options: DeployStatusOptions = {}): Promise { + folder = await mkdtemp(join(tmpdir(), "clerk-unlinked-advice-test-")); +}); + +afterEach(async () => { + await rm(folder, { recursive: true, force: true }); +}); + +async function writeInitBreadcrumb(dir: string): Promise { + await mkdir(join(dir, ".clerk"), { recursive: true }); + await Bun.write( + join(dir, ".clerk", "keyless.json"), + JSON.stringify({ claimToken: "tok_init", createdAt: "2026-11-02T00:00:00.000Z" }), + ); +} + +async function writeSdkKeylessFile(dir: string): Promise { + await mkdir(join(dir, ".clerk", ".tmp"), { recursive: true }); + await Bun.write( + join(dir, ".clerk", ".tmp", "keyless.json"), + JSON.stringify({ claimUrl: "https://dashboard.clerk.com/apps/claim?token=tok_sdk" }), + ); +} + +describe("classifyUnlinkedFolder", () => { + useCaptureLog(); + + test("a folder with neither keyless file is plain", async () => { + expect(await classifyUnlinkedFolder(folder)).toBe("plain"); + }); + + test("a clerk init breadcrumb with a claim token is init_app", async () => { + await writeInitBreadcrumb(folder); + expect(await classifyUnlinkedFolder(folder)).toBe("init_app"); + }); + + test("an SDK keyless file without a breadcrumb is sdk_app", async () => { + await writeSdkKeylessFile(folder); + expect(await classifyUnlinkedFolder(folder)).toBe("sdk_app"); + }); + + test("the breadcrumb wins when both files exist, because clerk link can claim it", async () => { + await writeInitBreadcrumb(folder); + await writeSdkKeylessFile(folder); + expect(await classifyUnlinkedFolder(folder)).toBe("init_app"); + }); + + test("a malformed breadcrumb is left on disk and does not count as init_app", async () => { + const path = join(folder, ".clerk", "keyless.json"); + await mkdir(join(folder, ".clerk"), { recursive: true }); + await Bun.write(path, JSON.stringify({ unexpected: true })); + + expect(await classifyUnlinkedFolder(folder)).toBe("plain"); + expect(await Bun.file(path).exists()).toBe(true); + }); +}); + +describe("notLinkedMessage", () => { + test("plain keeps today's text with the caller's rerun command", async () => { + expect(await notLinkedMessage(folder, "clerk deploy status")).toBe( + "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy status`.", + ); + }); + + test("init_app says clerk link claims the app", async () => { + await writeInitBreadcrumb(folder); + expect(await notLinkedMessage(folder, "clerk deploy")).toBe( + "This folder runs on an application `clerk init` created that no account owns yet. " + + "Run `clerk link` to claim it into your account and link this folder, then rerun `clerk deploy`.", + ); + }); + + test("sdk_app says a person claims it with clerk open first", async () => { + await writeSdkKeylessFile(folder); + expect(await notLinkedMessage(folder, "clerk deploy")).toBe( + "This folder runs on an application a Clerk SDK created that no account owns yet. " + + "`clerk link` can't list it until it's claimed: a person must run `clerk open` to claim it in the Dashboard. " + + "Then run `clerk link`, choose it, and rerun `clerk deploy`.", + ); + }); +}); diff --git a/packages/cli-core/src/lib/unlinked-advice.ts b/packages/cli-core/src/lib/unlinked-advice.ts new file mode 100644 index 000000000..700e785ab --- /dev/null +++ b/packages/cli-core/src/lib/unlinked-advice.ts @@ -0,0 +1,44 @@ +import { join } from "node:path"; +import { peekKeylessBreadcrumb, SDK_KEYLESS_FILE } from "./keyless.ts"; + +/** + * Who created the application an unlinked folder runs on, when no account owns it yet: + * + * - `init_app`: a signed-out `clerk init` left its claim token in `.clerk/keyless.json`. + * `clerk link` can claim it. + * - `sdk_app`: a Clerk SDK in keyless mode wrote `.clerk/.tmp/keyless.json`. Only the + * Dashboard claim page (`clerk open`) can claim it. + * - `plain`: neither file, so the folder is just unlinked. + */ +export type UnlinkedFolder = "init_app" | "sdk_app" | "plain"; + +export async function classifyUnlinkedFolder(cwd: string): Promise { + // Peek, not read: `readKeylessBreadcrumb` deletes a malformed file, and an error + // message must not change the folder it describes. + const breadcrumb = await peekKeylessBreadcrumb(cwd); + if (breadcrumb?.claimToken) return "init_app"; + if (await Bun.file(join(cwd, ...SDK_KEYLESS_FILE)).exists()) return "sdk_app"; + return "plain"; +} + +/** + * The `not_linked` message for a command that needs a linked folder. `rerun` is the + * command to retry afterwards, e.g. "clerk deploy status". + */ +export async function notLinkedMessage(cwd: string, rerun: string): Promise { + switch (await classifyUnlinkedFolder(cwd)) { + case "init_app": + return ( + "This folder runs on an application `clerk init` created that no account owns yet. " + + `Run \`clerk link\` to claim it into your account and link this folder, then rerun \`${rerun}\`.` + ); + case "sdk_app": + return ( + "This folder runs on an application a Clerk SDK created that no account owns yet. " + + "`clerk link` can't list it until it's claimed: a person must run `clerk open` to claim it in the Dashboard. " + + `Then run \`clerk link\`, choose it, and rerun \`${rerun}\`.` + ); + case "plain": + return `No Clerk project linked to this directory. Run \`clerk link\`, then rerun \`${rerun}\`.`; + } +} From 5562f28602962008c53282dc1e37bcd3d272ed6a Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Mon, 2 Nov 2026 15:15:09 -0600 Subject: [PATCH 02/15] Stop autoclaim when keys are another app's - After a successful claim, `attemptAutoclaim` compares the folder's publishable keys (env var, `.env`, `.env.local`) with the claimed app's development key. On any mismatch it links nothing and pulls nothing, and returns `keys_mismatch` with the sources. - Before, `clerk auth login` linked the claimed app and its env pull wrote the claimed app's secret key next to another app's publishable key. - `clerk auth login` prints the mismatch as a warning, exits 0, and shows the manual-link next steps. The breadcrumb stays cleared because the claim already happened. - The claim warning texts move from `login.ts` into `lib/autoclaim.ts` so `clerk link` can print the same ones. --- packages/cli-core/src/commands/auth/README.md | 1 + .../cli-core/src/commands/auth/login.test.ts | 38 +++++++++ packages/cli-core/src/commands/auth/login.ts | 24 ++---- packages/cli-core/src/lib/autoclaim.test.ts | 85 ++++++++++++++++++- packages/cli-core/src/lib/autoclaim.ts | 65 +++++++++++++- 5 files changed, 192 insertions(+), 21 deletions(-) diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index 333eacdf6..ef976402b 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -27,6 +27,7 @@ When `clerk init` runs in accountless mode it writes `.clerk/keyless.json` conta - **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. +- **Publishable key for another application** — the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), set in the environment, `.env`, or `.env.local`. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0. To finish, run `clerk link --app ` with the application the folder should use. - **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. The command prints the Dashboard URL of the claimed app's development instance, since this is the first time the app has a home in an account. #### API Endpoints diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index d3a208220..b860ad17d 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -101,7 +101,10 @@ const mockAttemptAutoclaim = mock(async (_cwd: string): Promise status: "not_keyless", })); +// The real warning texts, so these tests pin what `clerk auth login` prints. +const actualAutoclaim = await import("../../lib/autoclaim.ts"); mock.module("../../lib/autoclaim.ts", () => ({ + ...actualAutoclaim, attemptAutoclaim: (cwd: string) => mockAttemptAutoclaim(cwd), })); @@ -693,6 +696,41 @@ describe("login", () => { ); }); + test("warns, links nothing, and exits 0 when the folder's key belongs to another app", async () => { + // Human mode: only it prints the next steps. + mockIsHuman.mockReturnValue(true); + mockGetValidToken.mockResolvedValue(null); + mockOAuthSuccess(); + mockResolveProfile.mockResolvedValue(undefined); + const exitCodeBefore = process.exitCode; + mockAttemptAutoclaim.mockResolvedValue({ + status: "keys_mismatch", + sources: [".env.local"], + app: { + application_id: "app_claimed", + name: "bad-agent", + instances: [ + { + instance_id: "ins_dev_claimed", + environment_type: "development", + publishable_key: "pk_test_claimed", + }, + ], + } as Application, + }); + + await runLogin(); + + expect(captured.err).toContain( + "Claimed application `bad-agent` (app_claimed) into your account, but .env.local holds a publishable key for a different application, " + + "so this folder wasn't linked and its keys weren't changed. " + + "Run `clerk link --app ` with the application this folder should use.", + ); + expect(captured.err).not.toContain("Claimed and linked application"); + expect(captured.err).toContain("Run `clerk link` to connect your Clerk application"); + expect(process.exitCode).toBe(exitCodeBefore); + }); + test("links to the development instance even when production is listed first", async () => { mockGetValidToken.mockResolvedValue(null); mockOAuthSuccess(); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index f47a173d2..3a9d467e8 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -21,7 +21,7 @@ import { isHuman } from "../../mode.ts"; import { ERROR_CODE, errorMessage, 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 { attemptAutoclaim, claimWarning, 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"; @@ -274,22 +274,6 @@ async function runLogin(options: LoginOptions = {}): Promise { 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`.", -}; - -function claimWarning(result: AutoclaimResult): string | undefined { - if (result.status === "managed_workspace") { - // The API message already names the provider and says what to do. - return `Unable to claim - ${result.longMessage ?? "this workspace is managed by an integration provider."}`; - } - return CLAIM_WARNINGS[result.status]; -} - async function handleAutoclaim(cwd: string): Promise { const result = await attemptAutoclaim(cwd); @@ -321,7 +305,11 @@ async function loginNextSteps(result: AutoclaimResult): Promise { // no package.json in tempDir, so basename is used; assert exact value expect(parsed.name).toBe(basename(tempDir)); }); + describe("publishable keys already in the folder", () => { + const originalKey = process.env.CLERK_PUBLISHABLE_KEY; + + beforeEach(() => { + delete process.env.CLERK_PUBLISHABLE_KEY; + }); + + afterEach(() => { + if (originalKey === undefined) delete process.env.CLERK_PUBLISHABLE_KEY; + else process.env.CLERK_PUBLISHABLE_KEY = originalKey; + }); + + test("a key for another app stops before linking or pulling, and the env file is unchanged", async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + const envPath = join(tempDir, ".env"); + const before = + "# keep me\nCLERK_PUBLISHABLE_KEY=pk_test_other\nCLERK_SECRET_KEY=sk_test_other\n"; + await Bun.write(envPath, before); + + const result = await run(); + + expect(result).toEqual({ + status: "keys_mismatch", + app: MOCK_APP as never, + sources: [".env"], + }); + expect(linkAppSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + expect(await Bun.file(envPath).text()).toBe(before); + // The claim happened, so the token is spent either way. + expect(clearBreadcrumbSpy).toHaveBeenCalledWith(tempDir); + }); + + test("an exported key for another app names the env var as the source", async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + process.env.CLERK_PUBLISHABLE_KEY = "pk_test_other"; + + const result = await run(); + + expect(result.status).toBe("keys_mismatch"); + if (result.status === "keys_mismatch") { + expect(result.sources).toEqual(["CLERK_PUBLISHABLE_KEY env var"]); + } + expect(linkAppSpy).not.toHaveBeenCalled(); + }); + + test("the claimed app's own key links and pulls as before", async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + await Bun.write(join(tempDir, ".env"), "CLERK_PUBLISHABLE_KEY=pk_test_abc\n"); + + const result = await run(); + + expect(result.status).toBe("claimed"); + expect(linkAppSpy).toHaveBeenCalledWith(MOCK_APP, tempDir); + expect(pullSpy).toHaveBeenCalledWith({}); + }); + }); +}); + +describe("keysMismatchMessage", () => { + const app = MOCK_APP as never; + + test("names the one source holding the other app's key", () => { + expect(keysMismatchMessage({ status: "keys_mismatch", app, sources: [".env"] })).toBe( + "Claimed application `My Claimed App` (app_claimed) into your account, but .env holds a publishable key for a different application, " + + "so this folder wasn't linked and its keys weren't changed. " + + "Run `clerk link --app ` with the application this folder should use.", + ); + }); + + test("lists several sources", () => { + expect( + keysMismatchMessage({ status: "keys_mismatch", app, sources: [".env", ".env.local"] }), + ).toContain("but .env and .env.local hold publishable keys for a different application"); + }); + + test("is the warning clerk auth login prints for the mismatch", () => { + const result = { status: "keys_mismatch" as const, app, sources: [".env"] }; + expect(claimWarning(result)).toBe(keysMismatchMessage(result)); + }); }); diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index b323f7e59..5f669b064 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -1,7 +1,7 @@ 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 { findClerkKeys, linkApp } from "./autolink.ts"; import { deriveProjectName } from "./project-name.ts"; import { pull } from "../commands/env/pull.ts"; import { log } from "./log.ts"; @@ -18,8 +18,20 @@ type Terminal = { status: "not_found" | "no_organization" }; type ManagedWorkspace = { status: "managed_workspace"; longMessage: string | null }; type Failed = { status: "failed"; error: Error }; type Skipped = { status: "not_keyless" }; +/** + * The claim succeeded, but a publishable key in the folder belongs to another application, + * so linking and pulling would pair that key with the claimed app's secret key. Nothing was + * linked or written. `sources` names where each mismatching key was found. + */ +export type KeysMismatch = { status: "keys_mismatch"; app: Application; sources: string[] }; -export type AutoclaimResult = Claimed | Terminal | ManagedWorkspace | Failed | Skipped; +export type AutoclaimResult = + | Claimed + | Terminal + | ManagedWorkspace + | Failed + | Skipped + | KeysMismatch; type ClaimAttempt = { status: "claimed"; app: Application } | Terminal | ManagedWorkspace | Failed; @@ -44,6 +56,8 @@ export async function attemptAutoclaim(cwd: string): Promise { await clearKeylessBreadcrumb(cwd); if (result.status === "claimed") { + const sources = await mismatchingKeySources(result.app, cwd); + if (sources.length > 0) return { status: "keys_mismatch", app: result.app, sources }; const linked = await tryLinkApp(result.app, cwd); const envPulled = linked && (await tryPullEnv()); return { ...result, envPulled }; @@ -52,6 +66,21 @@ export async function attemptAutoclaim(cwd: string): Promise { return result; } +async function mismatchingKeySources(app: Application, cwd: string): Promise { + // Deserialized API JSON: a claim response without instances must not throw here. + const expected = app.instances?.find( + (instance) => instance.environment_type === "development", + )?.publishable_key; + if (!expected) return []; + try { + const keys = await findClerkKeys(cwd); + return keys.filter((found) => found.key !== expected).map((found) => found.source); + } catch (error) { + log.debug(`autoclaim: could not read local publishable keys: ${errorMessage(error)}`); + return []; + } +} + async function tryClaim(claimToken: string, name: string): Promise { try { const app = await claimApplication(claimToken, name); @@ -106,3 +135,35 @@ function classifyClaimError(error: unknown): Terminal | ManagedWorkspace | Faile log.debug(`Autoclaim failed: ${err.message}`); return { status: "failed", error: err }; } + +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`.", +}; + +/** The warning `clerk auth login` and `clerk link` print for a claim that didn't link the folder. */ +export function claimWarning(result: AutoclaimResult): string | undefined { + if (result.status === "managed_workspace") { + // The API message already names the provider and says what to do. + return `Unable to claim - ${result.longMessage ?? "this workspace is managed by an integration provider."}`; + } + if (result.status === "keys_mismatch") return keysMismatchMessage(result); + return CLAIM_WARNINGS[result.status]; +} + +export function keysMismatchMessage(result: KeysMismatch): string { + const { app, sources } = result; + const label = app.name ? `\`${app.name}\` (${app.application_id})` : `\`${app.application_id}\``; + const holder = + sources.length === 1 + ? `${sources[0]} holds a publishable key` + : `${sources.slice(0, -1).join(", ")} and ${sources.at(-1)} hold publishable keys`; + return ( + `Claimed application ${label} into your account, but ${holder} for a different application, ` + + "so this folder wasn't linked and its keys weren't changed. " + + "Run `clerk link --app ` with the application this folder should use." + ); +} From 5db9708aa847489435560b8c76d1bcae59bc832f Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Mon, 2 Nov 2026 15:15:54 -0600 Subject: [PATCH 03/15] Claim the clerk init app in clerk link - `clerk link` with no link and no `--app`, in a folder holding a claim token from a signed-out `clerk init`, now signs in if needed, claims that application into the account, links the folder, and writes its keys. It prints "Claimed and linked application: ..." with no prompt in either mode. - Before, `clerk link` listed only owned applications, so a person got a picker that couldn't show the app and an agent got the usage error. - When the folder's publishable key belongs to another application, `clerk link` stops with `key_pair_mismatch` and changes nothing. Other claim failures print the same warning as `clerk auth login` and continue with the usual flow. - Only the `clerk link` command claims: it sets the internal `claimKeylessApp` option on `link()`. `clerk init`, `clerk migrate` and the `clerk doctor` link fix call `link()` without it. - `login()` takes `claim: false`, which `clerk link` passes so it is the one step that claims and sees the result, including a key mismatch. --- packages/cli-core/src/commands/auth/README.md | 6 +- .../cli-core/src/commands/auth/login.test.ts | 20 ++ packages/cli-core/src/commands/auth/login.ts | 15 +- packages/cli-core/src/commands/link/README.md | 54 ++-- .../cli-core/src/commands/link/index.test.ts | 230 +++++++++++++++++- packages/cli-core/src/commands/link/index.ts | 57 ++++- 6 files changed, 355 insertions(+), 27 deletions(-) diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index ef976402b..c6fa73d40 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -18,16 +18,16 @@ Before signing in, the command prints a banner: the Clerk logo beside a link to 6. Exchanges the code for an access token 7. Stores the token and user info in local config 8. If this was a re-authentication over an existing session, revokes the previous grant. The outgoing session is read once the authorization code arrives and just before the token exchange replaces it, and revoked only after the replacement is stored — so an abandoned browser flow leaves the original session intact, and a concurrent refresh has the smallest possible window to rotate the token out from under the revocation. A failure here warns rather than failing the login -9. **Autoclaim**: if `.clerk/keyless.json` exists in the current directory, claims the temporary application, links it to the project, and pulls environment variables +9. **Autoclaim**: if `.clerk/keyless.json` exists in the current directory, claims the temporary application, links it to the project, and pulls environment variables. `clerk link` signs in through the same `login()` with claiming turned off (`claim: false`) and then claims the application itself, so it can act on the result #### Accountless autoclaim breadcrumb lifecycle -When `clerk init` runs in accountless mode it writes `.clerk/keyless.json` containing a claim token. On the next `clerk auth login`: +When `clerk init` runs in accountless mode it writes `.clerk/keyless.json` containing a claim token. On the next `clerk auth login` (or `clerk link`, which runs the same claim): - **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. -- **Publishable key for another application** — the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), set in the environment, `.env`, or `.env.local`. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0. To finish, run `clerk link --app ` with the application the folder should use. +- **Publishable key for another application** — the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), set in the environment, `.env`, or `.env.local`. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. - **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. The command prints the Dashboard URL of the claimed app's development instance, since this is the first time the app has a home in an account. #### API Endpoints diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index b860ad17d..bff62a0ac 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -731,6 +731,26 @@ describe("login", () => { expect(process.exitCode).toBe(exitCodeBefore); }); + test("claim: false signs in without claiming, for clerk link to claim itself", async () => { + mockGetValidToken.mockResolvedValue(null); + mockOAuthSuccess(); + + await runLogin({ showNextSteps: false, claim: false }); + + expect(mockAttemptAutoclaim).not.toHaveBeenCalled(); + expect(captured.err).not.toContain("Claimed and linked application"); + }); + + test("claim: false also skips the claim when an agent reuses a session", async () => { + mockGetValidToken.mockResolvedValue("existing-token"); + mockGetAuth.mockResolvedValue({ userId: "user_123" }); + mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "existing@example.com" }); + + await runLogin({ claim: false }); + + expect(mockAttemptAutoclaim).not.toHaveBeenCalled(); + }); + test("links to the development instance even when production is listed first", async () => { mockGetValidToken.mockResolvedValue(null); mockOAuthSuccess(); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 3a9d467e8..0d0fba1d1 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -31,6 +31,11 @@ import { ensureFirstApplication } from "../../lib/first-application.ts"; interface LoginOptions { showNextSteps?: boolean; yes?: boolean; + /** + * `false` skips claiming the folder's keyless app after sign-in. `clerk link` passes it + * because it claims right after signing in and must see the claim's result itself. + */ + claim?: boolean; } async function getExistingSession(): Promise { @@ -204,7 +209,7 @@ export async function login(options: LoginOptions = {}): Promise { } async function runLogin(options: LoginOptions = {}): Promise { - const { showNextSteps = true, yes } = options; + const { showNextSteps = true, yes, claim = true } = options; intro("Signing in"); setTelemetryStage("session_check"); const existingSession = await withSpinner("Checking session...", async () => @@ -214,7 +219,9 @@ async function runLogin(options: LoginOptions = {}): Promise { if (existingSession && !isHuman()) { setTelemetryStage("done"); log.success(`Logged in as ${existingSession.email}`); - const claimResult = await handleAutoclaim(process.cwd()); + const claimResult: AutoclaimResult = claim + ? await handleAutoclaim(process.cwd()) + : { status: "not_keyless" }; if (showNextSteps) { await outro(await loginNextSteps(claimResult)); } else { @@ -263,7 +270,9 @@ async function runLogin(options: LoginOptions = {}): Promise { setTelemetryStage("done"); log.success(`Logged in as ${userInfo.email}`); - const claimResult = await handleAutoclaim(process.cwd()); + const claimResult: AutoclaimResult = claim + ? await handleAutoclaim(process.cwd()) + : { status: "not_keyless" }; if (showNextSteps) { await outro(await loginNextSteps(claimResult)); diff --git a/packages/cli-core/src/commands/link/README.md b/packages/cli-core/src/commands/link/README.md index 2797566ca..8c2bb8655 100644 --- a/packages/cli-core/src/commands/link/README.md +++ b/packages/cli-core/src/commands/link/README.md @@ -24,7 +24,8 @@ In agent mode (`--mode agent` or piped stdout), `clerk link` only runs through deterministic paths: - `clerk link --app app_abc123` links directly -- bare `clerk link` first tries silent autolink from detected publishable keys +- bare `clerk link` in a folder a signed-out `clerk init` set up first claims that application (see [Claiming the `clerk init` application](#claiming-the-clerk-init-application)) +- bare `clerk link` then tries silent autolink from detected publishable keys - if no unambiguous app can be determined, the command exits with a usage error telling the caller to pass `--app` @@ -34,25 +35,49 @@ creates a fresh app named after the project instead of erroring. The CLI does not expose `createIfMissing` as a flag — it is library-only and reserved for `clerk init`'s flow. +## Claiming the `clerk init` application + +A signed-out `clerk init` creates an application that no account owns and +saves its claim token in `.clerk/keyless.json`. The picker can't list that +application, so bare `clerk link` claims it first, when the folder has no link +and no `--app` is given: + +1. Signs in if needed, without letting sign-in claim the application itself +2. Claims the application into your account with the token, which deletes `.clerk/keyless.json` +3. Compares the publishable keys in the env var, `.env`, and `.env.local` with the claimed application's development key +4. If they all match (or there are none), links the folder, writes the application's keys to the env file, prints "Claimed and linked application: ...", and stops +5. If any key belongs to a different application, links nothing, leaves the env files unchanged, and exits 1 with `key_pair_mismatch`. The message names the claimed application and the file, and tells you to run `clerk link --app ` with the application the folder should use +6. If the claim fails (expired token, no organization, managed workspace, or a temporary error), prints the same warning `clerk auth login` prints and continues with the normal flow below + +Neither mode prompts during the claim. `--app` or an existing link skips it. +Only the `clerk link` command claims: `clerk init`, `clerk migrate`, and the +`clerk doctor` link fix call the same `link()` without the internal +`claimKeylessApp` option, so their flows never claim. + +A Clerk SDK in keyless mode writes `.clerk/.tmp/keyless.json` instead, which +`clerk link` can't claim. Claim that application in the Dashboard with +`clerk open`, then run `clerk link` and choose it. + ## Flow 1. Resolves the normalized git remote URL (e.g., `github.com/org/repo`) for cross-clone matching 2. Checks if already linked — if resolved via a remote URL from another clone, prints an auto-link notice - With `skipIfLinked` (used by `clerk init`), returns immediately - Otherwise, offers to upgrade the profile key to use the git remote if available, or asks to re-link -3. If `skipIfLinked` and not already linked, tries silent autolink (detect keys → match → persist without prompting) -4. Checks for authentication (calls `clerk auth login` if needed) -5. If `--app` is provided, fetches that app directly -6. Otherwise, fetches the list of applications and scans for publishable keys in env vars / `.env` / `.env.local` -7. If a key matches an application, suggests it: "We found \. Link to this application?" +3. When run as the `clerk link` command with no link and no `--app`, claims the application `clerk init` left a claim token for (see above) +4. If `skipIfLinked` or agent mode and not already linked, tries silent autolink (detect keys → match → persist without prompting) +5. Checks for authentication (calls `clerk auth login` if needed) +6. If `--app` is provided, fetches that app directly +7. Otherwise, fetches the list of applications and scans for publishable keys in env vars / `.env` / `.env.local` +8. If a key matches an application, suggests it: "We found \. Link to this application?" - If the user confirms (default), links to the detected app - If the user declines, falls through to the interactive picker -8. If no match (or no apps exist), presents a searchable picker (type to filter by name) +9. If no match (or no apps exist), presents a searchable picker (type to filter by name) - The picker always includes a "+ Create a new application" option pinned at the top - Selecting it prompts for a name and creates the app via the Platform API - For non-interactive/CI/agent flows, create apps from the Clerk Dashboard or via the Platform API, then pass `--app ` -9. Stores the profile in the config file keyed by the normalized remote URL -10. Falls back to git-common-dir or the current directory path if no remote is configured +10. Stores the profile in the config file keyed by the normalized remote URL +11. Falls back to git-common-dir or the current directory path if no remote is configured ## Key Detection @@ -81,8 +106,9 @@ Detected publishable keys are matched against all applications returned by ## API Endpoints -| Method | Endpoint | Description | -| ------ | ----------------------------------- | ------------------------------------------------ | -| `GET` | `/v1/platform/applications` | List all applications for the authenticated user | -| `GET` | `/v1/platform/applications/{appId}` | Fetch application details with instance IDs | -| `POST` | `/v1/platform/applications` | Create a new application (interactive picker) | +| Method | Endpoint | Description | +| ------ | --------------------------------------------- | -------------------------------------------------------------- | +| `GET` | `/v1/platform/applications` | List all applications for the authenticated user | +| `GET` | `/v1/platform/applications/{appId}` | Fetch application details with instance IDs | +| `POST` | `/v1/platform/applications` | Create a new application (interactive picker) | +| `POST` | `/v1/platform/accountless_applications/claim` | Claim the application `clerk init` created, by its claim token | diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index e36dcb2a9..d0ec4ac8e 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -1,4 +1,8 @@ -import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; +import { test, expect, describe, afterEach, beforeEach, mock, spyOn } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Command } from "@commander-js/extra-typings"; import { useCaptureLog, configStubs, @@ -7,7 +11,8 @@ import { gitStubs, listageStubs, } from "../../test/lib/stubs.ts"; -import { PlapiError } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, EXIT_CODE, PlapiError } from "../../lib/errors.ts"; +import "../../lib/help.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; const mockIsAgent = mock(); @@ -37,7 +42,10 @@ mock.module("../auth/login.ts", () => ({ const mockListApplications = mock(); const mockFetchApplication = mock(); const mockCreateApplication = mock(); +const mockClaimApplication = mock(); mock.module("../../lib/plapi.ts", () => ({ + claimApplication: (...args: unknown[]) => mockClaimApplication(...args), + validateKeyPrefix: () => {}, listApplications: (...args: unknown[]) => mockListApplications(...args), fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), createApplication: (...args: unknown[]) => mockCreateApplication(...args), @@ -60,8 +68,10 @@ mock.module("../../lib/config.ts", () => ({ const mockAutolink = mock(); const mockFindClerkKeys = mock(); const mockMatchKeyToApp = mock(); +const mockLinkApp = mock(); mock.module("../../lib/autolink.ts", () => ({ ...autolinkStubs, + linkApp: (...args: unknown[]) => mockLinkApp(...args), autolink: (...args: unknown[]) => mockAutolink(...args), findClerkKeys: (...args: unknown[]) => mockFindClerkKeys(...args), matchKeyToApp: (...args: unknown[]) => mockMatchKeyToApp(...args), @@ -101,7 +111,12 @@ mock.module("../../lib/spinner.ts", () => ({ withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); -const { link, replacedLink } = await import("./index.ts"); +const mockPull = mock(); +mock.module("../env/pull.ts", () => ({ + pull: (...args: unknown[]) => mockPull(...args), +})); + +const { link, replacedLink, registerLink } = await import("./index.ts"); const mockApp = { application_id: "app_123", @@ -133,6 +148,9 @@ describe("link", () => { mockListApplications.mockReset(); mockFetchApplication.mockReset(); mockCreateApplication.mockReset(); + mockClaimApplication.mockReset(); + mockLinkApp.mockReset(); + mockPull.mockReset(); mockSetProfile.mockReset(); mockResolveProfile.mockReset(); mockResolveProfile.mockResolvedValue(undefined); @@ -1155,6 +1173,212 @@ describe("link", () => { }); }); + describe("claiming the app a signed-out clerk init created", () => { + const claimedApp = { + application_id: "app_claimed", + name: "My Init App", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_init", + }, + ], + }; + let folder = ""; + + beforeEach(async () => { + folder = await mkdtemp(join(tmpdir(), "clerk-link-claim-test-")); + await mkdir(join(folder, ".clerk"), { recursive: true }); + await Bun.write( + join(folder, ".clerk", "keyless.json"), + JSON.stringify({ claimToken: "tok_init", createdAt: "2026-11-02T00:00:00.000Z" }), + ); + mockLinkApp.mockResolvedValue({ path: folder, profile: {} }); + mockPull.mockResolvedValue(undefined); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(async () => { + await rm(folder, { recursive: true, force: true }); + }); + + function breadcrumbExists() { + return Bun.file(join(folder, ".clerk", "keyless.json")).exists(); + } + + test("signed out: signs in without letting login claim, then claims and links once", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue(null); + mockLogin.mockResolvedValue({ email: "a@b.c" }); + mockClaimApplication.mockResolvedValue(claimedApp); + + await runLink({ cwd: folder, claimKeylessApp: true }); + + expect(mockLogin).toHaveBeenCalledWith({ showNextSteps: false, claim: false }); + expect(mockClaimApplication).toHaveBeenCalledTimes(1); + expect(mockClaimApplication).toHaveBeenCalledWith("tok_init", expect.any(String)); + expect(mockLinkApp).toHaveBeenCalledWith(claimedApp, folder); + expect(mockPull).toHaveBeenCalledWith({}); + expect(captured.err).toContain("Claimed and linked application: `My Init App`"); + expect(mockOutro).toHaveBeenLastCalledWith(NEXT_STEPS.AUTOCLAIMED); + expect(mockListApplications).not.toHaveBeenCalled(); + expect(mockSearch).not.toHaveBeenCalled(); + expect(await breadcrumbExists()).toBe(false); + }); + + test("agent mode, signed in: claims and links without prompts", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockResolvedValue(claimedApp); + + await runLink({ cwd: folder, claimKeylessApp: true }); + + expect(mockLogin).not.toHaveBeenCalled(); + expect(mockLinkApp).toHaveBeenCalledWith(claimedApp, folder); + expect(captured.err).toContain("Claimed and linked application: `My Init App`"); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockAutolink).not.toHaveBeenCalled(); + }); + + test("agent mode: a failed claim warns, keeps the token, and ends in today's usage error", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockRejectedValue(new PlapiError(500, "Internal Server Error")); + + await expect(runLink({ cwd: folder, claimKeylessApp: true })).rejects.toThrow( + "Cannot select an application in agent mode. Pass --app ", + ); + + expect(captured.err).toContain( + "Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.", + ); + expect(mockAutolink).toHaveBeenCalledWith(folder); + expect(mockLinkApp).not.toHaveBeenCalled(); + expect(await breadcrumbExists()).toBe(true); + }); + + test("human mode: a spent token warns and falls through to the picker", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockRejectedValue(new PlapiError(404, "Not Found")); + mockListApplications.mockResolvedValue([mockApp]); + mockSearch.mockResolvedValue("app_123"); + + await runLink({ cwd: folder, claimKeylessApp: true }); + + expect(captured.err).toContain( + "Claim token is no longer valid - the application may have been claimed from the dashboard.", + ); + expect(mockSearch).toHaveBeenCalled(); + expect(mockSetProfile).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ appId: "app_123" }), + ); + expect(mockLinkApp).not.toHaveBeenCalled(); + }); + + test.each(["agent", "human"])( + "%s mode: a key for another app stops with key_pair_mismatch and changes nothing", + async (mode) => { + mockIsAgent.mockReturnValue(mode === "agent"); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockResolvedValue(claimedApp); + mockFindClerkKeys.mockResolvedValue([{ key: "pk_test_other", source: ".env" }]); + const envPath = join(folder, ".env"); + const before = "CLERK_PUBLISHABLE_KEY=pk_test_other\nCLERK_SECRET_KEY=sk_test_other\n"; + await Bun.write(envPath, before); + + const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).code).toBe(ERROR_CODE.KEY_PAIR_MISMATCH); + expect((error as CliError).exitCode).toBe(EXIT_CODE.GENERAL); + expect((error as CliError).message).toBe( + "Claimed application `My Init App` (app_claimed) into your account, but .env holds a publishable key for a different application, " + + "so this folder wasn't linked and its keys weren't changed. " + + "Run `clerk link --app ` with the application this folder should use.", + ); + expect(mockLinkApp).not.toHaveBeenCalled(); + expect(mockPull).not.toHaveBeenCalled(); + expect(mockSetProfile).not.toHaveBeenCalled(); + expect(await Bun.file(envPath).text()).toBe(before); + expect(await breadcrumbExists()).toBe(false); + }, + ); + + test("--app skips the claim and links the named app", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockFetchApplication.mockResolvedValue(mockApp); + + await runLink({ cwd: folder, app: "app_123", claimKeylessApp: true }); + + expect(mockClaimApplication).not.toHaveBeenCalled(); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(await breadcrumbExists()).toBe(true); + }); + + test("an existing link skips the claim", async () => { + mockIsAgent.mockReturnValue(true); + mockResolveProfile.mockResolvedValue({ + path: "/repo/.git", + profile: { workspaceId: "", appId: "app_existing", instances: { development: "ins_1" } }, + }); + + await runLink({ cwd: folder, claimKeylessApp: true }); + + expect(mockClaimApplication).not.toHaveBeenCalled(); + expect(captured.err).toContain("Already linked"); + expect(await breadcrumbExists()).toBe(true); + }); + + test("clerk init's call (skipIfLinked, createIfMissing) never claims", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockCreateApplication.mockResolvedValue(mockApp); + + await runLink({ cwd: folder, skipIfLinked: true, createIfMissing: "my-app" }); + + expect(mockClaimApplication).not.toHaveBeenCalled(); + expect(mockCreateApplication).toHaveBeenCalledWith("my-app"); + expect(await breadcrumbExists()).toBe(true); + }); + + test("clerk doctor's and clerk migrate's calls never claim", async () => { + mockIsAgent.mockReturnValue(true); + + await expect(runLink({ cwd: folder })).rejects.toThrow( + "Cannot select an application in agent mode", + ); + await runLink({ cwd: folder, skipIfLinked: true }).catch(() => {}); + + expect(mockClaimApplication).not.toHaveBeenCalled(); + expect(await breadcrumbExists()).toBe(true); + }); + + test("the clerk link command itself claims", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockResolvedValue(claimedApp); + const cwdSpy = spyOn(process, "cwd").mockReturnValue(folder); + const program = new Command(); + registerLink(program as never); + + try { + await program.parseAsync(["link"], { from: "user" }); + } finally { + cwdSpy.mockRestore(); + } + + expect(mockClaimApplication).toHaveBeenCalledWith("tok_init", expect.any(String)); + expect(mockLinkApp).toHaveBeenCalledWith(claimedApp, folder); + }); + }); + describe("create app from picker", () => { test("selecting create option prompts for name and creates app", async () => { mockIsAgent.mockReturnValue(false); diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 4a5127f4a..4311b386c 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -14,6 +14,8 @@ import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { CliError, ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; import { intro, outro } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; +import { readKeylessBreadcrumb } from "../../lib/keyless.ts"; +import { attemptAutoclaim, claimWarning, keysMismatchMessage } from "../../lib/autoclaim.ts"; interface LinkOptions { app?: string; @@ -28,6 +30,12 @@ interface LinkOptions { createIfMissing?: string; /** Callers that continue setup themselves (native `clerk init`) skip link's own next steps. */ showNextSteps?: boolean; + /** + * Claim the application a signed-out `clerk init` left a claim token for, before listing + * apps. Set only by the `clerk link` command: `clerk init`, `clerk migrate` and the + * `clerk doctor` fix also call `link()`, and their flows must not claim. + */ + claimKeylessApp?: boolean; } /** @@ -69,6 +77,13 @@ export async function link(options: LinkOptions = {}): Promise { return; } + let introShown = false; + if (options.claimKeylessApp && !existing && !options.app) { + const outcome = await claimKeylessApp(cwd, options); + if (outcome === "claimed") return; + introShown = outcome === "not_claimed"; + } + if (!existing && !options.app && (options.skipIfLinked || agent)) { const autolinked = await autolink(cwd); if (autolinked) return; @@ -80,7 +95,7 @@ export async function link(options: LinkOptions = {}): Promise { ); } - intro("Linking project"); + if (!introShown) intro("Linking project"); if (existing && agent) { printExistingStatus(existing, normalizedRemote); @@ -132,7 +147,41 @@ export async function link(options: LinkOptions = {}): Promise { await outro(options.showNextSteps === false ? undefined : NEXT_STEPS.LINK); } -async function ensureAuth() { +/** + * `no_breadcrumb` when there is nothing to claim, `not_claimed` (after printing why) when the + * claim didn't link the folder and `link()` should carry on. Throws when the folder's keys + * belong to another application. + */ +async function claimKeylessApp( + cwd: string, + options: LinkOptions, +): Promise<"claimed" | "not_claimed" | "no_breadcrumb"> { + const breadcrumb = await readKeylessBreadcrumb(cwd); + if (!breadcrumb?.claimToken) return "no_breadcrumb"; + + intro("Linking project"); + // `login()` would claim on its own and swallow a key mismatch as a warning. + await ensureAuth({ claim: false }); + const result = await attemptAutoclaim(cwd); + + if (result.status === "keys_mismatch") { + throw new CliError(keysMismatchMessage(result), { code: ERROR_CODE.KEY_PAIR_MISMATCH }); + } + + if (result.status === "claimed") { + const label = result.app.name || result.app.application_id; + log.success(`Claimed and linked application: \`${label}\``); + const steps = result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV; + await outro(options.showNextSteps === false ? undefined : steps); + return "claimed"; + } + + const warning = claimWarning(result); + if (warning) log.warn(warning); + return "not_claimed"; +} + +async function ensureAuth({ claim }: { claim?: boolean } = {}) { // CLERK_PLATFORM_API_KEY is a valid non-interactive auth mechanism. // The PLAPI fetch helpers use it directly for API calls, so no OAuth // token is needed when this key is present. @@ -140,7 +189,7 @@ async function ensureAuth() { const token = await getToken(); if (!token) { log.info("Not logged in. Authenticating first..."); - await login({ showNextSteps: false }); + await login({ showNextSteps: false, claim }); } } @@ -231,5 +280,5 @@ export function registerLink(program: Program): void { { command: "clerk link", description: "Pick an app interactively" }, { command: "clerk link --app app_abc123", description: "Link directly by application ID" }, ]) - .action(link); + .action(async (options: LinkOptions) => link({ ...options, claimKeylessApp: true })); } From b46c60677cbb26acd0e24759603f1e3096e8e35b Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Mon, 2 Nov 2026 15:16:02 -0600 Subject: [PATCH 04/15] docs(changeset): add link claim changeset - Adds a minor changeset for `clerk link` claiming the `clerk init` application, the per-folder `not_linked` messages in `clerk deploy` and `clerk deploy status`, and the publishable-key check after a claim. --- .changeset/grow-1285-link-claim-and-advice.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/grow-1285-link-claim-and-advice.md diff --git a/.changeset/grow-1285-link-claim-and-advice.md b/.changeset/grow-1285-link-claim-and-advice.md new file mode 100644 index 000000000..1e15d0237 --- /dev/null +++ b/.changeset/grow-1285-link-claim-and-advice.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Make `clerk link` claim the application a signed-out `clerk init` created and link the folder to it, and make `clerk deploy` and `clerk deploy status` say how to link a folder that runs on an unclaimed application. If the folder's publishable key belongs to a different application, `clerk link` and `clerk auth login` claim the application but leave the folder unlinked and its env files unchanged, and say which file holds the other key. From 13a08896d294c299d8db423f4a4bc0567090ea49 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Mon, 2 Nov 2026 15:31:31 -0600 Subject: [PATCH 05/15] Check SDK claim links before advising open - The `not_linked` message for a folder a Clerk SDK set up now appears only when `.clerk/.tmp/keyless.json` holds a well-formed https claim link. A file with no link, a non-https link or malformed JSON gets the plain message, because `clerk open` would fail there with "no accountless application was found". - The claim-link reader moves from `commands/open/keyless-claim.ts` into `lib/keyless.ts`, so `clerk open` and the message use the same check. `clerk open` behaves as before; only its debug line's prefix changes. - The message now says what the CLI found, hedges on whether the app is still unowned, and gives agents runnable steps (`clerk auth login` if needed, `clerk apps list --json`, `clerk link --app `). --- .../cli-core/src/commands/deploy/README.md | 10 ++--- .../src/commands/deploy/index.test.ts | 12 ++++-- .../commands/deploy/status-command.test.ts | 12 ++++-- .../src/commands/open/keyless-claim.ts | 38 +------------------ packages/cli-core/src/lib/keyless.ts | 36 ++++++++++++++++++ .../cli-core/src/lib/unlinked-advice.test.ts | 36 ++++++++++++++---- packages/cli-core/src/lib/unlinked-advice.ts | 19 +++++----- 7 files changed, 97 insertions(+), 66 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index d4cdd24af..e6ed642f7 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -67,11 +67,11 @@ Agent-mode `clerk deploy` without `--domain` is informational and exits 0 for li `clerk deploy` and `clerk deploy status` stop with `not_linked` in a folder with no link. The message says which kind of application the folder runs on, because only one kind is listed by `clerk link`: -| Folder holds | Message tells you to | -| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | -| `.clerk/keyless.json` with a claim token, written by a signed-out `clerk init` | Run `clerk link`, which claims the application into your account and links the folder, then rerun the command. | -| `.clerk/.tmp/keyless.json`, written by a Clerk SDK in keyless mode | Have a person run `clerk open` to claim the application in the Dashboard, then run `clerk link`, choose it, and rerun the command. | -| Neither file | Run `clerk link`, then rerun the command. | +| Folder holds | Message tells you to | +| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `.clerk/keyless.json` with a claim token, written by a signed-out `clerk init` | Run `clerk link`, which claims the application into your account and links the folder, then rerun the command. | +| `.clerk/.tmp/keyless.json` with an https claim link, written by a Clerk SDK in keyless mode | If no account owns the application yet, have a person run `clerk open` to claim it in the Dashboard. Then run `clerk link` and choose it (agents: `clerk auth login` if needed, then `clerk apps list --json` and `clerk link --app `), and rerun the command. | +| Neither, or an SDK file without a usable claim link (`clerk open` would have nothing to open) | Run `clerk link`, then rerun the command. | The error code is `not_linked` in all three cases. Only the message text differs. diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 527745688..7a829d1a4 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -549,9 +549,10 @@ describe("deploy", () => { "This folder runs on an application `clerk init` created that no account owns yet. " + "Run `clerk link` to claim it into your account and link this folder, then rerun `clerk deploy`."; const SDK_APP = - "This folder runs on an application a Clerk SDK created that no account owns yet. " + - "`clerk link` can't list it until it's claimed: a person must run `clerk open` to claim it in the Dashboard. " + - "Then run `clerk link`, choose it, and rerun `clerk deploy`."; + "This folder holds a claim link a Clerk SDK wrote to `.clerk/.tmp/keyless.json`. " + + "If no account owns that application yet, a person must run `clerk open` to claim it in the Dashboard. " + + "Then run `clerk link` and choose it (agents: `clerk auth login` if needed, then `clerk apps list --json` and `clerk link --app `), " + + "then rerun `clerk deploy`."; const PLAIN = "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy`."; @@ -575,7 +576,10 @@ describe("deploy", () => { } if (state === "sdk_app") { await mkdir(join(tempDir, ".clerk", ".tmp"), { recursive: true }); - await Bun.write(join(tempDir, ".clerk", ".tmp", "keyless.json"), "{}"); + await Bun.write( + join(tempDir, ".clerk", ".tmp", "keyless.json"), + JSON.stringify({ claimUrl: "https://dashboard.clerk.com/apps/claim?token=tok_sdk" }), + ); } } diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts index 3ba4c74d6..07a61288e 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -806,9 +806,10 @@ describe("deploy status", () => { "This folder runs on an application `clerk init` created that no account owns yet. " + "Run `clerk link` to claim it into your account and link this folder, then rerun `clerk deploy status`."; const SDK_APP = - "This folder runs on an application a Clerk SDK created that no account owns yet. " + - "`clerk link` can't list it until it's claimed: a person must run `clerk open` to claim it in the Dashboard. " + - "Then run `clerk link`, choose it, and rerun `clerk deploy status`."; + "This folder holds a claim link a Clerk SDK wrote to `.clerk/.tmp/keyless.json`. " + + "If no account owns that application yet, a person must run `clerk open` to claim it in the Dashboard. " + + "Then run `clerk link` and choose it (agents: `clerk auth login` if needed, then `clerk apps list --json` and `clerk link --app `), " + + "then rerun `clerk deploy status`."; const PLAIN = "No Clerk project linked to this directory. Run `clerk link`, then rerun `clerk deploy status`."; @@ -835,7 +836,10 @@ describe("deploy status", () => { } if (state === "sdk_app") { await mkdir(join(folder, ".clerk", ".tmp"), { recursive: true }); - await Bun.write(join(folder, ".clerk", ".tmp", "keyless.json"), "{}"); + await Bun.write( + join(folder, ".clerk", ".tmp", "keyless.json"), + JSON.stringify({ claimUrl: "https://dashboard.clerk.com/apps/claim?token=tok_sdk" }), + ); } } diff --git a/packages/cli-core/src/commands/open/keyless-claim.ts b/packages/cli-core/src/commands/open/keyless-claim.ts index 8c76ce383..4f02adad6 100644 --- a/packages/cli-core/src/commands/open/keyless-claim.ts +++ b/packages/cli-core/src/commands/open/keyless-claim.ts @@ -9,7 +9,7 @@ */ import { join } from "node:path"; -import { readKeylessBreadcrumb, SDK_KEYLESS_FILE } from "../../lib/keyless.ts"; +import { readKeylessBreadcrumb, readSdkClaimUrl, SDK_KEYLESS_FILE } from "../../lib/keyless.ts"; import { getDashboardUrl } from "../../lib/environment.ts"; import { bapiRequest } from "../../lib/bapi.ts"; import { log } from "../../lib/log.ts"; @@ -26,42 +26,6 @@ export interface KeylessInstanceInfo { environmentType: string | null; } -/** - * The SDKs write their own full `claimUrl` (already pointed at the right - * dashboard host) into this file. Reading it directly here — rather than - * through `lib/keyless-target.ts` — because that module only surfaces the - * key pair it needs for BAPI calls, not the claim URL. - */ -async function readSdkClaimUrl(cwd: string): Promise { - const file = Bun.file(join(cwd, ...SDK_KEYLESS_FILE)); - if (!(await file.exists())) return undefined; - - try { - const parsed = (await file.json()) as { claimUrl?: unknown }; - const claimUrl = typeof parsed.claimUrl === "string" ? parsed.claimUrl : undefined; - if (!claimUrl) return undefined; - - // Written by another process, so untrusted input: anything but well-formed - // https is dropped rather than handed to the browser launcher — which on - // Windows goes through `cmd /c start "" ""`, where a `file:`/ - // `javascript:` scheme or an embedded quote stops being just a URL. - let isHttps = false; - try { - isHttps = new URL(claimUrl).protocol === "https:"; - } catch { - // fall through: not a URL at all - } - if (!isHttps) { - log.debug(`open: ignoring non-https claimUrl in ${SDK_KEYLESS_FILE.join("/")}`); - return undefined; - } - return claimUrl; - } catch { - // A half-written file during an SDK refresh isn't worth failing over. - return undefined; - } -} - /** * Finds the claim link for an unclaimed keyless application, checking both * places one can turn up depending on how keyless mode was entered: diff --git a/packages/cli-core/src/lib/keyless.ts b/packages/cli-core/src/lib/keyless.ts index d3e79d4cc..e95a6bf29 100644 --- a/packages/cli-core/src/lib/keyless.ts +++ b/packages/cli-core/src/lib/keyless.ts @@ -113,6 +113,42 @@ export function parseClaimToken(claimUrl: string): string { return token; } +/** + * The claim URL a Clerk SDK in keyless mode wrote to `.clerk/.tmp/keyless.json`, already + * pointed at the right dashboard host, when it is well-formed https. `clerk open` opens it, + * and the `not_linked` advice recommends `clerk open` only when it exists. + * `lib/keyless-target.ts` reads the same file, but only for its key pair. + */ +export async function readSdkClaimUrl(cwd: string): Promise { + const file = Bun.file(join(cwd, ...SDK_KEYLESS_FILE)); + if (!(await file.exists())) return undefined; + + try { + const parsed = (await file.json()) as { claimUrl?: unknown }; + const claimUrl = typeof parsed.claimUrl === "string" ? parsed.claimUrl : undefined; + if (!claimUrl) return undefined; + + // Written by another process, so untrusted input: anything but well-formed + // https is dropped rather than handed to the browser launcher, which on + // Windows goes through `cmd /c start "" ""`, where a `file:`/ + // `javascript:` scheme or an embedded quote stops being just a URL. + let isHttps = false; + try { + isHttps = new URL(claimUrl).protocol === "https:"; + } catch { + // fall through: not a URL at all + } + if (!isHttps) { + log.debug(`keyless: ignoring non-https claimUrl in ${SDK_KEYLESS_FILE.join("/")}`); + return undefined; + } + return claimUrl; + } catch { + // A half-written file during an SDK refresh isn't worth failing over. + return undefined; + } +} + function breadcrumbPath(cwd: string): string { return join(cwd, BREADCRUMB_DIR, BREADCRUMB_FILE); } diff --git a/packages/cli-core/src/lib/unlinked-advice.test.ts b/packages/cli-core/src/lib/unlinked-advice.test.ts index a909bdded..7e5624a1c 100644 --- a/packages/cli-core/src/lib/unlinked-advice.test.ts +++ b/packages/cli-core/src/lib/unlinked-advice.test.ts @@ -23,11 +23,18 @@ async function writeInitBreadcrumb(dir: string): Promise { ); } -async function writeSdkKeylessFile(dir: string): Promise { +const SDK_FILE = [".clerk", ".tmp", "keyless.json"] as const; + +async function writeSdkKeylessFile(dir: string, content?: string): Promise { await mkdir(join(dir, ".clerk", ".tmp"), { recursive: true }); await Bun.write( - join(dir, ".clerk", ".tmp", "keyless.json"), - JSON.stringify({ claimUrl: "https://dashboard.clerk.com/apps/claim?token=tok_sdk" }), + join(dir, ...SDK_FILE), + content ?? + JSON.stringify({ + claimUrl: "https://dashboard.clerk.com/apps/claim?token=tok_sdk", + publishableKey: "pk_test_sdk", + secretKey: "sk_test_sdk", + }), ); } @@ -43,11 +50,25 @@ describe("classifyUnlinkedFolder", () => { expect(await classifyUnlinkedFolder(folder)).toBe("init_app"); }); - test("an SDK keyless file without a breadcrumb is sdk_app", async () => { + test("an SDK keyless file with an https claim link and no breadcrumb is sdk_app", async () => { await writeSdkKeylessFile(folder); expect(await classifyUnlinkedFolder(folder)).toBe("sdk_app"); }); + test.each([ + ["no claim link", "{}"], + ["a non-https claim link", JSON.stringify({ claimUrl: "javascript:alert(1)" })], + ["malformed JSON", '{"claimUrl": "https://dash'], + ])( + "an SDK keyless file with %s is plain, because clerk open has nothing to open", + async (_label, content) => { + await writeSdkKeylessFile(folder, content); + + expect(await classifyUnlinkedFolder(folder)).toBe("plain"); + expect(await Bun.file(join(folder, ...SDK_FILE)).exists()).toBe(true); + }, + ); + test("the breadcrumb wins when both files exist, because clerk link can claim it", async () => { await writeInitBreadcrumb(folder); await writeSdkKeylessFile(folder); @@ -82,9 +103,10 @@ describe("notLinkedMessage", () => { test("sdk_app says a person claims it with clerk open first", async () => { await writeSdkKeylessFile(folder); expect(await notLinkedMessage(folder, "clerk deploy")).toBe( - "This folder runs on an application a Clerk SDK created that no account owns yet. " + - "`clerk link` can't list it until it's claimed: a person must run `clerk open` to claim it in the Dashboard. " + - "Then run `clerk link`, choose it, and rerun `clerk deploy`.", + "This folder holds a claim link a Clerk SDK wrote to `.clerk/.tmp/keyless.json`. " + + "If no account owns that application yet, a person must run `clerk open` to claim it in the Dashboard. " + + "Then run `clerk link` and choose it (agents: `clerk auth login` if needed, then `clerk apps list --json` and `clerk link --app `), " + + "then rerun `clerk deploy`.", ); }); }); diff --git a/packages/cli-core/src/lib/unlinked-advice.ts b/packages/cli-core/src/lib/unlinked-advice.ts index 700e785ab..e7082b9a6 100644 --- a/packages/cli-core/src/lib/unlinked-advice.ts +++ b/packages/cli-core/src/lib/unlinked-advice.ts @@ -1,14 +1,14 @@ -import { join } from "node:path"; -import { peekKeylessBreadcrumb, SDK_KEYLESS_FILE } from "./keyless.ts"; +import { peekKeylessBreadcrumb, readSdkClaimUrl } from "./keyless.ts"; /** * Who created the application an unlinked folder runs on, when no account owns it yet: * * - `init_app`: a signed-out `clerk init` left its claim token in `.clerk/keyless.json`. * `clerk link` can claim it. - * - `sdk_app`: a Clerk SDK in keyless mode wrote `.clerk/.tmp/keyless.json`. Only the - * Dashboard claim page (`clerk open`) can claim it. - * - `plain`: neither file, so the folder is just unlinked. + * - `sdk_app`: a Clerk SDK in keyless mode wrote a usable claim link to + * `.clerk/.tmp/keyless.json`. Only the Dashboard claim page (`clerk open`) can claim it. + * - `plain`: neither, so the folder is just unlinked. An SDK file without a usable link + * counts as `plain`, because `clerk open` would have nothing to open. */ export type UnlinkedFolder = "init_app" | "sdk_app" | "plain"; @@ -17,7 +17,7 @@ export async function classifyUnlinkedFolder(cwd: string): Promise`), " + + `then rerun \`${rerun}\`.` ); case "plain": return `No Clerk project linked to this directory. Run \`clerk link\`, then rerun \`${rerun}\`.`; From 876495de341fc46f9ad8add783e669d27d8fae58 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Mon, 2 Nov 2026 15:32:22 -0600 Subject: [PATCH 06/15] Handle each way a link claim can end - A claim that succeeds but doesn't link the folder (a failed config write, or an app with no development instance) no longer prints "Claimed and linked". `clerk link` prints "Claimed application" and links the claimed app directly, as `--app ` would; `clerk auth login` says the folder wasn't linked and to run `clerk link`. - Both commands print the claim result, including the Dashboard URL, from one `reportClaimed` in `lib/autoclaim.ts`. - The publishable-key check runs only for the `clerk auth login` command, its `clerk login` alias and `clerk link`'s own claim. `clerk init`, `clerk migrate` and the `clerk doctor` fix sign in through `login()` without it and behave as before. `attemptAutoclaim` now requires callers to choose. - An env file the check can't read no longer counts as "no keys": the folder is linked but no keys are written, and a warning names the file and says to run `clerk env pull` once it's fixed. - A temporary claim failure or a managed workspace now stops `clerk link` with the original error and says to rerun `clerk link` or use `--app`, instead of offering a list that can't contain the claimed app. - Tests clear and restore the shell variables they depend on and stub the credential store in the autoclaim tests. --- .changeset/grow-1285-link-claim-and-advice.md | 2 +- packages/cli-core/src/commands/auth/README.md | 12 +- .../cli-core/src/commands/auth/index.test.ts | 32 +++ packages/cli-core/src/commands/auth/index.ts | 4 +- .../cli-core/src/commands/auth/login.test.ts | 67 +++++- packages/cli-core/src/commands/auth/login.ts | 40 ++-- packages/cli-core/src/commands/link/README.md | 24 ++- .../cli-core/src/commands/link/index.test.ts | 192 +++++++++++++++++- packages/cli-core/src/commands/link/index.ts | 128 ++++++++---- packages/cli-core/src/lib/autoclaim.test.ts | 176 ++++++++++++++-- packages/cli-core/src/lib/autoclaim.ts | 122 ++++++++--- 11 files changed, 668 insertions(+), 131 deletions(-) create mode 100644 packages/cli-core/src/commands/auth/index.test.ts diff --git a/.changeset/grow-1285-link-claim-and-advice.md b/.changeset/grow-1285-link-claim-and-advice.md index 1e15d0237..0e7abcf8f 100644 --- a/.changeset/grow-1285-link-claim-and-advice.md +++ b/.changeset/grow-1285-link-claim-and-advice.md @@ -2,4 +2,4 @@ "clerk": minor --- -Make `clerk link` claim the application a signed-out `clerk init` created and link the folder to it, and make `clerk deploy` and `clerk deploy status` say how to link a folder that runs on an unclaimed application. If the folder's publishable key belongs to a different application, `clerk link` and `clerk auth login` claim the application but leave the folder unlinked and its env files unchanged, and say which file holds the other key. +Make `clerk link` claim the application a signed-out `clerk init` created and link the folder to it, and make `clerk deploy` and `clerk deploy status` say how to link a folder that runs on an unclaimed application. If the claim fails temporarily or the workspace is managed by an integration, `clerk link` now stops with the error and says to rerun it, instead of offering apps that can't include the claimed one. If the folder's publishable key belongs to a different application, `clerk link` and `clerk auth login` claim the application but leave the folder unlinked and its env files unchanged, and say which file holds the other key. diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index c6fa73d40..9c65fd225 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -24,11 +24,13 @@ Before signing in, the command prints a banner: the Clerk logo beside a link to When `clerk init` runs in accountless mode it writes `.clerk/keyless.json` containing a claim token. On the next `clerk auth login` (or `clerk link`, which runs the same claim): -- **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. -- **Publishable key for another application** — the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), set in the environment, `.env`, or `.env.local`. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. -- **Success** — application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. The command prints the Dashboard URL of the claimed app's development instance, since this is the first time the app has a home in an account. +- **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** (a Platform API error or a network failure): treated as transient; breadcrumb is preserved so the next login retries. `clerk link` stops with the original error here, and with a managed workspace, because its app list can't show the application the token would claim. +- **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), set in the environment, `.env`, or `.env.local`. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link` check keys; `clerk init`, `clerk migrate`, and the `clerk doctor` fix sign in through `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. +- **Unreadable env file**: the claim succeeds and the folder is linked, but no keys are written, because the unread file could hold another application's key. A warning names the file and the reason and says to run `clerk env pull` once the file is fixed. +- **Claimed but not linked**: saving the link failed, or the application has no development instance. The command prints "Claimed application: ..." and a warning with the reason, and says to run `clerk link` to finish setup. +- **Success**: application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. The command prints the Dashboard URL of the claimed app's development instance, since this is the first time the app has a home in an account. #### API Endpoints diff --git a/packages/cli-core/src/commands/auth/index.test.ts b/packages/cli-core/src/commands/auth/index.test.ts new file mode 100644 index 000000000..3d88bef3a --- /dev/null +++ b/packages/cli-core/src/commands/auth/index.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { Command } from "@commander-js/extra-typings"; +import "../../lib/help.ts"; + +const mockLogin = mock(); +mock.module("./login.ts", () => ({ + login: (...args: unknown[]) => mockLogin(...args), +})); +mock.module("./logout.ts", () => ({ logout: async () => {} })); +mock.module("../../lib/banner.ts", () => ({ printBanner: () => {} })); + +const { registerAuth } = await import("./index.ts"); + +describe("registerAuth", () => { + afterEach(() => { + mockLogin.mockReset(); + }); + + // Only these commands check the folder's keys after a claim; `clerk init`, `clerk migrate` + // and `clerk doctor` call `login()` without the option. + test.each([[["auth", "login"]], [["login"]]])( + "`clerk %p` signs in with the claim key check on", + async (args) => { + const program = new Command(); + registerAuth(program as never); + + await program.parseAsync(args, { from: "user" }); + + expect(mockLogin).toHaveBeenCalledWith(expect.objectContaining({ checkClaimKeys: true })); + }, + ); +}); diff --git a/packages/cli-core/src/commands/auth/index.ts b/packages/cli-core/src/commands/auth/index.ts index 0b4922c3d..d8ca0e67d 100644 --- a/packages/cli-core/src/commands/auth/index.ts +++ b/packages/cli-core/src/commands/auth/index.ts @@ -26,7 +26,7 @@ export function registerAuth(program: Program): void { ]) .action(async (opts) => { printBanner(); - await login(opts); + await login({ ...opts, checkClaimKeys: true }); }); auth @@ -42,7 +42,7 @@ export function registerAuth(program: Program): void { .option("-y, --yes", "Proceed with OAuth without prompting when already logged in") .action(async (opts) => { printBanner(); - await login(opts); + await login({ ...opts, checkClaimKeys: true }); }); program diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index bff62a0ac..36909d3a8 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, afterEach, beforeEach, mock, spyOn } from "bun:test"; -import { AuthError, ERROR_CODE, UserAbortError } from "../../lib/errors.ts"; +import { AuthError, ERROR_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; import { useCaptureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts"; import type { AutoclaimResult } from "../../lib/autoclaim.ts"; import type { Application } from "../../lib/plapi.ts"; @@ -97,15 +97,18 @@ mock.module("../../lib/first-application.ts", () => ({ ensureFirstApplication: () => mockEnsureFirstApplication(), })); -const mockAttemptAutoclaim = mock(async (_cwd: string): Promise => ({ - status: "not_keyless", -})); +const mockAttemptAutoclaim = mock( + async (_cwd: string, _options: { checkKeys: boolean }): Promise => ({ + status: "not_keyless", + }), +); // The real warning texts, so these tests pin what `clerk auth login` prints. const actualAutoclaim = await import("../../lib/autoclaim.ts"); mock.module("../../lib/autoclaim.ts", () => ({ ...actualAutoclaim, - attemptAutoclaim: (cwd: string) => mockAttemptAutoclaim(cwd), + attemptAutoclaim: (cwd: string, options: { checkKeys: boolean }) => + mockAttemptAutoclaim(cwd, options), })); const { setLogLevel } = await import("../../lib/log.ts"); @@ -673,6 +676,7 @@ describe("login", () => { mockResolveProfile.mockResolvedValue(undefined); mockAttemptAutoclaim.mockResolvedValue({ status: "claimed", + linked: true, envPulled: true, app: { application_id: "app_claimed", @@ -731,6 +735,50 @@ describe("login", () => { expect(process.exitCode).toBe(exitCodeBefore); }); + test("clerk init's sign-in call claims without the key check", async () => { + // `clerk init` calls exactly this (pinned in commands/init/index.test.ts and strategy.test.ts). + mockGetValidToken.mockResolvedValue("existing-token"); + mockGetAuth.mockResolvedValue({ userId: "user_123" }); + mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "existing@example.com" }); + + await runLogin({ showNextSteps: false }); + + expect(mockAttemptAutoclaim).toHaveBeenCalledWith(process.cwd(), { checkKeys: false }); + }); + + test("checkClaimKeys turns the key check on, as the clerk auth login command does", async () => { + mockGetValidToken.mockResolvedValue("existing-token"); + mockGetAuth.mockResolvedValue({ userId: "user_123" }); + mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "existing@example.com" }); + + await runLogin({ checkClaimKeys: true }); + + expect(mockAttemptAutoclaim).toHaveBeenCalledWith(process.cwd(), { checkKeys: true }); + }); + + test("a claim that didn't link the folder says so and points at clerk link", async () => { + mockIsHuman.mockReturnValue(true); + mockGetValidToken.mockResolvedValue(null); + mockOAuthSuccess(); + mockResolveProfile.mockResolvedValue(undefined); + mockAttemptAutoclaim.mockResolvedValue({ + status: "claimed", + linked: false, + linkError: "the application has no development instance", + envPulled: false, + app: { application_id: "app_claimed", name: "bad-agent", instances: [] }, + }); + + await runLogin(); + + expect(captured.err).toContain("Claimed application: `bad-agent`"); + expect(captured.err).not.toContain("Claimed and linked application"); + expect(captured.err).toContain( + "This folder wasn't linked: the application has no development instance. Run `clerk link` to finish setup.", + ); + expect(captured.err).toContain("Run `clerk link` to connect your Clerk application"); + }); + test("claim: false signs in without claiming, for clerk link to claim itself", async () => { mockGetValidToken.mockResolvedValue(null); mockOAuthSuccess(); @@ -757,6 +805,7 @@ describe("login", () => { mockResolveProfile.mockResolvedValue(undefined); mockAttemptAutoclaim.mockResolvedValue({ status: "claimed", + linked: true, envPulled: true, app: { application_id: "app_claimed", @@ -792,6 +841,7 @@ describe("login", () => { mockResolveProfile.mockResolvedValue(undefined); mockAttemptAutoclaim.mockResolvedValue({ status: "claimed", + linked: true, envPulled: true, // Deliberately shaped like API JSON that dropped the array. app: { application_id: "app_claimed", name: "bad-agent" } as Application, @@ -876,6 +926,7 @@ describe("login", () => { mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "existing@example.com" }); mockAttemptAutoclaim.mockResolvedValue({ status: "managed_workspace", + error: new PlapiError(403, "{}"), longMessage: "The target application cannot be claimed into the current workspace. Select a different workspace and try again.", }); @@ -893,7 +944,11 @@ describe("login", () => { mockGetValidToken.mockResolvedValue("existing-token"); mockGetAuth.mockResolvedValue({ userId: "user_123" }); mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "existing@example.com" }); - mockAttemptAutoclaim.mockResolvedValue({ status: "managed_workspace", longMessage: null }); + mockAttemptAutoclaim.mockResolvedValue({ + status: "managed_workspace", + longMessage: null, + error: new PlapiError(403, "{}"), + }); await runLogin(); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 0d0fba1d1..e51816b70 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -6,7 +6,7 @@ import { revokeToken, type UserInfo, } from "../../lib/token-exchange.ts"; -import { buildDashboardUrl, getOAuthConfig } from "../../lib/environment.ts"; +import { getOAuthConfig } from "../../lib/environment.ts"; import { createOAuthSession, getStoredSession, @@ -21,7 +21,12 @@ import { isHuman } from "../../mode.ts"; import { ERROR_CODE, errorMessage, throwUserAbort } from "../../lib/errors.ts"; import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; -import { attemptAutoclaim, claimWarning, type AutoclaimResult } from "../../lib/autoclaim.ts"; +import { + attemptAutoclaim, + claimWarning, + reportClaimed, + 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"; @@ -36,6 +41,12 @@ interface LoginOptions { * because it claims right after signing in and must see the claim's result itself. */ claim?: boolean; + /** + * Compare the folder's publishable keys with the claimed app's before linking it. Only the + * `clerk auth login` command sets this; `clerk init`, `clerk migrate` and `clerk doctor` sign in + * through `login()` and must keep linking what they claim. + */ + checkClaimKeys?: boolean; } async function getExistingSession(): Promise { @@ -209,7 +220,7 @@ export async function login(options: LoginOptions = {}): Promise { } async function runLogin(options: LoginOptions = {}): Promise { - const { showNextSteps = true, yes, claim = true } = options; + const { showNextSteps = true, yes, claim = true, checkClaimKeys = false } = options; intro("Signing in"); setTelemetryStage("session_check"); const existingSession = await withSpinner("Checking session...", async () => @@ -220,7 +231,7 @@ async function runLogin(options: LoginOptions = {}): Promise { setTelemetryStage("done"); log.success(`Logged in as ${existingSession.email}`); const claimResult: AutoclaimResult = claim - ? await handleAutoclaim(process.cwd()) + ? await handleAutoclaim(process.cwd(), checkClaimKeys) : { status: "not_keyless" }; if (showNextSteps) { await outro(await loginNextSteps(claimResult)); @@ -271,7 +282,7 @@ async function runLogin(options: LoginOptions = {}): Promise { log.success(`Logged in as ${userInfo.email}`); const claimResult: AutoclaimResult = claim - ? await handleAutoclaim(process.cwd()) + ? await handleAutoclaim(process.cwd(), checkClaimKeys) : { status: "not_keyless" }; if (showNextSteps) { @@ -283,23 +294,11 @@ async function runLogin(options: LoginOptions = {}): Promise { return userInfo; } -async function handleAutoclaim(cwd: string): Promise { - const result = await attemptAutoclaim(cwd); +async function handleAutoclaim(cwd: string, checkKeys: boolean): Promise { + const result = await attemptAutoclaim(cwd, { checkKeys }); if (result.status === "claimed") { - const label = result.app.name || result.app.application_id; - log.success(`Claimed and linked application: \`${label}\``); - // First time this app has a home in an account; say where it is. - // Deserialized API JSON; a missing array must not fail a claim that - // already succeeded server-side. - const development = result.app.instances?.find( - (instance) => instance.environment_type === "development", - ); - if (development) { - // URL on its own line: with it, the sentence is wider than the frame. - log.info("Your app now lives in your Clerk account:"); - log.info(` ${buildDashboardUrl(result.app.application_id, development.instance_id)}`); - } + reportClaimed(result, { unlinkedHint: "Run `clerk link` to finish setup." }); } const warning = claimWarning(result); @@ -309,6 +308,7 @@ async function handleAutoclaim(cwd: string): Promise { } async function loginNextSteps(result: AutoclaimResult): Promise { + if (result.status === "claimed" && !result.linked) return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK; if (result.status === "claimed") { return result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV; } diff --git a/packages/cli-core/src/commands/link/README.md b/packages/cli-core/src/commands/link/README.md index 8c2bb8655..31070b3ac 100644 --- a/packages/cli-core/src/commands/link/README.md +++ b/packages/cli-core/src/commands/link/README.md @@ -45,18 +45,24 @@ and no `--app` is given: 1. Signs in if needed, without letting sign-in claim the application itself 2. Claims the application into your account with the token, which deletes `.clerk/keyless.json` 3. Compares the publishable keys in the env var, `.env`, and `.env.local` with the claimed application's development key -4. If they all match (or there are none), links the folder, writes the application's keys to the env file, prints "Claimed and linked application: ...", and stops +4. If they all match (or there are none), links the folder, writes the application's keys to the env file, prints "Claimed and linked application: ..." and the application's Dashboard URL, and stops 5. If any key belongs to a different application, links nothing, leaves the env files unchanged, and exits 1 with `key_pair_mismatch`. The message names the claimed application and the file, and tells you to run `clerk link --app ` with the application the folder should use -6. If the claim fails (expired token, no organization, managed workspace, or a temporary error), prints the same warning `clerk auth login` prints and continues with the normal flow below - -Neither mode prompts during the claim. `--app` or an existing link skips it. -Only the `clerk link` command claims: `clerk init`, `clerk migrate`, and the +6. If an env file can't be read, links the folder but writes no keys, and warns with the file and the reason. Run `clerk env pull` once the file is fixed +7. If the claim succeeds but saving the link fails, prints "Claimed application: ..." and links the claimed application directly, as `clerk link --app ` would. An application with no development instance then fails with "Application has no development instance", and a config write that keeps failing reports its own error +8. If the claim fails temporarily (a Platform API error or a network failure), or the workspace is managed by an integration such as Vercel, keeps `.clerk/keyless.json` and exits with the original error. A warning says to rerun `clerk link` to retry (for a managed workspace, after switching to a workspace you own), or to run `clerk link --app ` to link a different application. A Platform API error keeps its own code, e.g. `accountless_application_managed_workspace` +9. If the token is spent (expired, or the account has no organization), prints the same warning `clerk auth login` prints and continues with the normal flow below + +Neither mode prompts during the claim. `--app` or an existing link skips this +step, but when you're signed out, signing in still claims the application +without the key check, as `clerk auth login` did before it had one. Only the +`clerk link` command runs this step: `clerk init`, `clerk migrate`, and the `clerk doctor` link fix call the same `link()` without the internal -`claimKeylessApp` option, so their flows never claim. +`claimKeylessApp` option. -A Clerk SDK in keyless mode writes `.clerk/.tmp/keyless.json` instead, which -`clerk link` can't claim. Claim that application in the Dashboard with -`clerk open`, then run `clerk link` and choose it. +A Clerk SDK in keyless mode writes a claim link to `.clerk/.tmp/keyless.json` +instead, which `clerk link` can't use. Claim that application in the Dashboard +with `clerk open`, then run `clerk link` and choose it, or, in agent mode, run +`clerk apps list --json` and `clerk link --app `. ## Flow diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index d0ec4ac8e..fe6b35e14 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -136,10 +136,25 @@ const mockApp = { ], }; +/** Shell variables `link()` and the claim read; a developer's own must not decide these tests. */ +const SHELL_VARS = ["CLERK_PLATFORM_API_KEY", "CLERK_PUBLISHABLE_KEY"]; +const originalShell = Object.fromEntries(SHELL_VARS.map((name) => [name, process.env[name]])); + describe("link", () => { let consoleSpy: ReturnType; const captured = useCaptureLog(); + beforeEach(() => { + for (const name of SHELL_VARS) delete process.env[name]; + }); + + afterEach(() => { + for (const name of SHELL_VARS) { + if (originalShell[name] === undefined) delete process.env[name]; + else process.env[name] = originalShell[name]; + } + }); + afterEach(() => { _modeOverride = undefined; mockIsAgent.mockReset(); @@ -1222,6 +1237,7 @@ describe("link", () => { expect(mockPull).toHaveBeenCalledWith({}); expect(captured.err).toContain("Claimed and linked application: `My Init App`"); expect(mockOutro).toHaveBeenLastCalledWith(NEXT_STEPS.AUTOCLAIMED); + expect(captured.err).toContain("Your app now lives in your Clerk account:"); expect(mockListApplications).not.toHaveBeenCalled(); expect(mockSearch).not.toHaveBeenCalled(); expect(await breadcrumbExists()).toBe(false); @@ -1242,23 +1258,144 @@ describe("link", () => { expect(mockAutolink).not.toHaveBeenCalled(); }); - test("agent mode: a failed claim warns, keeps the token, and ends in today's usage error", async () => { - mockIsAgent.mockReturnValue(true); + test.each(["agent", "human"])( + "%s mode: a temporary claim failure stops with the API error and keeps the token", + async (mode) => { + mockIsAgent.mockReturnValue(mode === "agent"); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockRejectedValue(new PlapiError(500, "Internal Server Error")); + + const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(PlapiError); + expect((error as PlapiError).status).toBe(500); + expect((error as PlapiError).context).toBe("Failed to claim application"); + expect(captured.err).toContain( + "Couldn't claim the application `clerk init` created because of a temporary error. " + + "Rerun `clerk link` to retry, or run `clerk link --app ` to link a different application.", + ); + expect(captured.err).not.toContain("clerk auth login"); + expect(mockAutolink).not.toHaveBeenCalled(); + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockLinkApp).not.toHaveBeenCalled(); + expect(await breadcrumbExists()).toBe(true); + }, + ); + + test("a network failure during the claim stops with that error as it is", async () => { + mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); - mockClaimApplication.mockRejectedValue(new PlapiError(500, "Internal Server Error")); + const networkError = new TypeError("fetch failed"); + mockClaimApplication.mockRejectedValue(networkError); - await expect(runLink({ cwd: folder, claimKeylessApp: true })).rejects.toThrow( - "Cannot select an application in agent mode. Pass --app ", + const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBe(networkError); + expect(captured.err).toContain("Rerun `clerk link` to retry"); + expect(mockSearch).not.toHaveBeenCalled(); + expect(await breadcrumbExists()).toBe(true); + }); + + test("a managed workspace stops with the API error and says to switch workspaces", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue("token"); + const body = JSON.stringify({ + errors: [ + { + code: "accountless_application_managed_workspace", + message: "Cannot claim into a managed workspace", + long_message: "This workspace is managed by Vercel.", + }, + ], + }); + mockClaimApplication.mockRejectedValue(new PlapiError(403, body)); + + const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( + (caught: unknown) => caught, ); + expect(error).toBeInstanceOf(PlapiError); + expect((error as PlapiError).code).toBe("accountless_application_managed_workspace"); expect(captured.err).toContain( - "Auto-claim failed due to a temporary error. It will be retried on your next `clerk auth login`.", + "Unable to claim - This workspace is managed by Vercel. Switch to a workspace you own and rerun `clerk link`, " + + "or run `clerk link --app ` to link a different application.", ); - expect(mockAutolink).toHaveBeenCalledWith(folder); - expect(mockLinkApp).not.toHaveBeenCalled(); + expect(mockSearch).not.toHaveBeenCalled(); expect(await breadcrumbExists()).toBe(true); }); + test("a claimed app with no development instance ends in the usual error, not a picker", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + const noDevApp = { + application_id: "app_claimed", + name: "My Init App", + instances: [{ instance_id: "ins_prod", environment_type: "production" }], + }; + mockClaimApplication.mockResolvedValue(noDevApp); + mockLinkApp.mockResolvedValue(undefined); + + const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( + (caught: unknown) => caught, + ); + + expect((error as CliError).code).toBe(ERROR_CODE.INSTANCE_NOT_FOUND); + expect((error as CliError).message).toBe("Application has no development instance"); + expect(captured.err).toContain("Claimed application: `My Init App`"); + expect(captured.err).not.toContain("Claimed and linked"); + expect(mockAutolink).not.toHaveBeenCalled(); + expect(mockSetProfile).not.toHaveBeenCalled(); + }); + + test("a failed link write is retried by linking the claimed app directly", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockResolvedValue(claimedApp); + mockLinkApp.mockRejectedValue(new Error("config busy")); + + await runLink({ cwd: folder, claimKeylessApp: true }); + + expect(mockSetProfile).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ appId: "app_claimed", instances: { development: "ins_dev" } }), + ); + expect(captured.err).toContain("Claimed application: `My Init App`"); + expect(captured.err).toContain("Linked to"); + expect(mockPull).not.toHaveBeenCalled(); + expect(mockAutolink).not.toHaveBeenCalled(); + }); + + test("a config write that keeps failing reports its own error", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockResolvedValue(claimedApp); + mockLinkApp.mockRejectedValue(new Error("EACCES: config.json")); + mockSetProfile.mockRejectedValue(new Error("EACCES: config.json")); + + await expect(runLink({ cwd: folder, claimKeylessApp: true })).rejects.toThrow( + "EACCES: config.json", + ); + expect(captured.err).not.toContain("Claimed and linked"); + }); + + test("with CLERK_PLATFORM_API_KEY set, claims once without signing in", async () => { + process.env.CLERK_PLATFORM_API_KEY = "ak_test"; + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue(null); + mockClaimApplication.mockResolvedValue(claimedApp); + + await runLink({ cwd: folder, claimKeylessApp: true }); + + expect(mockLogin).not.toHaveBeenCalled(); + expect(mockGetToken).not.toHaveBeenCalled(); + expect(mockClaimApplication).toHaveBeenCalledTimes(1); + expect(mockLinkApp).toHaveBeenCalledWith(claimedApp, folder); + }); + test("human mode: a spent token warns and falls through to the picker", async () => { mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); @@ -1322,6 +1459,45 @@ describe("link", () => { expect(await breadcrumbExists()).toBe(true); }); + test("signed out, --app leaves claiming to sign-in, as before", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue(null); + mockLogin.mockResolvedValue({ email: "a@b.c" }); + mockFetchApplication.mockResolvedValue(mockApp); + + await runLink({ cwd: folder, app: "app_123", claimKeylessApp: true }); + + // Sign-in with its default claim; link's own claim step doesn't run. + expect(mockLogin).toHaveBeenCalledWith({ showNextSteps: false }); + expect(mockClaimApplication).not.toHaveBeenCalled(); + expect(mockSetProfile).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ appId: "app_123" }), + ); + }); + + test("signed out, relinking an already linked folder leaves claiming to sign-in, as before", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue(null); + mockLogin.mockResolvedValue({ email: "a@b.c" }); + mockResolveProfile.mockResolvedValue({ + path: "/repo/.git", + profile: { workspaceId: "", appId: "app_existing", instances: { development: "ins_1" } }, + }); + mockConfirm.mockResolvedValue(true); + mockListApplications.mockResolvedValue([mockApp]); + mockSearch.mockResolvedValue("app_123"); + + await runLink({ cwd: folder, claimKeylessApp: true }); + + expect(mockLogin).toHaveBeenCalledWith({ showNextSteps: false }); + expect(mockClaimApplication).not.toHaveBeenCalled(); + expect(mockSetProfile).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ appId: "app_123" }), + ); + }); + test("an existing link skips the claim", async () => { mockIsAgent.mockReturnValue(true); mockResolveProfile.mockResolvedValue({ diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 4311b386c..b679c7df9 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -11,11 +11,22 @@ import { autolink, findClerkKeys, matchKeyToApp } from "../../lib/autolink.ts"; import { getGitRepoIdentifier, getGitRepoRoot, getGitNormalizedRemote } from "../../lib/git.ts"; import { dim, cyan } from "../../lib/color.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; -import { CliError, ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; +import { + ApiError, + CliError, + ERROR_CODE, + throwUsageError, + withApiContext, +} from "../../lib/errors.ts"; import { intro, outro } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; import { readKeylessBreadcrumb } from "../../lib/keyless.ts"; -import { attemptAutoclaim, claimWarning, keysMismatchMessage } from "../../lib/autoclaim.ts"; +import { + attemptAutoclaim, + claimWarning, + keysMismatchMessage, + reportClaimed, +} from "../../lib/autoclaim.ts"; interface LinkOptions { app?: string; @@ -32,8 +43,10 @@ interface LinkOptions { showNextSteps?: boolean; /** * Claim the application a signed-out `clerk init` left a claim token for, before listing - * apps. Set only by the `clerk link` command: `clerk init`, `clerk migrate` and the - * `clerk doctor` fix also call `link()`, and their flows must not claim. + * apps. Set only by the `clerk link` command; `clerk init`, `clerk migrate` and the + * `clerk doctor` fix call `link()` without it. Signing in through `login()` still claims, + * without the key check, on every path that doesn't run this step: other callers, and + * `clerk link --app` or a relink when signed out. */ claimKeylessApp?: boolean; } @@ -78,18 +91,20 @@ export async function link(options: LinkOptions = {}): Promise { } let introShown = false; + let claimedApp: Application | undefined; if (options.claimKeylessApp && !existing && !options.app) { const outcome = await claimKeylessApp(cwd, options); - if (outcome === "claimed") return; - introShown = outcome === "not_claimed"; + if (outcome.kind === "linked") return; + introShown = outcome.kind !== "no_breadcrumb"; + if (outcome.kind === "claimed_unlinked") claimedApp = outcome.app; } - if (!existing && !options.app && (options.skipIfLinked || agent)) { + if (!claimedApp && !existing && !options.app && (options.skipIfLinked || agent)) { const autolinked = await autolink(cwd); if (autolinked) return; } - if (agent && !existing && !options.app && !options.createIfMissing) { + if (!claimedApp && agent && !existing && !options.app && !options.createIfMissing) { throwUsageError( "Cannot select an application in agent mode. Pass --app , or run `clerk apps list --json` and retry.", ); @@ -113,14 +128,18 @@ export async function link(options: LinkOptions = {}): Promise { await ensureAuth(); - const app = options.app - ? await withApiContext(fetchApplication(options.app), "Failed to fetch application") - : agent && options.createIfMissing - ? await withApiContext( - createApplication(options.createIfMissing), - "Failed to create application", - ) - : await resolveApp(cwd, displayPath, !existing); + // A claimed app that didn't link is linked here, as `--app ` would: a missing + // development instance or a failed config write then reports its own error. + const app = + claimedApp ?? + (options.app + ? await withApiContext(fetchApplication(options.app), "Failed to fetch application") + : agent && options.createIfMissing + ? await withApiContext( + createApplication(options.createIfMissing), + "Failed to create application", + ) + : await resolveApp(cwd, displayPath, !existing)); const devInstance = app.instances.find((i) => i.environment_type === "development"); const prodInstance = app.instances.find((i) => i.environment_type === "production"); @@ -147,38 +166,67 @@ export async function link(options: LinkOptions = {}): Promise { await outro(options.showNextSteps === false ? undefined : NEXT_STEPS.LINK); } +const OTHER_APP_HINT = "or run `clerk link --app ` to link a different application."; + +type ClaimOutcome = + | { kind: "no_breadcrumb" } + | { kind: "linked" } + | { kind: "claimed_unlinked"; app: Application } + | { kind: "not_claimed" }; + /** - * `no_breadcrumb` when there is nothing to claim, `not_claimed` (after printing why) when the - * claim didn't link the folder and `link()` should carry on. Throws when the folder's keys - * belong to another application. + * `linked` when the claim linked the folder, `claimed_unlinked` when `link()` must link the + * claimed app itself, `not_claimed` (after printing why) when the token is spent and `link()` + * carries on as usual. Throws on a key mismatch, and on a failure that keeps the token, + * because no picker or app list can show the app the token claims. */ -async function claimKeylessApp( - cwd: string, - options: LinkOptions, -): Promise<"claimed" | "not_claimed" | "no_breadcrumb"> { +async function claimKeylessApp(cwd: string, options: LinkOptions): Promise { const breadcrumb = await readKeylessBreadcrumb(cwd); - if (!breadcrumb?.claimToken) return "no_breadcrumb"; + if (!breadcrumb?.claimToken) return { kind: "no_breadcrumb" }; intro("Linking project"); // `login()` would claim on its own and swallow a key mismatch as a warning. await ensureAuth({ claim: false }); - const result = await attemptAutoclaim(cwd); - - if (result.status === "keys_mismatch") { - throw new CliError(keysMismatchMessage(result), { code: ERROR_CODE.KEY_PAIR_MISMATCH }); - } - - if (result.status === "claimed") { - const label = result.app.name || result.app.application_id; - log.success(`Claimed and linked application: \`${label}\``); - const steps = result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV; - await outro(options.showNextSteps === false ? undefined : steps); - return "claimed"; + const result = await attemptAutoclaim(cwd, { checkKeys: true }); + + switch (result.status) { + case "keys_mismatch": + throw new CliError(keysMismatchMessage(result), { code: ERROR_CODE.KEY_PAIR_MISMATCH }); + case "failed": + log.warn( + "Couldn't claim the application `clerk init` created because of a temporary error. " + + `Rerun \`clerk link\` to retry, ${OTHER_APP_HINT}`, + ); + throw withClaimContext(result.error); + case "managed_workspace": + log.warn( + `Unable to claim - ${result.longMessage ?? "this workspace is managed by an integration provider."} ` + + `Switch to a workspace you own and rerun \`clerk link\`, ${OTHER_APP_HINT}`, + ); + throw withClaimContext(result.error); + case "claimed": + reportClaimed(result); + if (!result.linked) return { kind: "claimed_unlinked", app: result.app }; + await outro( + options.showNextSteps === false + ? undefined + : result.envPulled + ? NEXT_STEPS.AUTOCLAIMED + : NEXT_STEPS.AUTOCLAIMED_NO_ENV, + ); + return { kind: "linked" }; + default: { + const warning = claimWarning(result); + if (warning) log.warn(warning); + return { kind: "not_claimed" }; + } } +} - const warning = claimWarning(result); - if (warning) log.warn(warning); - return "not_claimed"; +/** The API's own error keeps its code for agents; only its prefix names the claim. */ +function withClaimContext(error: Error): Error { + if (error instanceof ApiError) error.context = "Failed to claim application"; + return error; } async function ensureAuth({ claim }: { claim?: boolean } = {}) { @@ -189,7 +237,7 @@ async function ensureAuth({ claim }: { claim?: boolean } = {}) { const token = await getToken(); if (!token) { log.info("Not logged in. Authenticating first..."); - await login({ showNextSteps: false, claim }); + await login(claim === false ? { showNextSteps: false, claim } : { showNextSteps: false }); } } diff --git a/packages/cli-core/src/lib/autoclaim.test.ts b/packages/cli-core/src/lib/autoclaim.test.ts index 35832bb15..50b74cb37 100644 --- a/packages/cli-core/src/lib/autoclaim.test.ts +++ b/packages/cli-core/src/lib/autoclaim.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdtemp, rm } from "node:fs/promises"; import { join, basename } from "node:path"; import { tmpdir } from "node:os"; import { stubFetch, useCaptureLog } from "../test/lib/stubs.ts"; @@ -7,8 +7,26 @@ import { stubFetch, useCaptureLog } 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 * as credentialStoreMod from "./credential-store.ts"; import type { Profile } from "./config.ts"; -import { attemptAutoclaim, claimWarning, keysMismatchMessage } from "./autoclaim.ts"; +import { + attemptAutoclaim, + claimWarning, + keysMismatchMessage, + reportClaimed, + type AutoclaimResult, +} from "./autoclaim.ts"; + +/** Shell variables the claim reads; a developer's own must not decide these tests. */ +const SHELL_VARS = ["CLERK_PUBLISHABLE_KEY", "CLERK_PLATFORM_API_KEY", "CLERK_PLATFORM_API_URL"]; +const originalShell = Object.fromEntries(SHELL_VARS.map((name) => [name, process.env[name]])); + +function restoreShellVars(): void { + for (const name of SHELL_VARS) { + if (originalShell[name] === undefined) delete process.env[name]; + else process.env[name] = originalShell[name]; + } +} const MOCK_APP = { application_id: "app_claimed", @@ -40,9 +58,13 @@ describe("attemptAutoclaim", () => { let readBreadcrumbSpy: ReturnType; let clearBreadcrumbSpy: ReturnType; let pullSpy: ReturnType; + let tokenSpy: ReturnType; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "clerk-autoclaim-test-")); + delete process.env.CLERK_PUBLISHABLE_KEY; + // The API key keeps PLAPI off the OS keychain; the spy makes sure it stays off. + tokenSpy = spyOn(credentialStoreMod, "getValidToken").mockResolvedValue(null); process.env.CLERK_PLATFORM_API_KEY = "ak_test_key"; process.env.CLERK_PLATFORM_API_URL = "https://test-api.clerk.com"; linkAppSpy = spyOn(autolinkMod, "linkApp").mockResolvedValue({ @@ -55,8 +77,8 @@ describe("attemptAutoclaim", () => { }); afterEach(async () => { - delete process.env.CLERK_PLATFORM_API_KEY; - delete process.env.CLERK_PLATFORM_API_URL; + restoreShellVars(); + tokenSpy.mockRestore(); globalThis.fetch = originalFetch; linkAppSpy.mockRestore(); readBreadcrumbSpy.mockRestore(); @@ -69,8 +91,8 @@ describe("attemptAutoclaim", () => { readBreadcrumbSpy.mockResolvedValue({ claimToken: token, createdAt: new Date().toISOString() }); } - function run() { - return attemptAutoclaim(tempDir); + function run({ checkKeys = true }: { checkKeys?: boolean } = {}) { + return attemptAutoclaim(tempDir, { checkKeys }); } test("returns not_keyless when no breadcrumb exists", async () => { @@ -88,6 +110,7 @@ describe("attemptAutoclaim", () => { expect(result.status).toBe("claimed"); if (result.status === "claimed") { expect(result.app.application_id).toBe("app_claimed"); + expect(result.linked).toBe(true); expect(result.envPulled).toBe(true); } expect(linkAppSpy).toHaveBeenCalledWith(MOCK_APP, tempDir); @@ -208,6 +231,38 @@ describe("attemptAutoclaim", () => { expect(linkAppSpy).not.toHaveBeenCalled(); }); + test("an app with no development instance is claimed but not linked, and nothing is pulled", async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + linkAppSpy.mockResolvedValue(undefined); + + const result = await run(); + + expect(result).toMatchObject({ + status: "claimed", + linked: false, + linkError: "the application has no development instance", + envPulled: false, + }); + expect(pullSpy).not.toHaveBeenCalled(); + }); + + test("a failed link write is reported as not linked with its reason", async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + linkAppSpy.mockRejectedValue(new Error("EACCES: permission denied, open 'config.json'")); + + const result = await run(); + + expect(result).toMatchObject({ + status: "claimed", + linked: false, + linkError: "EACCES: permission denied, open 'config.json'", + envPulled: false, + }); + expect(pullSpy).not.toHaveBeenCalled(); + }); + test("sends claim token and app name in request body", async () => { withBreadcrumb("my_claim_token"); let capturedBody: string | undefined; @@ -224,17 +279,6 @@ describe("attemptAutoclaim", () => { expect(parsed.name).toBe(basename(tempDir)); }); describe("publishable keys already in the folder", () => { - const originalKey = process.env.CLERK_PUBLISHABLE_KEY; - - beforeEach(() => { - delete process.env.CLERK_PUBLISHABLE_KEY; - }); - - afterEach(() => { - if (originalKey === undefined) delete process.env.CLERK_PUBLISHABLE_KEY; - else process.env.CLERK_PUBLISHABLE_KEY = originalKey; - }); - test("a key for another app stops before linking or pulling, and the env file is unchanged", async () => { withBreadcrumb(); stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); @@ -271,6 +315,50 @@ describe("attemptAutoclaim", () => { expect(linkAppSpy).not.toHaveBeenCalled(); }); + test("without the key check, a key for another app still links and pulls (clerk init, migrate, doctor)", async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + await Bun.write(join(tempDir, ".env"), "CLERK_PUBLISHABLE_KEY=pk_test_other\n"); + + const result = await run({ checkKeys: false }); + + expect(result.status).toBe("claimed"); + expect(linkAppSpy).toHaveBeenCalledWith(MOCK_APP, tempDir); + expect(pullSpy).toHaveBeenCalledWith({}); + }); + + // Root ignores file permissions, so an unreadable file can't be made there. + test.skipIf(process.getuid?.() === 0)( + "an unreadable .env links the folder but writes no keys, even with a foreign key exported", + async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + process.env.CLERK_PUBLISHABLE_KEY = "pk_test_other"; + const envPath = join(tempDir, ".env"); + const before = "CLERK_PUBLISHABLE_KEY=pk_test_mine\n"; + await Bun.write(envPath, before); + await chmod(envPath, 0o000); + + let result: AutoclaimResult; + try { + result = await run(); + } finally { + await chmod(envPath, 0o600); + } + + expect(result.status).toBe("claimed"); + if (result.status === "claimed") { + expect(result.linked).toBe(true); + expect(result.envPulled).toBe(false); + expect(result.unreadableEnv?.file).toBe(".env"); + expect(result.unreadableEnv?.reason).toContain("EACCES"); + } + expect(linkAppSpy).toHaveBeenCalledWith(MOCK_APP, tempDir); + expect(pullSpy).not.toHaveBeenCalled(); + expect(await Bun.file(envPath).text()).toBe(before); + }, + ); + test("the claimed app's own key links and pulls as before", async () => { withBreadcrumb(); stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); @@ -307,3 +395,57 @@ describe("keysMismatchMessage", () => { expect(claimWarning(result)).toBe(keysMismatchMessage(result)); }); }); + +describe("reportClaimed", () => { + const captured = useCaptureLog(); + const app = MOCK_APP as never; + + test("a linked claim prints the claim line and where the app now lives", () => { + reportClaimed({ status: "claimed", app, linked: true, envPulled: true }); + + expect(captured.err).toContain("Claimed and linked application: `My Claimed App`"); + expect(captured.err).toMatch( + /Your app now lives in your Clerk account:\n[^\n]*? {2}https:\/\/dashboard\.clerk\.com\/apps\/app_claimed\/instances\/ins_dev/, + ); + }); + + test("an unlinked claim says so, with the caller's hint", () => { + reportClaimed( + { status: "claimed", app, linked: false, linkError: "disk full", envPulled: false }, + { unlinkedHint: "Run `clerk link` to finish setup." }, + ); + + expect(captured.err).toContain("Claimed application: `My Claimed App`"); + expect(captured.err).not.toContain("Claimed and linked"); + expect(captured.err).toContain( + "This folder wasn't linked: disk full. Run `clerk link` to finish setup.", + ); + }); + + test("an unlinked claim without a hint prints no warning, for clerk link to link it", () => { + reportClaimed({ + status: "claimed", + app, + linked: false, + linkError: "disk full", + envPulled: false, + }); + + expect(captured.err).not.toContain("wasn't linked"); + }); + + test("an unreadable env file names the file and the reason, and says to pull later", () => { + reportClaimed({ + status: "claimed", + app, + linked: true, + envPulled: false, + unreadableEnv: { file: ".env", reason: "EACCES: permission denied" }, + }); + + expect(captured.err).toContain( + "Couldn't read .env to check its publishable key (EACCES: permission denied), so this folder's keys weren't written. " + + "Run `clerk env pull` once the file is fixed.", + ); + }); +}); diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index 5f669b064..d2bf60b47 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -1,21 +1,38 @@ +import { relative } from "node:path"; import { readKeylessBreadcrumb, clearKeylessBreadcrumb } from "./keyless.ts"; import { claimApplication, type Application } from "./plapi.ts"; import { PlapiError, errorMessage } from "./errors.ts"; import { findClerkKeys, linkApp } from "./autolink.ts"; import { deriveProjectName } from "./project-name.ts"; +import { buildDashboardUrl } from "./environment.ts"; import { pull } from "../commands/env/pull.ts"; import { log } from "./log.ts"; -type Claimed = { status: "claimed"; app: Application; envPulled: boolean }; +/** An env file the key check couldn't read, so the claimed app's keys weren't written. */ +type UnreadableEnv = { file: string; reason: string }; + +type Claimed = { + status: "claimed"; + app: Application; + /** Whether the folder's link was saved. `linkError` says why not. */ + linked: boolean; + linkError?: string; + envPulled: boolean; + unreadableEnv?: UnreadableEnv; +}; /** The claim can never succeed with this token; the breadcrumb is cleared. */ type Terminal = { status: "not_found" | "no_organization" }; /** * The token's workspace is provisioned by an integration (Vercel Marketplace, * Stripe), so nothing can be claimed into it. The breadcrumb is kept: the user - * switches to a workspace they own and the next `clerk auth login` retries. + * switches to a workspace they own and the next `clerk auth login` or `clerk link` retries. * `longMessage` is the API's `long_message`, which names the provider. */ -type ManagedWorkspace = { status: "managed_workspace"; longMessage: string | null }; +type ManagedWorkspace = { + status: "managed_workspace"; + longMessage: string | null; + error: PlapiError; +}; type Failed = { status: "failed"; error: Error }; type Skipped = { status: "not_keyless" }; /** @@ -43,8 +60,15 @@ const TERMINAL_BY_STATUS: Record = { 403: "no_organization", }; -/** Orchestrates post-login claim of a keyless app. Never throws. */ -export async function attemptAutoclaim(cwd: string): Promise { +/** + * Orchestrates the claim of a keyless app. Never throws. `checkKeys` compares the folder's + * publishable keys with the claimed app's before linking; `clerk init`, `clerk migrate` and + * the `clerk doctor` fix sign in without it, so they keep linking whatever they claim. + */ +export async function attemptAutoclaim( + cwd: string, + { checkKeys }: { checkKeys: boolean }, +): Promise { const breadcrumb = await readKeylessBreadcrumb(cwd); if (!breadcrumb) return { status: "not_keyless" }; @@ -56,28 +80,42 @@ export async function attemptAutoclaim(cwd: string): Promise { await clearKeylessBreadcrumb(cwd); if (result.status === "claimed") { - const sources = await mismatchingKeySources(result.app, cwd); - if (sources.length > 0) return { status: "keys_mismatch", app: result.app, sources }; - const linked = await tryLinkApp(result.app, cwd); - const envPulled = linked && (await tryPullEnv()); - return { ...result, envPulled }; + const check = checkKeys ? await checkLocalKeys(result.app, cwd) : { sources: [] }; + if ("sources" in check && check.sources.length > 0) { + return { status: "keys_mismatch", app: result.app, sources: check.sources }; + } + const link = await tryLinkApp(result.app, cwd); + if ("unreadable" in check) { + return { ...result, ...link, envPulled: false, unreadableEnv: check.unreadable }; + } + const envPulled = link.linked && (await tryPullEnv()); + return { ...result, ...link, envPulled }; } return result; } -async function mismatchingKeySources(app: Application, cwd: string): Promise { +/** + * Where the folder holds a publishable key for another app, or the env file that couldn't be + * read. An unreadable file can hide a mismatch, so the caller then writes no keys. + */ +async function checkLocalKeys( + app: Application, + cwd: string, +): Promise<{ sources: string[] } | { unreadable: UnreadableEnv }> { // Deserialized API JSON: a claim response without instances must not throw here. const expected = app.instances?.find( (instance) => instance.environment_type === "development", )?.publishable_key; - if (!expected) return []; + if (!expected) return { sources: [] }; try { const keys = await findClerkKeys(cwd); - return keys.filter((found) => found.key !== expected).map((found) => found.source); + return { sources: keys.filter((found) => found.key !== expected).map((found) => found.source) }; } catch (error) { log.debug(`autoclaim: could not read local publishable keys: ${errorMessage(error)}`); - return []; + const path = (error as { path?: unknown }).path; + const file = typeof path === "string" ? relative(cwd, path) || path : "an env file"; + return { unreadable: { file, reason: errorMessage(error) } }; } } @@ -91,17 +129,18 @@ 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 +// succeeded on the server and the breadcrumb is cleared, so a local link // failure must not surface as a failed login. -async function tryLinkApp(app: Application, cwd: string): Promise { +async function tryLinkApp( + app: Application, + cwd: string, +): Promise<{ linked: boolean; linkError?: string }> { try { - await linkApp(app, cwd); - return true; + const linked = await linkApp(app, cwd); + if (!linked) return { linked: false, linkError: "the application has no development instance" }; + return { linked: true }; } catch (error) { - log.warn( - `Claim succeeded but linking the project locally failed: ${errorMessage(error)}. Run \`clerk link\` to finish setup.`, - ); - return false; + return { linked: false, linkError: errorMessage(error) }; } } @@ -122,7 +161,7 @@ function classifyClaimError(error: unknown): Terminal | ManagedWorkspace | Faile error.code === MANAGED_WORKSPACE_CODE ) { log.debug(`Claim returned 403 ${MANAGED_WORKSPACE_CODE}: classified as managed_workspace`); - return { status: "managed_workspace", longMessage: error.longMessage }; + return { status: "managed_workspace", longMessage: error.longMessage, error }; } if (error instanceof PlapiError && error.status in TERMINAL_BY_STATUS) { @@ -154,6 +193,43 @@ export function claimWarning(result: AutoclaimResult): string | undefined { return CLAIM_WARNINGS[result.status]; } +/** + * Prints what a successful claim did. `unlinkedHint` follows the warning when the folder wasn't + * linked; `clerk link` passes none because it goes on to link the claimed app itself. + */ +export function reportClaimed( + result: Extract, + { unlinkedHint }: { unlinkedHint?: string } = {}, +): void { + const label = result.app.name || result.app.application_id; + log.success( + result.linked + ? `Claimed and linked application: \`${label}\`` + : `Claimed application: \`${label}\``, + ); + // First time this app has a home in an account; say where it is. + // Deserialized API JSON; a missing array must not fail a claim that + // already succeeded server-side. + const development = result.app.instances?.find( + (instance) => instance.environment_type === "development", + ); + if (development) { + // URL on its own line: with it, the sentence is wider than the frame. + log.info("Your app now lives in your Clerk account:"); + log.info(` ${buildDashboardUrl(result.app.application_id, development.instance_id)}`); + } + if (!result.linked && unlinkedHint) { + log.warn(`This folder wasn't linked: ${result.linkError}. ${unlinkedHint}`); + } + if (result.unreadableEnv) { + const { file, reason } = result.unreadableEnv; + log.warn( + `Couldn't read ${file} to check its publishable key (${reason}), so this folder's keys weren't written. ` + + "Run `clerk env pull` once the file is fixed.", + ); + } +} + export function keysMismatchMessage(result: KeysMismatch): string { const { app, sources } = result; const label = app.name ? `\`${app.name}\` (${app.application_id})` : `\`${app.application_id}\``; From abf9a8b9e767aefd8514f89a88d81036768207db Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Mon, 2 Nov 2026 15:39:13 -0600 Subject: [PATCH 07/15] Word link claim failures by cause - `clerk link` still stops when a claim fails with the token kept, but the advice now depends on the cause: a 5xx, 429 or network failure says it is temporary and to rerun `clerk link`; any other 4xx says Clerk rejected the claim and to run `clerk link --app `, because it would repeat on every run. - For Platform API failures the advice is the error's message prefix, so agent JSON carries it next to the API's own code, printed once. Network failures are reported as before, after a warning with the advice. - An unreadable env file no longer hides an exported publishable key for another app: the claim stops with `key_pair_mismatch` as it does when the files are readable. - `clerk link` tests set their mock defaults before each test, so each passes when run alone. - Removes an unused import from `commands/open/keyless-claim.ts`. --- .changeset/grow-1285-link-claim-and-advice.md | 2 +- packages/cli-core/src/commands/auth/README.md | 4 +- packages/cli-core/src/commands/link/README.md | 8 +- .../cli-core/src/commands/link/index.test.ts | 114 ++++++++++++------ packages/cli-core/src/commands/link/index.ts | 41 +++++-- .../src/commands/open/keyless-claim.ts | 1 - packages/cli-core/src/lib/autoclaim.test.ts | 50 +++++--- packages/cli-core/src/lib/autoclaim.ts | 5 + 8 files changed, 158 insertions(+), 67 deletions(-) diff --git a/.changeset/grow-1285-link-claim-and-advice.md b/.changeset/grow-1285-link-claim-and-advice.md index 0e7abcf8f..bf890596d 100644 --- a/.changeset/grow-1285-link-claim-and-advice.md +++ b/.changeset/grow-1285-link-claim-and-advice.md @@ -2,4 +2,4 @@ "clerk": minor --- -Make `clerk link` claim the application a signed-out `clerk init` created and link the folder to it, and make `clerk deploy` and `clerk deploy status` say how to link a folder that runs on an unclaimed application. If the claim fails temporarily or the workspace is managed by an integration, `clerk link` now stops with the error and says to rerun it, instead of offering apps that can't include the claimed one. If the folder's publishable key belongs to a different application, `clerk link` and `clerk auth login` claim the application but leave the folder unlinked and its env files unchanged, and say which file holds the other key. +Make `clerk link` claim the application a signed-out `clerk init` created and link the folder to it, and make `clerk deploy` and `clerk deploy status` say how to link a folder that runs on an unclaimed application. If the claim fails or the workspace is managed by an integration, `clerk link` now stops with the error and says whether to rerun it or link another app with `--app`, instead of offering apps that can't include the claimed one. If the folder's publishable key belongs to a different application, `clerk link` and `clerk auth login` claim the application but leave the folder unlinked and its env files unchanged, and say which file holds the other key. diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index 9c65fd225..98f616842 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -26,9 +26,9 @@ When `clerk init` runs in accountless mode it writes `.clerk/keyless.json` conta - **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** (a Platform API error or a network failure): treated as transient; breadcrumb is preserved so the next login retries. `clerk link` stops with the original error here, and with a managed workspace, because its app list can't show the application the token would claim. +- **Any other error** (a Platform API error or a network failure): treated as transient; breadcrumb is preserved so the next login retries. `clerk link` stops with the original error here, and with a managed workspace, because its app list can't show the application the token would claim. Its message says to rerun `clerk link` for a 5xx, a 429 or a network failure, and to use `clerk link --app ` for any other 4xx, which would repeat on every run. - **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), set in the environment, `.env`, or `.env.local`. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link` check keys; `clerk init`, `clerk migrate`, and the `clerk doctor` fix sign in through `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. -- **Unreadable env file**: the claim succeeds and the folder is linked, but no keys are written, because the unread file could hold another application's key. A warning names the file and the reason and says to run `clerk env pull` once the file is fixed. +- **Unreadable env file**: the claim succeeds. An exported publishable key for a different application still stops it as above. Otherwise the folder is linked, but no keys are written, because the unread file could hold another application's key. A warning names the file and the reason and says to run `clerk env pull` once the file is fixed. - **Claimed but not linked**: saving the link failed, or the application has no development instance. The command prints "Claimed application: ..." and a warning with the reason, and says to run `clerk link` to finish setup. - **Success**: application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. The command prints the Dashboard URL of the claimed app's development instance, since this is the first time the app has a home in an account. diff --git a/packages/cli-core/src/commands/link/README.md b/packages/cli-core/src/commands/link/README.md index 31070b3ac..f92e6665c 100644 --- a/packages/cli-core/src/commands/link/README.md +++ b/packages/cli-core/src/commands/link/README.md @@ -47,9 +47,13 @@ and no `--app` is given: 3. Compares the publishable keys in the env var, `.env`, and `.env.local` with the claimed application's development key 4. If they all match (or there are none), links the folder, writes the application's keys to the env file, prints "Claimed and linked application: ..." and the application's Dashboard URL, and stops 5. If any key belongs to a different application, links nothing, leaves the env files unchanged, and exits 1 with `key_pair_mismatch`. The message names the claimed application and the file, and tells you to run `clerk link --app ` with the application the folder should use -6. If an env file can't be read, links the folder but writes no keys, and warns with the file and the reason. Run `clerk env pull` once the file is fixed +6. If an env file can't be read, still compares an exported publishable key, and stops as in step 5 when it belongs to a different application. Otherwise links the folder but writes no keys, and warns with the file and the reason. Run `clerk env pull` once the file is fixed 7. If the claim succeeds but saving the link fails, prints "Claimed application: ..." and links the claimed application directly, as `clerk link --app ` would. An application with no development instance then fails with "Application has no development instance", and a config write that keeps failing reports its own error -8. If the claim fails temporarily (a Platform API error or a network failure), or the workspace is managed by an integration such as Vercel, keeps `.clerk/keyless.json` and exits with the original error. A warning says to rerun `clerk link` to retry (for a managed workspace, after switching to a workspace you own), or to run `clerk link --app ` to link a different application. A Platform API error keeps its own code, e.g. `accountless_application_managed_workspace` +8. If the claim fails, keeps `.clerk/keyless.json` and exits with the original error. A Platform API error keeps its own code, and the advice is the start of its message (" (): "): + - A 5xx or 429: the error is temporary; rerun `clerk link` to retry, or run `clerk link --app ` to link a different application + - A managed workspace (403 `accountless_application_managed_workspace`): switch to a workspace you own and rerun `clerk link`, or use `--app` + - Any other 4xx: Clerk rejected the claim; run `clerk link --app ` with the application the folder should use + - A network failure: the error is reported as is, after a warning that it is temporary and to rerun `clerk link` or use `--app` 9. If the token is spent (expired, or the account has no organization), prints the same warning `clerk auth login` prints and continues with the normal flow below Neither mode prompts during the claim. `--app` or an existing link skips this diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index fe6b35e14..570e68485 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -155,7 +155,8 @@ describe("link", () => { } }); - afterEach(() => { + // Run before each test as well as after, so a test's defaults never depend on the test before it. + function resetMocks() { _modeOverride = undefined; mockIsAgent.mockReset(); mockGetToken.mockReset(); @@ -186,6 +187,12 @@ describe("link", () => { mockOutro.mockReset(); mockConfirm.mockReset(); mockInput.mockReset(); + } + + beforeEach(resetMocks); + + afterEach(() => { + resetMocks(); consoleSpy?.mockRestore(); }); @@ -1258,25 +1265,31 @@ describe("link", () => { expect(mockAutolink).not.toHaveBeenCalled(); }); - test.each(["agent", "human"])( - "%s mode: a temporary claim failure stops with the API error and keeps the token", - async (mode) => { + const TEMPORARY = + "Couldn't claim the application `clerk init` created because of a temporary error. " + + "Rerun `clerk link` to retry, or run `clerk link --app ` to link a different application"; + + test.each([ + ["agent", 500], + ["human", 500], + ["agent", 429], + ])( + "%s mode: a %d claim response stops with the API error, worded as temporary, and keeps the token", + async (mode, status) => { mockIsAgent.mockReturnValue(mode === "agent"); mockGetToken.mockResolvedValue("token"); - mockClaimApplication.mockRejectedValue(new PlapiError(500, "Internal Server Error")); + mockClaimApplication.mockRejectedValue(new PlapiError(status as number, "Server Error")); const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( (caught: unknown) => caught, ); + // The global handler prints " (): " and the API's code as JSON. expect(error).toBeInstanceOf(PlapiError); - expect((error as PlapiError).status).toBe(500); - expect((error as PlapiError).context).toBe("Failed to claim application"); - expect(captured.err).toContain( - "Couldn't claim the application `clerk init` created because of a temporary error. " + - "Rerun `clerk link` to retry, or run `clerk link --app ` to link a different application.", - ); - expect(captured.err).not.toContain("clerk auth login"); + expect((error as PlapiError).status).toBe(status); + expect((error as PlapiError).context).toBe(TEMPORARY); + // The advice lives in the error only, so it prints once. + expect(captured.err).not.toContain("temporary error"); expect(mockAutolink).not.toHaveBeenCalled(); expect(mockSearch).not.toHaveBeenCalled(); expect(mockLinkApp).not.toHaveBeenCalled(); @@ -1284,50 +1297,79 @@ describe("link", () => { }, ); - test("a network failure during the claim stops with that error as it is", async () => { - mockIsAgent.mockReturnValue(false); + test("agent mode: a 400 says Clerk rejected the claim and points at --app, not a retry", async () => { + mockIsAgent.mockReturnValue(true); mockGetToken.mockResolvedValue("token"); - const networkError = new TypeError("fetch failed"); - mockClaimApplication.mockRejectedValue(networkError); + const body = JSON.stringify({ + errors: [{ code: "form_param_invalid", message: "Invalid claim token" }], + }); + mockClaimApplication.mockRejectedValue(new PlapiError(400, body)); const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( (caught: unknown) => caught, ); - expect(error).toBe(networkError); - expect(captured.err).toContain("Rerun `clerk link` to retry"); - expect(mockSearch).not.toHaveBeenCalled(); + expect(error).toBeInstanceOf(PlapiError); + expect((error as PlapiError).code).toBe("form_param_invalid"); + expect((error as PlapiError).status).toBe(400); + expect((error as PlapiError).message).toBe("Invalid claim token"); + expect((error as PlapiError).context).toBe( + "Clerk rejected the claim of the application `clerk init` created. " + + "Run `clerk link --app ` with the application this folder should use", + ); + expect(captured.err).not.toContain("Rerun `clerk link` to retry"); + expect(mockAutolink).not.toHaveBeenCalled(); expect(await breadcrumbExists()).toBe(true); }); - test("a managed workspace stops with the API error and says to switch workspaces", async () => { + test("a network failure during the claim stops with that error as it is, after a warning", async () => { mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); - const body = JSON.stringify({ - errors: [ - { - code: "accountless_application_managed_workspace", - message: "Cannot claim into a managed workspace", - long_message: "This workspace is managed by Vercel.", - }, - ], - }); - mockClaimApplication.mockRejectedValue(new PlapiError(403, body)); + const networkError = new TypeError("fetch failed"); + mockClaimApplication.mockRejectedValue(networkError); const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( (caught: unknown) => caught, ); - expect(error).toBeInstanceOf(PlapiError); - expect((error as PlapiError).code).toBe("accountless_application_managed_workspace"); - expect(captured.err).toContain( - "Unable to claim - This workspace is managed by Vercel. Switch to a workspace you own and rerun `clerk link`, " + - "or run `clerk link --app ` to link a different application.", - ); + expect(error).toBe(networkError); + expect(captured.err).toContain(TEMPORARY); expect(mockSearch).not.toHaveBeenCalled(); expect(await breadcrumbExists()).toBe(true); }); + test.each(["agent", "human"])( + "%s mode: a managed workspace stops with the API's code and says to switch workspaces", + async (mode) => { + mockIsAgent.mockReturnValue(mode === "agent"); + mockGetToken.mockResolvedValue("token"); + const body = JSON.stringify({ + errors: [ + { + code: "accountless_application_managed_workspace", + message: "Cannot claim into a managed workspace", + long_message: "This workspace is managed by Vercel.", + }, + ], + }); + mockClaimApplication.mockRejectedValue(new PlapiError(403, body)); + + const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(PlapiError); + expect((error as PlapiError).code).toBe("accountless_application_managed_workspace"); + expect((error as PlapiError).context).toBe( + "Unable to claim - This workspace is managed by Vercel. Switch to a workspace you own and rerun `clerk link`, " + + "or run `clerk link --app ` to link a different application", + ); + expect(captured.err).not.toContain("Switch to a workspace"); + expect(mockSearch).not.toHaveBeenCalled(); + expect(await breadcrumbExists()).toBe(true); + }, + ); + test("a claimed app with no development instance ends in the usual error, not a picker", async () => { mockIsAgent.mockReturnValue(true); mockGetToken.mockResolvedValue("token"); diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index b679c7df9..c4673448d 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -193,17 +193,13 @@ async function claimKeylessApp(cwd: string, options: LinkOptions): Promise= 500 || error.status === 429) return withAdvice(error, TEMPORARY_ADVICE); + return withAdvice( + error, + "Clerk rejected the claim of the application `clerk init` created. " + + "Run `clerk link --app ` with the application this folder should use", + ); +} + +/** + * Puts the advice in the API error's prefix, so the agent JSON carries it in `message` next + * to the API's own code, printed once: " (): ". + */ +function withAdvice(error: ApiError, advice: string): ApiError { + error.context = advice.replace(/\.$/, ""); return error; } diff --git a/packages/cli-core/src/commands/open/keyless-claim.ts b/packages/cli-core/src/commands/open/keyless-claim.ts index 4f02adad6..c2e719145 100644 --- a/packages/cli-core/src/commands/open/keyless-claim.ts +++ b/packages/cli-core/src/commands/open/keyless-claim.ts @@ -8,7 +8,6 @@ * `buildDashboardUrl`. */ -import { join } from "node:path"; import { readKeylessBreadcrumb, readSdkClaimUrl, SDK_KEYLESS_FILE } from "../../lib/keyless.ts"; import { getDashboardUrl } from "../../lib/environment.ts"; import { bapiRequest } from "../../lib/bapi.ts"; diff --git a/packages/cli-core/src/lib/autoclaim.test.ts b/packages/cli-core/src/lib/autoclaim.test.ts index 50b74cb37..149ff5ba7 100644 --- a/packages/cli-core/src/lib/autoclaim.test.ts +++ b/packages/cli-core/src/lib/autoclaim.test.ts @@ -327,24 +327,44 @@ describe("attemptAutoclaim", () => { expect(pullSpy).toHaveBeenCalledWith({}); }); + /** Claims with `.env` made unreadable for the duration; returns the result and the file's content. */ + async function claimWithUnreadableEnv(): Promise<{ result: AutoclaimResult; before: string }> { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + const envPath = join(tempDir, ".env"); + const before = "CLERK_PUBLISHABLE_KEY=pk_test_mine\n"; + await Bun.write(envPath, before); + await chmod(envPath, 0o000); + try { + return { result: await run(), before }; + } finally { + await chmod(envPath, 0o600); + } + } + // Root ignores file permissions, so an unreadable file can't be made there. test.skipIf(process.getuid?.() === 0)( - "an unreadable .env links the folder but writes no keys, even with a foreign key exported", + "an unreadable .env with a foreign key exported stops with keys_mismatch and writes nothing", async () => { - withBreadcrumb(); - stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); process.env.CLERK_PUBLISHABLE_KEY = "pk_test_other"; - const envPath = join(tempDir, ".env"); - const before = "CLERK_PUBLISHABLE_KEY=pk_test_mine\n"; - await Bun.write(envPath, before); - await chmod(envPath, 0o000); - - let result: AutoclaimResult; - try { - result = await run(); - } finally { - await chmod(envPath, 0o600); - } + + const { result, before } = await claimWithUnreadableEnv(); + + expect(result).toEqual({ + status: "keys_mismatch", + app: MOCK_APP as never, + sources: ["CLERK_PUBLISHABLE_KEY env var"], + }); + expect(linkAppSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + expect(await Bun.file(join(tempDir, ".env")).text()).toBe(before); + }, + ); + + test.skipIf(process.getuid?.() === 0)( + "an unreadable .env with no foreign key exported links the folder but writes no keys", + async () => { + const { result, before } = await claimWithUnreadableEnv(); expect(result.status).toBe("claimed"); if (result.status === "claimed") { @@ -355,7 +375,7 @@ describe("attemptAutoclaim", () => { } expect(linkAppSpy).toHaveBeenCalledWith(MOCK_APP, tempDir); expect(pullSpy).not.toHaveBeenCalled(); - expect(await Bun.file(envPath).text()).toBe(before); + expect(await Bun.file(join(tempDir, ".env")).text()).toBe(before); }, ); diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index d2bf60b47..554bc3bfd 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -5,6 +5,7 @@ import { PlapiError, errorMessage } from "./errors.ts"; import { findClerkKeys, linkApp } from "./autolink.ts"; import { deriveProjectName } from "./project-name.ts"; import { buildDashboardUrl } from "./environment.ts"; +import { detectPublishableKeyName } from "./framework.ts"; import { pull } from "../commands/env/pull.ts"; import { log } from "./log.ts"; @@ -113,6 +114,10 @@ async function checkLocalKeys( return { sources: keys.filter((found) => found.key !== expected).map((found) => found.source) }; } catch (error) { log.debug(`autoclaim: could not read local publishable keys: ${errorMessage(error)}`); + // The failed read discards keys already found; the exported one needs no file. + const name = await detectPublishableKeyName(cwd); + const exported = process.env[name]; + if (exported && exported !== expected) return { sources: [`${name} env var`] }; const path = (error as { path?: unknown }).path; const file = typeof path === "string" ? relative(cwd, path) || path : "an env file"; return { unreadable: { file, reason: errorMessage(error) } }; From 099326888d263df22ce9daa330d9cbfa89c04072 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Mon, 2 Nov 2026 15:44:03 -0600 Subject: [PATCH 08/15] Match login's claim failure advice to link - `clerk auth login` warned "It will be retried on your next `clerk auth login`" for every claim failure that kept the token, and its next steps said "Run `clerk link` to connect your application manually", but a bare `clerk link` now retries the claim and stops if it fails again. - The 5xx/429/network test and both advice texts move into `lib/autoclaim.ts`, so login and link word a failure the same way: a temporary failure says to rerun `clerk link`; any other 4xx says Clerk rejected the claim, with the status and reason, and to run `clerk link --app `. - Login's next steps offer "Run `clerk auth login` again to retry auto-claim" only after a temporary failure; both cases show "Run `clerk link --app ` to link a different application". - Adds an agent-mode `clerk link` test for a spent token (404) that continues into autolink. - Updates the auth README and the changeset to match. --- .changeset/grow-1285-link-claim-and-advice.md | 2 +- packages/cli-core/src/commands/auth/README.md | 2 +- .../cli-core/src/commands/auth/login.test.ts | 55 +++++++++++++++++++ packages/cli-core/src/commands/auth/login.ts | 7 ++- .../cli-core/src/commands/link/index.test.ts | 26 +++++++++ packages/cli-core/src/commands/link/index.ts | 22 +++----- packages/cli-core/src/lib/autoclaim.ts | 26 ++++++++- packages/cli-core/src/lib/next-steps.ts | 4 +- 8 files changed, 122 insertions(+), 22 deletions(-) diff --git a/.changeset/grow-1285-link-claim-and-advice.md b/.changeset/grow-1285-link-claim-and-advice.md index bf890596d..e18f20391 100644 --- a/.changeset/grow-1285-link-claim-and-advice.md +++ b/.changeset/grow-1285-link-claim-and-advice.md @@ -2,4 +2,4 @@ "clerk": minor --- -Make `clerk link` claim the application a signed-out `clerk init` created and link the folder to it, and make `clerk deploy` and `clerk deploy status` say how to link a folder that runs on an unclaimed application. If the claim fails or the workspace is managed by an integration, `clerk link` now stops with the error and says whether to rerun it or link another app with `--app`, instead of offering apps that can't include the claimed one. If the folder's publishable key belongs to a different application, `clerk link` and `clerk auth login` claim the application but leave the folder unlinked and its env files unchanged, and say which file holds the other key. +Make `clerk link` claim the application a signed-out `clerk init` created and link the folder to it, and make `clerk deploy` and `clerk deploy status` say how to link a folder that runs on an unclaimed application. If the claim fails with an error that keeps the claim token, or the workspace is managed by an integration, `clerk link` now stops with the error and says whether to rerun it or link another app with `--app`, instead of offering apps that can't include the claimed one. If the folder's publishable key belongs to a different application, `clerk link` and `clerk auth login` claim the application but leave the folder unlinked and its env files unchanged, and say which file holds the other key. diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index 98f616842..6235b7881 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -26,7 +26,7 @@ When `clerk init` runs in accountless mode it writes `.clerk/keyless.json` conta - **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** (a Platform API error or a network failure): treated as transient; breadcrumb is preserved so the next login retries. `clerk link` stops with the original error here, and with a managed workspace, because its app list can't show the application the token would claim. Its message says to rerun `clerk link` for a 5xx, a 429 or a network failure, and to use `clerk link --app ` for any other 4xx, which would repeat on every run. +- **Any other error** (a Platform API error or a network failure): breadcrumb is preserved, so the next `clerk link` or `clerk auth login` claims again. A 5xx, a 429 or a network failure may pass on that retry, so the warning says to rerun `clerk link`. Any other 4xx will fail the same way on every retry, so the warning says Clerk rejected the claim, with the status and reason, and to run `clerk link --app ` with the application the folder should use. `clerk auth login` prints that advice as a warning and exits 0; `clerk link` stops with the original error and the same advice, and also stops for a managed workspace, because its app list can't show the application the token would claim. - **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), set in the environment, `.env`, or `.env.local`. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link` check keys; `clerk init`, `clerk migrate`, and the `clerk doctor` fix sign in through `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. - **Unreadable env file**: the claim succeeds. An exported publishable key for a different application still stops it as above. Otherwise the folder is linked, but no keys are written, because the unread file could hold another application's key. A warning names the file and the reason and says to run `clerk env pull` once the file is fixed. - **Claimed but not linked**: saving the link failed, or the application has no development instance. The command prints "Claimed application: ..." and a warning with the reason, and says to run `clerk link` to finish setup. diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 36909d3a8..81bd8d10a 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -940,6 +940,61 @@ describe("login", () => { expect(captured.err).not.toContain("does not have an active organization"); }); + test("a temporary claim failure gives the same advice as clerk link, and the --app next step", async () => { + mockIsHuman.mockReturnValue(true); + mockGetValidToken.mockResolvedValue(null); + mockOAuthSuccess(); + mockResolveProfile.mockResolvedValue(undefined); + mockAttemptAutoclaim.mockResolvedValue({ + status: "failed", + error: new PlapiError(503, "Service Unavailable"), + }); + + await runLogin(); + + expect(captured.err).toContain( + "Couldn't claim the application `clerk init` created because of a temporary error. " + + "Rerun `clerk link` to retry, or run `clerk link --app ` to link a different application.", + ); + expect(captured.err).not.toContain("retried on your next `clerk auth login`"); + expect(captured.err).toContain("Run `clerk auth login` again to retry auto-claim"); + expect(captured.err).toContain( + "Run `clerk link --app ` to link a different application", + ); + expect(captured.err).not.toContain("Run `clerk link` to connect your application manually"); + }); + + test("a 4xx claim failure says Clerk rejected the claim, and the next steps offer no retry", async () => { + // Human mode: only it prints the next steps. + mockIsHuman.mockReturnValue(true); + mockGetValidToken.mockResolvedValue(null); + mockOAuthSuccess(); + mockResolveProfile.mockResolvedValue(undefined); + mockAttemptAutoclaim.mockResolvedValue({ + status: "failed", + error: new PlapiError( + 400, + JSON.stringify({ + errors: [{ code: "form_param_invalid", message: "Invalid claim token" }], + }), + ), + }); + + await runLogin(); + + expect(captured.err).toContain( + "Clerk rejected the claim of the application `clerk init` created. " + + "Run `clerk link --app ` with the application this folder should use. " + + "Clerk's reason (400): Invalid claim token", + ); + expect(captured.err).not.toContain("temporary error"); + // The next step; the warning above words the --app advice differently. + expect(captured.err).toContain( + "Run `clerk link --app ` to link a different application", + ); + expect(captured.err).not.toContain("Run `clerk auth login` again to retry auto-claim"); + }); + test("falls back to a generic provider when the managed-workspace rejection has no long message", async () => { mockGetValidToken.mockResolvedValue("existing-token"); mockGetAuth.mockResolvedValue({ userId: "user_123" }); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index e51816b70..0b932d9e0 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -24,6 +24,7 @@ import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { attemptAutoclaim, claimWarning, + isTemporaryClaimError, reportClaimed, type AutoclaimResult, } from "../../lib/autoclaim.ts"; @@ -312,7 +313,11 @@ async function loginNextSteps(result: AutoclaimResult): Promise { expect(mockLinkApp).toHaveBeenCalledWith(claimedApp, folder); }); + test("agent mode: a spent token (404) warns and continues into autolink, which links the folder", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockRejectedValue(new PlapiError(404, "Not Found")); + const autolinked = { + path: "github.com/org/repo", + profile: { workspaceId: "", appId: "app_auto", instances: { development: "ins_dev" } }, + }; + mockAutolink.mockResolvedValue(autolinked); + + const outcome = await runLink({ cwd: folder, claimKeylessApp: true }).then( + () => "resolved", + (caught: unknown) => caught, + ); + + expect(outcome).toBe("resolved"); + expect(captured.err).toContain( + "Claim token is no longer valid - the application may have been claimed from the dashboard.", + ); + expect(mockClaimApplication).toHaveBeenCalledTimes(1); + expect(mockAutolink).toHaveBeenCalledWith(folder); + expect(await mockAutolink.mock.results[0]?.value).toBe(autolinked); + expect(captured.err).not.toContain("Cannot select an application"); + expect(await breadcrumbExists()).toBe(false); + }); + test("human mode: a spent token warns and falls through to the picker", async () => { mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index c4673448d..5d8f8611c 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -23,9 +23,13 @@ import { log } from "../../lib/log.ts"; import { readKeylessBreadcrumb } from "../../lib/keyless.ts"; import { attemptAutoclaim, + CLAIM_OTHER_APP_HINT, claimWarning, + isTemporaryClaimError, keysMismatchMessage, + REJECTED_CLAIM_ADVICE, reportClaimed, + TEMPORARY_CLAIM_ADVICE, } from "../../lib/autoclaim.ts"; interface LinkOptions { @@ -166,8 +170,6 @@ export async function link(options: LinkOptions = {}): Promise { await outro(options.showNextSteps === false ? undefined : NEXT_STEPS.LINK); } -const OTHER_APP_HINT = "or run `clerk link --app ` to link a different application."; - type ClaimOutcome = | { kind: "no_breadcrumb" } | { kind: "linked" } @@ -198,7 +200,7 @@ async function claimKeylessApp(cwd: string, options: LinkOptions): Promise= 500 || error.status === 429) return withAdvice(error, TEMPORARY_ADVICE); return withAdvice( error, - "Clerk rejected the claim of the application `clerk init` created. " + - "Run `clerk link --app ` with the application this folder should use", + isTemporaryClaimError(error) ? TEMPORARY_CLAIM_ADVICE : REJECTED_CLAIM_ADVICE, ); } diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index 554bc3bfd..3678eda14 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -1,7 +1,7 @@ import { relative } from "node:path"; import { readKeylessBreadcrumb, clearKeylessBreadcrumb } from "./keyless.ts"; import { claimApplication, type Application } from "./plapi.ts"; -import { PlapiError, errorMessage } from "./errors.ts"; +import { ApiError, PlapiError, errorMessage } from "./errors.ts"; import { findClerkKeys, linkApp } from "./autolink.ts"; import { deriveProjectName } from "./project-name.ts"; import { buildDashboardUrl } from "./environment.ts"; @@ -184,10 +184,24 @@ 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`.", }; +export const CLAIM_OTHER_APP_HINT = + "or run `clerk link --app ` to link a different application."; + +/** What `clerk link` and `clerk auth login` advise when a claim failed and kept its token. */ +export const TEMPORARY_CLAIM_ADVICE = + "Couldn't claim the application `clerk init` created because of a temporary error. " + + `Rerun \`clerk link\` to retry, ${CLAIM_OTHER_APP_HINT}`; +export const REJECTED_CLAIM_ADVICE = + "Clerk rejected the claim of the application `clerk init` created. " + + "Run `clerk link --app ` with the application this folder should use."; + +/** A 5xx, a 429 or a network failure may pass on a retry; any other 4xx repeats on every run. */ +export function isTemporaryClaimError(error: Error): boolean { + return !(error instanceof ApiError) || error.status >= 500 || error.status === 429; +} + /** The warning `clerk auth login` and `clerk link` print for a claim that didn't link the folder. */ export function claimWarning(result: AutoclaimResult): string | undefined { if (result.status === "managed_workspace") { @@ -195,6 +209,12 @@ export function claimWarning(result: AutoclaimResult): string | undefined { return `Unable to claim - ${result.longMessage ?? "this workspace is managed by an integration provider."}`; } if (result.status === "keys_mismatch") return keysMismatchMessage(result); + if (result.status === "failed") { + const { error } = result; + if (isTemporaryClaimError(error)) return TEMPORARY_CLAIM_ADVICE; + const status = error instanceof ApiError ? ` (${error.status})` : ""; + return `${REJECTED_CLAIM_ADVICE} Clerk's reason${status}: ${error.message}`; + } return CLAIM_WARNINGS[result.status]; } diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index 2cbb997e6..bffbb6910 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -27,8 +27,10 @@ export const NEXT_STEPS = { ], AUTOCLAIM_RETRY: [ "Run `clerk auth login` again to retry auto-claim", - "Run `clerk link` to connect your application manually", + "Run `clerk link --app ` to link a different application", ], + /** After a 4xx claim failure, which a retry would only repeat. */ + AUTOCLAIM_REJECTED: ["Run `clerk link --app ` to link a different application"], AUTOCLAIM_SWITCH_WORKSPACE: [ "Select a different workspace in the Clerk Dashboard", "Run `clerk auth login` again to proceed", From 359daf3328909e41daa17b9cfe9b1301d81c451f Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Mon, 2 Nov 2026 16:50:04 -0600 Subject: [PATCH 09/15] Hedge the clerk init folder deploy message - The `not_linked` message for a folder holding a signed-out `clerk init`'s claim token said the application was one "no account owns yet", but a Dashboard claim can leave the token file behind, so that wasn't known. - It now says what the CLI found and hedges the same way the SDK message does: "If no account owns that application yet, `clerk link` claims it into your account. Run `clerk link` to link this folder, then rerun `clerk deploy`." Running `clerk link` is the right step either way. --- packages/cli-core/src/commands/deploy/README.md | 2 +- packages/cli-core/src/commands/deploy/index.test.ts | 5 +++-- .../cli-core/src/commands/deploy/status-command.test.ts | 5 +++-- packages/cli-core/src/lib/unlinked-advice.test.ts | 5 +++-- packages/cli-core/src/lib/unlinked-advice.ts | 7 ++++--- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index e6ed642f7..f99856434 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -69,7 +69,7 @@ Agent-mode `clerk deploy` without `--domain` is informational and exits 0 for li | Folder holds | Message tells you to | | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `.clerk/keyless.json` with a claim token, written by a signed-out `clerk init` | Run `clerk link`, which claims the application into your account and links the folder, then rerun the command. | +| `.clerk/keyless.json` with a claim token, written by a signed-out `clerk init` | If no account owns the application yet, `clerk link` claims it into your account. Run `clerk link` to link the folder, then rerun the command. | | `.clerk/.tmp/keyless.json` with an https claim link, written by a Clerk SDK in keyless mode | If no account owns the application yet, have a person run `clerk open` to claim it in the Dashboard. Then run `clerk link` and choose it (agents: `clerk auth login` if needed, then `clerk apps list --json` and `clerk link --app `), and rerun the command. | | Neither, or an SDK file without a usable claim link (`clerk open` would have nothing to open) | Run `clerk link`, then rerun the command. | diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 7a829d1a4..b37d56d83 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -546,8 +546,9 @@ describe("deploy", () => { describe("not_linked message names the app the folder runs on", () => { const INIT_APP = - "This folder runs on an application `clerk init` created that no account owns yet. " + - "Run `clerk link` to claim it into your account and link this folder, then rerun `clerk deploy`."; + "This folder holds a claim token a signed-out `clerk init` wrote to `.clerk/keyless.json`. " + + "If no account owns that application yet, `clerk link` claims it into your account. " + + "Run `clerk link` to link this folder, then rerun `clerk deploy`."; const SDK_APP = "This folder holds a claim link a Clerk SDK wrote to `.clerk/.tmp/keyless.json`. " + "If no account owns that application yet, a person must run `clerk open` to claim it in the Dashboard. " + diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts index 07a61288e..d2910d425 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -803,8 +803,9 @@ describe("deploy status", () => { // `test/integration/telemetry.test.ts`. describe("not_linked message names the app the folder runs on", () => { const INIT_APP = - "This folder runs on an application `clerk init` created that no account owns yet. " + - "Run `clerk link` to claim it into your account and link this folder, then rerun `clerk deploy status`."; + "This folder holds a claim token a signed-out `clerk init` wrote to `.clerk/keyless.json`. " + + "If no account owns that application yet, `clerk link` claims it into your account. " + + "Run `clerk link` to link this folder, then rerun `clerk deploy status`."; const SDK_APP = "This folder holds a claim link a Clerk SDK wrote to `.clerk/.tmp/keyless.json`. " + "If no account owns that application yet, a person must run `clerk open` to claim it in the Dashboard. " + diff --git a/packages/cli-core/src/lib/unlinked-advice.test.ts b/packages/cli-core/src/lib/unlinked-advice.test.ts index 7e5624a1c..4502a8fea 100644 --- a/packages/cli-core/src/lib/unlinked-advice.test.ts +++ b/packages/cli-core/src/lib/unlinked-advice.test.ts @@ -95,8 +95,9 @@ describe("notLinkedMessage", () => { test("init_app says clerk link claims the app", async () => { await writeInitBreadcrumb(folder); expect(await notLinkedMessage(folder, "clerk deploy")).toBe( - "This folder runs on an application `clerk init` created that no account owns yet. " + - "Run `clerk link` to claim it into your account and link this folder, then rerun `clerk deploy`.", + "This folder holds a claim token a signed-out `clerk init` wrote to `.clerk/keyless.json`. " + + "If no account owns that application yet, `clerk link` claims it into your account. " + + "Run `clerk link` to link this folder, then rerun `clerk deploy`.", ); }); diff --git a/packages/cli-core/src/lib/unlinked-advice.ts b/packages/cli-core/src/lib/unlinked-advice.ts index e7082b9a6..bcd6e74cd 100644 --- a/packages/cli-core/src/lib/unlinked-advice.ts +++ b/packages/cli-core/src/lib/unlinked-advice.ts @@ -4,7 +4,7 @@ import { peekKeylessBreadcrumb, readSdkClaimUrl } from "./keyless.ts"; * Who created the application an unlinked folder runs on, when no account owns it yet: * * - `init_app`: a signed-out `clerk init` left its claim token in `.clerk/keyless.json`. - * `clerk link` can claim it. + * `clerk link` claims it if no account owns it yet; a Dashboard claim can leave the file behind. * - `sdk_app`: a Clerk SDK in keyless mode wrote a usable claim link to * `.clerk/.tmp/keyless.json`. Only the Dashboard claim page (`clerk open`) can claim it. * - `plain`: neither, so the folder is just unlinked. An SDK file without a usable link @@ -29,8 +29,9 @@ export async function notLinkedMessage(cwd: string, rerun: string): Promise Date: Mon, 2 Nov 2026 16:50:44 -0600 Subject: [PATCH 10/15] Check every dev env file before a claim links - The key check read only `.env` and `.env.local`, but the claim's env pull writes `.env.development.local` first when it exists, so a claim could overwrite another app's keys there. The check now reads the exported variable and `.env.development.local`, `.env.local`, `.env.development` and `.env`, each on its own. - An unreadable file no longer hides a mismatch found in another file: any mismatch refuses with `key_pair_mismatch`, and an unreadable file only leads to "linked, no keys written" when nothing mismatches. - The claim's env pull now writes the folder the check read (`pull({ cwd })`), not the process's working directory. CLI callers pass the current directory, so their behavior is the same. - A 401 from the claim means the saved sign-in was rejected: both commands say to run `clerk auth login` and rerun `clerk link`, with no `--app` advice, which would fail the same way. - Adds tests with the real link and env pull, a 401 in each command, the registered `clerk link` command with no or a malformed token file, no-organization continuing, and a failed key write. --- packages/cli-core/src/commands/auth/README.md | 6 +- .../cli-core/src/commands/auth/login.test.ts | 20 ++++ packages/cli-core/src/commands/auth/login.ts | 8 +- packages/cli-core/src/commands/link/README.md | 7 +- .../cli-core/src/commands/link/index.test.ts | 87 ++++++++++++++- packages/cli-core/src/commands/link/index.ts | 12 +-- packages/cli-core/src/lib/autoclaim.test.ts | 101 +++++++++++++++++- packages/cli-core/src/lib/autoclaim.ts | 94 ++++++++++------ packages/cli-core/src/lib/next-steps.ts | 5 + 9 files changed, 286 insertions(+), 54 deletions(-) diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index 6235b7881..4010a0fae 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -26,9 +26,9 @@ When `clerk init` runs in accountless mode it writes `.clerk/keyless.json` conta - **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** (a Platform API error or a network failure): breadcrumb is preserved, so the next `clerk link` or `clerk auth login` claims again. A 5xx, a 429 or a network failure may pass on that retry, so the warning says to rerun `clerk link`. Any other 4xx will fail the same way on every retry, so the warning says Clerk rejected the claim, with the status and reason, and to run `clerk link --app ` with the application the folder should use. `clerk auth login` prints that advice as a warning and exits 0; `clerk link` stops with the original error and the same advice, and also stops for a managed workspace, because its app list can't show the application the token would claim. -- **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), set in the environment, `.env`, or `.env.local`. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link` check keys; `clerk init`, `clerk migrate`, and the `clerk doctor` fix sign in through `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. -- **Unreadable env file**: the claim succeeds. An exported publishable key for a different application still stops it as above. Otherwise the folder is linked, but no keys are written, because the unread file could hold another application's key. A warning names the file and the reason and says to run `clerk env pull` once the file is fixed. +- **Any other error** (a Platform API error or a network failure): breadcrumb is preserved, so the next `clerk link` or `clerk auth login` claims again. A 5xx, a 429 or a network failure may pass on that retry, so the warning says to rerun `clerk link`. A 401 means Clerk rejected the saved sign-in, so the warning says to run `clerk auth login` to sign in again and then rerun `clerk link`. Any other 4xx will fail the same way on every retry, so the warning says Clerk rejected the claim, with the status and reason, and to run `clerk link --app ` with the application the folder should use. `clerk auth login` prints that advice as a warning and exits 0; `clerk link` stops with the original error and the same advice, and also stops for a managed workspace, because its app list can't show the application the token would claim. +- **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), either exported or in any env file `clerk env pull` can write (`.env.development.local`, `.env.local`, `.env.development`, `.env`). Each file is read on its own. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link` check keys; `clerk init`, `clerk migrate`, and the `clerk doctor` fix sign in through `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. +- **Unreadable env file**: the claim succeeds. A different application's key in the exported variable or any readable file still stops it as above. Otherwise the folder is linked, but no keys are written, because the unread file could hold another application's key. A warning names the file and the reason and says to run `clerk env pull` once the file is fixed. - **Claimed but not linked**: saving the link failed, or the application has no development instance. The command prints "Claimed application: ..." and a warning with the reason, and says to run `clerk link` to finish setup. - **Success**: application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. The command prints the Dashboard URL of the claimed app's development instance, since this is the first time the app has a home in an account. diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 81bd8d10a..ff57bbc19 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -995,6 +995,26 @@ describe("login", () => { expect(captured.err).not.toContain("Run `clerk auth login` again to retry auto-claim"); }); + test("a 401 claim failure says to sign in again, with no --app advice in the warning or next steps", async () => { + mockIsHuman.mockReturnValue(true); + mockGetValidToken.mockResolvedValue(null); + mockOAuthSuccess(); + mockResolveProfile.mockResolvedValue(undefined); + mockAttemptAutoclaim.mockResolvedValue({ + status: "failed", + error: new PlapiError(401, "Unauthorized"), + }); + + await runLogin(); + + expect(captured.err).toContain( + "Clerk rejected the saved sign-in while claiming the application `clerk init` created. " + + "Run `clerk auth login` to sign in again, then rerun `clerk link`.", + ); + expect(captured.err).toContain("Run `clerk auth login` to sign in again"); + expect(captured.err).not.toContain("--app"); + }); + test("falls back to a generic provider when the managed-workspace rejection has no long message", async () => { mockGetValidToken.mockResolvedValue("existing-token"); mockGetAuth.mockResolvedValue({ userId: "user_123" }); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 0b932d9e0..e95f2b318 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -24,7 +24,7 @@ import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { attemptAutoclaim, claimWarning, - isTemporaryClaimError, + claimFailureKind, reportClaimed, type AutoclaimResult, } from "../../lib/autoclaim.ts"; @@ -314,9 +314,9 @@ async function loginNextSteps(result: AutoclaimResult): Promise` with the application the folder should use -6. If an env file can't be read, still compares an exported publishable key, and stops as in step 5 when it belongs to a different application. Otherwise links the folder but writes no keys, and warns with the file and the reason. Run `clerk env pull` once the file is fixed +5. If any of those keys belongs to a different application, links nothing, leaves the env files unchanged, and exits 1 with `key_pair_mismatch`. The message names the claimed application and the file, and tells you to run `clerk link --app ` with the application the folder should use +6. If an env file can't be read and no other source holds a different application's key, links the folder but writes no keys, and warns with the file and the reason. Run `clerk env pull` once the file is fixed. A mismatch in any readable source still stops as in step 5 7. If the claim succeeds but saving the link fails, prints "Claimed application: ..." and links the claimed application directly, as `clerk link --app ` would. An application with no development instance then fails with "Application has no development instance", and a config write that keeps failing reports its own error 8. If the claim fails, keeps `.clerk/keyless.json` and exits with the original error. A Platform API error keeps its own code, and the advice is the start of its message (" (): "): - A 5xx or 429: the error is temporary; rerun `clerk link` to retry, or run `clerk link --app ` to link a different application - A managed workspace (403 `accountless_application_managed_workspace`): switch to a workspace you own and rerun `clerk link`, or use `--app` + - A 401: Clerk rejected the saved sign-in; run `clerk auth login` to sign in again, then rerun `clerk link` - Any other 4xx: Clerk rejected the claim; run `clerk link --app ` with the application the folder should use - A network failure: the error is reported as is, after a warning that it is temporary and to rerun `clerk link` or use `--app` 9. If the token is spent (expired, or the account has no organization), prints the same warning `clerk auth login` prints and continues with the normal flow below diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index cf1767be0..2fa27ef03 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -1241,7 +1241,7 @@ describe("link", () => { expect(mockClaimApplication).toHaveBeenCalledTimes(1); expect(mockClaimApplication).toHaveBeenCalledWith("tok_init", expect.any(String)); expect(mockLinkApp).toHaveBeenCalledWith(claimedApp, folder); - expect(mockPull).toHaveBeenCalledWith({}); + expect(mockPull).toHaveBeenCalledWith({ cwd: folder }); expect(captured.err).toContain("Claimed and linked application: `My Init App`"); expect(mockOutro).toHaveBeenLastCalledWith(NEXT_STEPS.AUTOCLAIMED); expect(captured.err).toContain("Your app now lives in your Clerk account:"); @@ -1464,6 +1464,61 @@ describe("link", () => { expect(await breadcrumbExists()).toBe(false); }); + test("agent mode: a 401 says to sign in again, with no --app advice", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockRejectedValue(new PlapiError(401, "Unauthorized")); + + const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(PlapiError); + expect((error as PlapiError).status).toBe(401); + expect((error as PlapiError).context).toBe( + "Clerk rejected the saved sign-in while claiming the application `clerk init` created. " + + "Run `clerk auth login` to sign in again, then rerun `clerk link`", + ); + expect((error as PlapiError).context).not.toContain("--app"); + expect(captured.err).not.toContain("--app"); + expect(mockAutolink).not.toHaveBeenCalled(); + expect(await breadcrumbExists()).toBe(true); + }); + + test("human mode: no organization warns and falls through to the picker", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockRejectedValue(new PlapiError(403, "Forbidden")); + mockListApplications.mockResolvedValue([mockApp]); + mockSearch.mockResolvedValue("app_123"); + + await runLink({ cwd: folder, claimKeylessApp: true }); + + expect(captured.err).toContain( + "Unable to claim - your account does not have an active organization.", + ); + expect(mockSearch).toHaveBeenCalled(); + expect(mockSetProfile).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ appId: "app_123" }), + ); + expect(await breadcrumbExists()).toBe(false); + }); + + test("a claim whose key write fails still links, and the next steps say to run clerk env pull", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockResolvedValue(claimedApp); + mockPull.mockRejectedValue(new Error("EACCES: .env.local")); + + await runLink({ cwd: folder, claimKeylessApp: true }); + + expect(mockLinkApp).toHaveBeenCalledWith(claimedApp, folder); + expect(captured.err).toContain("Claimed and linked application: `My Init App`"); + expect(mockOutro).toHaveBeenLastCalledWith(NEXT_STEPS.AUTOCLAIMED_NO_ENV); + expect(NEXT_STEPS.AUTOCLAIMED_NO_ENV.join("\n")).toContain("clerk env pull"); + }); + test("human mode: a spent token warns and falls through to the picker", async () => { mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); @@ -1604,6 +1659,36 @@ describe("link", () => { expect(await breadcrumbExists()).toBe(true); }); + test.each([ + ["no token file", null], + ["a malformed token file", JSON.stringify({ unexpected: true })], + ])( + "the clerk link command with %s doesn't claim and follows today's flow", + async (_label, content) => { + const tokenPath = join(folder, ".clerk", "keyless.json"); + if (content === null) await rm(tokenPath); + else await Bun.write(tokenPath, content); + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + const cwdSpy = spyOn(process, "cwd").mockReturnValue(folder); + const program = new Command(); + registerLink(program as never); + + let error: unknown; + try { + await program.parseAsync(["link"], { from: "user" }); + } catch (caught) { + error = caught; + } finally { + cwdSpy.mockRestore(); + } + + expect(mockClaimApplication).not.toHaveBeenCalled(); + expect(mockAutolink).toHaveBeenCalledWith(folder); + expect((error as Error).message).toContain("Cannot select an application in agent mode"); + }, + ); + test("the clerk link command itself claims", async () => { mockIsAgent.mockReturnValue(true); mockGetToken.mockResolvedValue("token"); diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 5d8f8611c..5ea1bce66 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -25,11 +25,10 @@ import { attemptAutoclaim, CLAIM_OTHER_APP_HINT, claimWarning, - isTemporaryClaimError, + CLAIM_FAILURE_ADVICE, + claimFailureKind, keysMismatchMessage, - REJECTED_CLAIM_ADVICE, reportClaimed, - TEMPORARY_CLAIM_ADVICE, } from "../../lib/autoclaim.ts"; interface LinkOptions { @@ -224,13 +223,10 @@ async function claimKeylessApp(cwd: string, options: LinkOptions): Promise { expect(result.envPulled).toBe(true); } expect(linkAppSpy).toHaveBeenCalledWith(MOCK_APP, tempDir); - expect(pullSpy).toHaveBeenCalledWith({}); + expect(pullSpy).toHaveBeenCalledWith({ cwd: tempDir }); }); test("returns envPulled false when pull fails", async () => { @@ -279,6 +279,99 @@ describe("attemptAutoclaim", () => { expect(parsed.name).toBe(basename(tempDir)); }); describe("publishable keys already in the folder", () => { + test.skipIf(process.getuid?.() === 0)( + "a foreign key in a readable .env refuses even when .env.local can't be read", + async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + const envPath = join(tempDir, ".env"); + const localPath = join(tempDir, ".env.local"); + await Bun.write(envPath, "CLERK_PUBLISHABLE_KEY=pk_test_other\n"); + await Bun.write(localPath, "CLERK_PUBLISHABLE_KEY=pk_test_abc\n"); + await chmod(localPath, 0o000); + + let result: AutoclaimResult; + try { + result = await run(); + } finally { + await chmod(localPath, 0o600); + } + + expect(result).toEqual({ + status: "keys_mismatch", + app: MOCK_APP as never, + sources: [".env"], + }); + expect(linkAppSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + }, + ); + + describe("with the real link and env pull", () => { + // The claim response carries secret keys, so a real pull writes both keys. + const APP_WITH_KEYS = { + application_id: "app_claimed", + name: "My Claimed App", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_abc", + secret_key: "sk_test_abc", + }, + ], + }; + let configDir = ""; + let cwdSpy: ReturnType; + + beforeEach(async () => { + // Real `linkApp` saves a profile and real `pull` picks its target file and writes it. + linkAppSpy.mockRestore(); + pullSpy.mockRestore(); + // Anything that still reads the process's directory lands in the temp folder, not the repo. + cwdSpy = spyOn(process, "cwd").mockReturnValue(tempDir); + configDir = await mkdtemp(join(tmpdir(), "clerk-autoclaim-config-")); + _setConfigDir(configDir); + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(APP_WITH_KEYS), { status: 200 })); + }); + + afterEach(async () => { + cwdSpy.mockRestore(); + _setConfigDir(undefined); + await rm(configDir, { recursive: true, force: true }); + }); + + test("another app's keys in .env.development.local, the file env pull writes first, refuse the claim", async () => { + const localPath = join(tempDir, ".env.local"); + const devLocalPath = join(tempDir, ".env.development.local"); + const local = "CLERK_PUBLISHABLE_KEY=pk_test_abc\nCLERK_SECRET_KEY=sk_test_abc\n"; + const devLocal = "CLERK_PUBLISHABLE_KEY=pk_test_bbb\nCLERK_SECRET_KEY=sk_test_bbb\n"; + await Bun.write(localPath, local); + await Bun.write(devLocalPath, devLocal); + + const result = await attemptAutoclaim(tempDir, { checkKeys: true }); + + expect(result).toEqual({ + status: "keys_mismatch", + app: APP_WITH_KEYS as never, + sources: [".env.development.local"], + }); + expect(await Bun.file(localPath).text()).toBe(local); + expect(await Bun.file(devLocalPath).text()).toBe(devLocal); + }); + + test("the claimed app's own key in .env.development.local links, and the pull writes that file", async () => { + const devLocalPath = join(tempDir, ".env.development.local"); + await Bun.write(devLocalPath, "CLERK_PUBLISHABLE_KEY=pk_test_abc\n"); + + const result = await attemptAutoclaim(tempDir, { checkKeys: true }); + + expect(result).toMatchObject({ status: "claimed", linked: true, envPulled: true }); + expect(await Bun.file(devLocalPath).text()).toContain("CLERK_SECRET_KEY=sk_test_abc"); + }); + }); + test("a key for another app stops before linking or pulling, and the env file is unchanged", async () => { withBreadcrumb(); stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); @@ -324,7 +417,7 @@ describe("attemptAutoclaim", () => { expect(result.status).toBe("claimed"); expect(linkAppSpy).toHaveBeenCalledWith(MOCK_APP, tempDir); - expect(pullSpy).toHaveBeenCalledWith({}); + expect(pullSpy).toHaveBeenCalledWith({ cwd: tempDir }); }); /** Claims with `.env` made unreadable for the duration; returns the result and the file's content. */ @@ -388,7 +481,7 @@ describe("attemptAutoclaim", () => { expect(result.status).toBe("claimed"); expect(linkAppSpy).toHaveBeenCalledWith(MOCK_APP, tempDir); - expect(pullSpy).toHaveBeenCalledWith({}); + expect(pullSpy).toHaveBeenCalledWith({ cwd: tempDir }); }); }); }); diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index 3678eda14..73de3d29c 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -1,8 +1,9 @@ -import { relative } from "node:path"; +import { join } from "node:path"; import { readKeylessBreadcrumb, clearKeylessBreadcrumb } from "./keyless.ts"; import { claimApplication, type Application } from "./plapi.ts"; import { ApiError, PlapiError, errorMessage } from "./errors.ts"; -import { findClerkKeys, linkApp } from "./autolink.ts"; +import { linkApp } from "./autolink.ts"; +import { ENV_FILE_CANDIDATES, parseEnvFile } from "./dotenv.ts"; import { deriveProjectName } from "./project-name.ts"; import { buildDashboardUrl } from "./environment.ts"; import { detectPublishableKeyName } from "./framework.ts"; @@ -89,7 +90,7 @@ export async function attemptAutoclaim( if ("unreadable" in check) { return { ...result, ...link, envPulled: false, unreadableEnv: check.unreadable }; } - const envPulled = link.linked && (await tryPullEnv()); + const envPulled = link.linked && (await tryPullEnv(cwd)); return { ...result, ...link, envPulled }; } @@ -97,8 +98,10 @@ export async function attemptAutoclaim( } /** - * Where the folder holds a publishable key for another app, or the env file that couldn't be - * read. An unreadable file can hide a mismatch, so the caller then writes no keys. + * Where the folder holds a publishable key for another app, or the first env file that + * couldn't be read. Covers the exported variable and every file `clerk env pull` can write + * (`ENV_FILE_CANDIDATES`), each read on its own: any mismatch refuses, and an unreadable file + * only matters when no source mismatches, because it could hide one. */ async function checkLocalKeys( app: Application, @@ -109,19 +112,32 @@ async function checkLocalKeys( (instance) => instance.environment_type === "development", )?.publishable_key; if (!expected) return { sources: [] }; - try { - const keys = await findClerkKeys(cwd); - return { sources: keys.filter((found) => found.key !== expected).map((found) => found.source) }; - } catch (error) { - log.debug(`autoclaim: could not read local publishable keys: ${errorMessage(error)}`); - // The failed read discards keys already found; the exported one needs no file. - const name = await detectPublishableKeyName(cwd); - const exported = process.env[name]; - if (exported && exported !== expected) return { sources: [`${name} env var`] }; - const path = (error as { path?: unknown }).path; - const file = typeof path === "string" ? relative(cwd, path) || path : "an env file"; - return { unreadable: { file, reason: errorMessage(error) } }; + + const name = await detectPublishableKeyName(cwd); + const sources: string[] = []; + let unreadable: UnreadableEnv | undefined; + + const exported = process.env[name]; + if (exported && exported !== expected) sources.push(`${name} env var`); + + for (const file of ENV_FILE_CANDIDATES) { + try { + const handle = Bun.file(join(cwd, file)); + if (!(await handle.exists())) continue; + const foreign = parseEnvFile(await handle.text()).some( + (line) => + line.type === "entry" && line.key === name && line.value && line.value !== expected, + ); + if (foreign) sources.push(file); + } catch (error) { + log.debug(`autoclaim: could not read ${file}: ${errorMessage(error)}`); + unreadable ??= { file, reason: errorMessage(error) }; + } } + + if (sources.length > 0) return { sources }; + if (unreadable) return { unreadable }; + return { sources: [] }; } async function tryClaim(claimToken: string, name: string): Promise { @@ -149,9 +165,10 @@ async function tryLinkApp( } } -async function tryPullEnv(): Promise { +// Pulls into `cwd`, the folder the key check read, not the process's working directory. +async function tryPullEnv(cwd: string): Promise { try { - await pull({}); + await pull({ cwd }); return true; } catch (error) { log.debug(`Auto env pull failed: ${errorMessage(error)}`); @@ -189,19 +206,33 @@ const CLAIM_WARNINGS: Partial> = { export const CLAIM_OTHER_APP_HINT = "or run `clerk link --app ` to link a different application."; -/** What `clerk link` and `clerk auth login` advise when a claim failed and kept its token. */ -export const TEMPORARY_CLAIM_ADVICE = - "Couldn't claim the application `clerk init` created because of a temporary error. " + - `Rerun \`clerk link\` to retry, ${CLAIM_OTHER_APP_HINT}`; -export const REJECTED_CLAIM_ADVICE = - "Clerk rejected the claim of the application `clerk init` created. " + - "Run `clerk link --app ` with the application this folder should use."; +/** + * Why a claim failed and kept its token. A 5xx, a 429 or a network failure may pass on a + * retry. A 401 means the saved sign-in was rejected, so `--app` would fail the same way. Any + * other 4xx repeats on every run. + */ +export type ClaimFailureKind = "temporary" | "sign_in" | "rejected"; -/** A 5xx, a 429 or a network failure may pass on a retry; any other 4xx repeats on every run. */ -export function isTemporaryClaimError(error: Error): boolean { - return !(error instanceof ApiError) || error.status >= 500 || error.status === 429; +export function claimFailureKind(error: Error): ClaimFailureKind { + if (!(error instanceof ApiError) || error.status >= 500 || error.status === 429) { + return "temporary"; + } + return error.status === 401 ? "sign_in" : "rejected"; } +/** What `clerk link` and `clerk auth login` advise for each kind of failed claim. */ +export const CLAIM_FAILURE_ADVICE: Record = { + temporary: + "Couldn't claim the application `clerk init` created because of a temporary error. " + + `Rerun \`clerk link\` to retry, ${CLAIM_OTHER_APP_HINT}`, + sign_in: + "Clerk rejected the saved sign-in while claiming the application `clerk init` created. " + + "Run `clerk auth login` to sign in again, then rerun `clerk link`.", + rejected: + "Clerk rejected the claim of the application `clerk init` created. " + + "Run `clerk link --app ` with the application this folder should use.", +}; + /** The warning `clerk auth login` and `clerk link` print for a claim that didn't link the folder. */ export function claimWarning(result: AutoclaimResult): string | undefined { if (result.status === "managed_workspace") { @@ -211,9 +242,10 @@ export function claimWarning(result: AutoclaimResult): string | undefined { if (result.status === "keys_mismatch") return keysMismatchMessage(result); if (result.status === "failed") { const { error } = result; - if (isTemporaryClaimError(error)) return TEMPORARY_CLAIM_ADVICE; + const kind = claimFailureKind(error); + if (kind !== "rejected") return CLAIM_FAILURE_ADVICE[kind]; const status = error instanceof ApiError ? ` (${error.status})` : ""; - return `${REJECTED_CLAIM_ADVICE} Clerk's reason${status}: ${error.message}`; + return `${CLAIM_FAILURE_ADVICE.rejected} Clerk's reason${status}: ${error.message}`; } return CLAIM_WARNINGS[result.status]; } diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index bffbb6910..2c895a429 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -31,6 +31,11 @@ export const NEXT_STEPS = { ], /** After a 4xx claim failure, which a retry would only repeat. */ AUTOCLAIM_REJECTED: ["Run `clerk link --app ` to link a different application"], + /** After a 401 claim failure: the saved sign-in was rejected, so `--app` would fail too. */ + AUTOCLAIM_SIGN_IN: [ + "Run `clerk auth login` to sign in again", + "Run `clerk link` to claim the application", + ], AUTOCLAIM_SWITCH_WORKSPACE: [ "Select a different workspace in the Clerk Dashboard", "Run `clerk auth login` again to proceed", From 837f811a99305f0cb84ef564846a17efc26004c4 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Mon, 2 Nov 2026 17:01:42 -0600 Subject: [PATCH 11/15] Tighten claim failure advice and key reading - An expired saved session during the claim was classed as a temporary error, so `clerk link` printed "Rerun `clerk link` to retry" next to the session error saying to sign in. It now counts as a rejected sign-in: `clerk link` throws the session error alone, and `clerk auth login` gives the sign-in advice. - With `CLERK_PLATFORM_API_KEY` set, the Platform API client uses that key before any saved sign-in, so a 401 means the key was rejected. Both commands now say to fix or unset it instead of signing in again, and login's next steps match. - Any other error the CLI raises itself (e.g. `invalid_key_format`) is no longer called temporary. `clerk link` rethrows it unchanged; `clerk auth login` warns with its message and shows the fix-the-key step only when `CLERK_PLATFORM_API_KEY` is set, and no next steps otherwise. "Temporary" now covers only network failures, 5xx and 429. - The key check matched key lines with the shared env parser, which reads a CRLF line, an indented line or spaces around `=` as a comment, so another app's key on such a line was never seen. It now converts line endings, matches key lines with its own pattern, and compares values trimmed, unquoted and without an inline comment. The shared parser is unchanged. - The reader's comment and the READMEs say why `.env.development` is checked: `clerk env pull` never writes it, but it outranks `.env` in development. - `login.test.ts` clears and restores `CLERK_PLATFORM_API_KEY` around each test. --- packages/cli-core/src/commands/auth/README.md | 4 +- .../cli-core/src/commands/auth/login.test.ts | 102 +++++++++++++++++- packages/cli-core/src/commands/auth/login.ts | 8 +- packages/cli-core/src/commands/link/README.md | 5 +- .../cli-core/src/commands/link/index.test.ts | 61 ++++++++++- packages/cli-core/src/commands/link/index.ts | 14 +-- packages/cli-core/src/lib/autoclaim.test.ts | 100 +++++++++++++++++ packages/cli-core/src/lib/autoclaim.ts | 66 +++++++++--- packages/cli-core/src/lib/next-steps.ts | 4 + 9 files changed, 336 insertions(+), 28 deletions(-) diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index 4010a0fae..7b1ffaf68 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -26,8 +26,8 @@ When `clerk init` runs in accountless mode it writes `.clerk/keyless.json` conta - **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** (a Platform API error or a network failure): breadcrumb is preserved, so the next `clerk link` or `clerk auth login` claims again. A 5xx, a 429 or a network failure may pass on that retry, so the warning says to rerun `clerk link`. A 401 means Clerk rejected the saved sign-in, so the warning says to run `clerk auth login` to sign in again and then rerun `clerk link`. Any other 4xx will fail the same way on every retry, so the warning says Clerk rejected the claim, with the status and reason, and to run `clerk link --app ` with the application the folder should use. `clerk auth login` prints that advice as a warning and exits 0; `clerk link` stops with the original error and the same advice, and also stops for a managed workspace, because its app list can't show the application the token would claim. -- **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), either exported or in any env file `clerk env pull` can write (`.env.development.local`, `.env.local`, `.env.development`, `.env`). Each file is read on its own. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link` check keys; `clerk init`, `clerk migrate`, and the `clerk doctor` fix sign in through `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. +- **Any other error** (a Platform API error or a network failure): breadcrumb is preserved, so the next `clerk link` or `clerk auth login` claims again. A 5xx, a 429 or a network failure may pass on that retry, so the warning says to rerun `clerk link`. A 401, or a saved session that can no longer be refreshed, means Clerk rejected the saved sign-in, so the warning says to run `clerk auth login` to sign in again and then rerun `clerk link`. With `CLERK_PLATFORM_API_KEY` set, the CLI uses that key before any saved sign-in, so a 401 means the key was rejected and the warning says to fix or unset it. Any other 4xx will fail the same way on every retry, so the warning says Clerk rejected the claim, with the status and reason, and to run `clerk link --app ` with the application the folder should use. `clerk auth login` prints that advice as a warning and exits 0; `clerk link` stops with the original error and the same advice, and also stops for a managed workspace, because its app list can't show the application the token would claim. +- **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), either exported or in a development env file (`.env.development.local`, `.env.local`, `.env.development`, `.env`). `clerk env pull` writes `.env.development.local`, `.env.local` or `.env`; `.env.development` is checked because it outranks `.env` in development, so a different key there would override the pulled one. Each file is read on its own, and values are compared as a framework reads them: trimmed, unquoted, and without an inline comment. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link` check keys; `clerk init`, `clerk migrate`, and the `clerk doctor` fix sign in through `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. - **Unreadable env file**: the claim succeeds. A different application's key in the exported variable or any readable file still stops it as above. Otherwise the folder is linked, but no keys are written, because the unread file could hold another application's key. A warning names the file and the reason and says to run `clerk env pull` once the file is fixed. - **Claimed but not linked**: saving the link failed, or the application has no development instance. The command prints "Claimed application: ..." and a warning with the reason, and says to run `clerk link` to finish setup. - **Success**: application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. The command prints the Dashboard URL of the claimed app's development instance, since this is the first time the app has a home in an account. diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index ff57bbc19..930105f9f 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, afterEach, beforeEach, mock, spyOn } from "bun:test"; -import { AuthError, ERROR_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; +import { AuthError, CliError, ERROR_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; import { useCaptureLog, credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts"; import type { AutoclaimResult } from "../../lib/autoclaim.ts"; import type { Application } from "../../lib/plapi.ts"; @@ -122,7 +122,11 @@ describe("login", () => { const origSpawn = Bun.spawn; const origStdinIsTTY = process.stdin.isTTY; + // The claim's failure advice depends on it; a developer's own must not decide these tests. + const originalApiKey = process.env.CLERK_PLATFORM_API_KEY; + beforeEach(() => { + delete process.env.CLERK_PLATFORM_API_KEY; consoleSpy = spyOn(console, "log").mockImplementation(() => {}); // The paste-back prompt only appears on an interactive stdin. Pin it off // so the suite doesn't change behaviour when run from a terminal. @@ -130,6 +134,8 @@ describe("login", () => { }); afterEach(() => { + if (originalApiKey === undefined) delete process.env.CLERK_PLATFORM_API_KEY; + else process.env.CLERK_PLATFORM_API_KEY = originalApiKey; mockGetValidToken.mockReset(); mockStoreToken.mockReset(); mockCreateOAuthSession.mockReset(); @@ -1015,6 +1021,100 @@ describe("login", () => { expect(captured.err).not.toContain("--app"); }); + test("a 401 with CLERK_PLATFORM_API_KEY set says to fix or unset the key, not to sign in", async () => { + const original = process.env.CLERK_PLATFORM_API_KEY; + process.env.CLERK_PLATFORM_API_KEY = "ak_test"; + mockIsHuman.mockReturnValue(true); + mockGetValidToken.mockResolvedValue(null); + mockOAuthSuccess(); + mockResolveProfile.mockResolvedValue(undefined); + mockAttemptAutoclaim.mockResolvedValue({ + status: "failed", + error: new PlapiError(401, "Unauthorized"), + }); + + try { + await runLogin(); + } finally { + if (original === undefined) delete process.env.CLERK_PLATFORM_API_KEY; + else process.env.CLERK_PLATFORM_API_KEY = original; + } + + expect(captured.err).toContain( + "Clerk rejected `CLERK_PLATFORM_API_KEY` while claiming the application `clerk init` created. " + + "Fix or unset it, then rerun `clerk link`.", + ); + expect(captured.err).toContain( + "Fix or unset `CLERK_PLATFORM_API_KEY`, then run `clerk link` to claim the application", + ); + expect(captured.err).not.toContain("sign in again"); + }); + + test("a CLI error with CLERK_PLATFORM_API_KEY set warns with its own message, and the next step is the key", async () => { + process.env.CLERK_PLATFORM_API_KEY = "sk_test_wrong_kind"; + mockIsHuman.mockReturnValue(true); + mockGetValidToken.mockResolvedValue(null); + mockOAuthSuccess(); + mockResolveProfile.mockResolvedValue(undefined); + mockAttemptAutoclaim.mockResolvedValue({ + status: "failed", + error: new CliError("Expected a Platform API key (ak_...), found a secret key (sk_...).", { + code: ERROR_CODE.INVALID_KEY_FORMAT, + }), + }); + + await runLogin(); + + expect(captured.err).toContain( + "Expected a Platform API key (ak_...), found a secret key (sk_...).", + ); + expect(captured.err).not.toContain("temporary error"); + expect(captured.err).not.toContain("Run `clerk auth login` again to retry auto-claim"); + expect(captured.err).toContain( + "Fix or unset `CLERK_PLATFORM_API_KEY`, then run `clerk link` to claim the application", + ); + expect(captured.err).not.toContain("clerk link --app"); + }); + + test("a CLI error without CLERK_PLATFORM_API_KEY set shows no next steps beyond its message", async () => { + mockIsHuman.mockReturnValue(true); + mockGetValidToken.mockResolvedValue(null); + mockOAuthSuccess(); + mockResolveProfile.mockResolvedValue(undefined); + mockAttemptAutoclaim.mockResolvedValue({ + status: "failed", + error: new CliError( + "Expected a secret key starting with `sk_` in .env, found something else.", + { + code: ERROR_CODE.INVALID_KEY_FORMAT, + }, + ), + }); + + await runLogin(); + + expect(captured.err).toContain("found something else."); + expect(captured.err).not.toContain("Next steps"); + expect(captured.err).not.toContain("clerk link --app"); + }); + + test("an expired session during the claim gives the sign-in advice", async () => { + mockGetValidToken.mockResolvedValue("existing-token"); + mockGetAuth.mockResolvedValue({ userId: "user_123" }); + mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "existing@example.com" }); + mockAttemptAutoclaim.mockResolvedValue({ + status: "failed", + error: new AuthError({ reason: "session_expired" }), + }); + + await runLogin(); + + expect(captured.err).toContain( + "Run `clerk auth login` to sign in again, then rerun `clerk link`.", + ); + expect(captured.err).not.toContain("temporary error"); + }); + test("falls back to a generic provider when the managed-workspace rejection has no long message", async () => { mockGetValidToken.mockResolvedValue("existing-token"); mockGetAuth.mockResolvedValue({ userId: "user_123" }); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index e95f2b318..9e7952023 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -308,7 +308,7 @@ async function handleAutoclaim(cwd: string, checkKeys: boolean): Promise { +async function loginNextSteps(result: AutoclaimResult): Promise { if (result.status === "claimed" && !result.linked) return NEXT_STEPS.AUTOCLAIM_MANUAL_LINK; if (result.status === "claimed") { return result.envPulled ? NEXT_STEPS.AUTOCLAIMED : NEXT_STEPS.AUTOCLAIMED_NO_ENV; @@ -316,6 +316,12 @@ async function loginNextSteps(result: AutoclaimResult): Promise` with the application the folder should use 6. If an env file can't be read and no other source holds a different application's key, links the folder but writes no keys, and warns with the file and the reason. Run `clerk env pull` once the file is fixed. A mismatch in any readable source still stops as in step 5 @@ -52,7 +52,8 @@ and no `--app` is given: 8. If the claim fails, keeps `.clerk/keyless.json` and exits with the original error. A Platform API error keeps its own code, and the advice is the start of its message (" (): "): - A 5xx or 429: the error is temporary; rerun `clerk link` to retry, or run `clerk link --app ` to link a different application - A managed workspace (403 `accountless_application_managed_workspace`): switch to a workspace you own and rerun `clerk link`, or use `--app` - - A 401: Clerk rejected the saved sign-in; run `clerk auth login` to sign in again, then rerun `clerk link` + - A 401, or a saved session that can no longer be refreshed: Clerk rejected the saved sign-in; run `clerk auth login` to sign in again, then rerun `clerk link`. An expired session's own error says this, so no separate warning is printed + - A 401 while `CLERK_PLATFORM_API_KEY` is set: Clerk rejected that key, which the CLI uses before any saved sign-in; fix or unset it, then rerun `clerk link` - Any other 4xx: Clerk rejected the claim; run `clerk link --app ` with the application the folder should use - A network failure: the error is reported as is, after a warning that it is temporary and to rerun `clerk link` or use `--app` 9. If the token is spent (expired, or the account has no organization), prints the same warning `clerk auth login` prints and continues with the normal flow below diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index 2fa27ef03..0c3eab519 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -11,7 +11,7 @@ import { gitStubs, listageStubs, } from "../../test/lib/stubs.ts"; -import { CliError, ERROR_CODE, EXIT_CODE, PlapiError } from "../../lib/errors.ts"; +import { AuthError, CliError, ERROR_CODE, EXIT_CODE, PlapiError } from "../../lib/errors.ts"; import "../../lib/help.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; @@ -1485,6 +1485,65 @@ describe("link", () => { expect(await breadcrumbExists()).toBe(true); }); + test("an expired sign-in stops with the session error alone, not a temporary-error warning", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + const expired = new AuthError({ reason: "session_expired" }); + mockClaimApplication.mockRejectedValue(expired); + + const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBe(expired); + expect((error as AuthError).message).toContain("clerk auth login"); + expect(captured.err).not.toContain("temporary error"); + expect(captured.err).not.toContain("Rerun `clerk link` to retry"); + expect(mockAutolink).not.toHaveBeenCalled(); + expect(await breadcrumbExists()).toBe(true); + }); + + test("a CLI error such as invalid_key_format stops with that error alone, not as temporary", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + const badKey = new CliError( + "Expected a Platform API key (ak_...), found a secret key (sk_...).", + { + code: ERROR_CODE.INVALID_KEY_FORMAT, + }, + ); + mockClaimApplication.mockRejectedValue(badKey); + + const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBe(badKey); + expect((error as CliError).code).toBe(ERROR_CODE.INVALID_KEY_FORMAT); + expect(captured.err).not.toContain("temporary"); + expect(captured.err).not.toContain("Rerun `clerk link` to retry"); + expect(mockAutolink).not.toHaveBeenCalled(); + expect(await breadcrumbExists()).toBe(true); + }); + + test("agent mode: a 401 with CLERK_PLATFORM_API_KEY set says the key was rejected, not the sign-in", async () => { + process.env.CLERK_PLATFORM_API_KEY = "ak_test"; + mockIsAgent.mockReturnValue(true); + mockClaimApplication.mockRejectedValue(new PlapiError(401, "Unauthorized")); + + const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(PlapiError); + expect((error as PlapiError).context).toBe( + "Clerk rejected `CLERK_PLATFORM_API_KEY` while claiming the application `clerk init` created. " + + "Fix or unset it, then rerun `clerk link`", + ); + expect((error as PlapiError).context).not.toContain("clerk auth login"); + expect(await breadcrumbExists()).toBe(true); + }); + test("human mode: no organization warns and falls through to the picker", async () => { mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 5ea1bce66..1a069b18b 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -13,6 +13,7 @@ import { dim, cyan } from "../../lib/color.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { ApiError, + AuthError, CliError, ERROR_CODE, throwUsageError, @@ -221,12 +222,13 @@ async function claimKeylessApp(cwd: string, options: LinkOptions): Promise { }, ); + test.each([ + ["Windows line endings", "CLERK_PUBLISHABLE_KEY=pk_test_abc\r\nOTHER=1\r\n"], + ["a quoted value with Windows line endings", 'CLERK_PUBLISHABLE_KEY="pk_test_abc"\r\n'], + ["an inline comment", "CLERK_PUBLISHABLE_KEY=pk_test_abc # dev key\n"], + ["trailing spaces", "CLERK_PUBLISHABLE_KEY=pk_test_abc \n"], + ])("the claimed app's own key with %s links", async (_label, content) => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + await Bun.write(join(tempDir, ".env.local"), content); + + const result = await run(); + + expect(result.status).toBe("claimed"); + expect(linkAppSpy).toHaveBeenCalledWith(MOCK_APP, tempDir); + }); + + test("another app's key in a file with Windows line endings refuses", async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + const localPath = join(tempDir, ".env.local"); + const before = "CLERK_PUBLISHABLE_KEY=pk_test_other\r\nCLERK_SECRET_KEY=sk_test_other\r\n"; + await Bun.write(localPath, before); + + const result = await run(); + + expect(result).toEqual({ + status: "keys_mismatch", + app: MOCK_APP as never, + sources: [".env.local"], + }); + expect(linkAppSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + expect(await Bun.file(localPath).text()).toBe(before); + }); + + test.each([ + ["spaces around =", " CLERK_PUBLISHABLE_KEY = pk_test_other\n"], + ["an indented export", " export CLERK_PUBLISHABLE_KEY=pk_test_other\n"], + ])("another app's key on a line with %s refuses", async (_label, content) => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + const localPath = join(tempDir, ".env.local"); + await Bun.write(localPath, content); + + const result = await run(); + + expect(result).toEqual({ + status: "keys_mismatch", + app: MOCK_APP as never, + sources: [".env.local"], + }); + expect(linkAppSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + expect(await Bun.file(localPath).text()).toBe(content); + }); + + test("a commented-out line with another app's key doesn't refuse", async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + await Bun.write( + join(tempDir, ".env.local"), + "# CLERK_PUBLISHABLE_KEY=pk_test_other\nCLERK_PUBLISHABLE_KEY=pk_test_abc\n", + ); + + const result = await run(); + + expect(result.status).toBe("claimed"); + }); + + test.skipIf(process.getuid?.() === 0)( + "mismatches before and after an unreadable file are all named, and nothing is linked", + async () => { + withBreadcrumb(); + stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); + const localPath = join(tempDir, ".env.local"); + await Bun.write( + join(tempDir, ".env.development.local"), + "CLERK_PUBLISHABLE_KEY=pk_test_b\n", + ); + await Bun.write(localPath, "CLERK_PUBLISHABLE_KEY=pk_test_abc\n"); + await Bun.write(join(tempDir, ".env"), "CLERK_PUBLISHABLE_KEY=pk_test_c\n"); + await chmod(localPath, 0o000); + + let result: AutoclaimResult; + try { + result = await run(); + } finally { + await chmod(localPath, 0o600); + } + + expect(result).toEqual({ + status: "keys_mismatch", + app: MOCK_APP as never, + sources: [".env.development.local", ".env"], + }); + expect(linkAppSpy).not.toHaveBeenCalled(); + expect(pullSpy).not.toHaveBeenCalled(); + }, + ); + describe("with the real link and env pull", () => { // The claim response carries secret keys, so a real pull writes both keys. const APP_WITH_KEYS = { diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index 73de3d29c..4b716d0fd 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -1,9 +1,9 @@ import { join } from "node:path"; import { readKeylessBreadcrumb, clearKeylessBreadcrumb } from "./keyless.ts"; import { claimApplication, type Application } from "./plapi.ts"; -import { ApiError, PlapiError, errorMessage } from "./errors.ts"; +import { ApiError, AuthError, CliError, PlapiError, errorMessage } from "./errors.ts"; import { linkApp } from "./autolink.ts"; -import { ENV_FILE_CANDIDATES, parseEnvFile } from "./dotenv.ts"; +import { ENV_FILE_CANDIDATES } from "./dotenv.ts"; import { deriveProjectName } from "./project-name.ts"; import { buildDashboardUrl } from "./environment.ts"; import { detectPublishableKeyName } from "./framework.ts"; @@ -99,8 +99,10 @@ export async function attemptAutoclaim( /** * Where the folder holds a publishable key for another app, or the first env file that - * couldn't be read. Covers the exported variable and every file `clerk env pull` can write - * (`ENV_FILE_CANDIDATES`), each read on its own: any mismatch refuses, and an unreadable file + * couldn't be read. Covers the exported variable and the development env files + * (`ENV_FILE_CANDIDATES`): `clerk env pull` writes `.env.development.local`, `.env.local` or + * `.env`, and `.env.development` outranks `.env` in development, so a key there would override + * the pulled one. Each file is read on its own: any mismatch refuses, and an unreadable file * only matters when no source mismatches, because it could hide one. */ async function checkLocalKeys( @@ -117,17 +119,25 @@ async function checkLocalKeys( const sources: string[] = []; let unreadable: UnreadableEnv | undefined; - const exported = process.env[name]; + const exported = normalizeKey(process.env[name] ?? ""); if (exported && exported !== expected) sources.push(`${name} env var`); + // Its own pattern, not `parseEnvFile`, which reads a CRLF line, an indented line or spaces + // around `=` as a comment; frameworks read all three as entries. `name` is an identifier, + // so it needs no escaping. + const entry = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*(.*)$`); + for (const file of ENV_FILE_CANDIDATES) { try { const handle = Bun.file(join(cwd, file)); if (!(await handle.exists())) continue; - const foreign = parseEnvFile(await handle.text()).some( - (line) => - line.type === "entry" && line.key === name && line.value && line.value !== expected, - ); + const lines = (await handle.text()).replace(/\r\n?/g, "\n").split("\n"); + const foreign = lines.some((line) => { + const match = entry.exec(line); + if (!match) return false; + const value = normalizeKey(match[1] ?? ""); + return value !== "" && value !== expected; + }); if (foreign) sources.push(file); } catch (error) { log.debug(`autoclaim: could not read ${file}: ${errorMessage(error)}`); @@ -140,6 +150,19 @@ async function checkLocalKeys( return { sources: [] }; } +/** + * The key as a framework reads it: cutting at the first whitespace drops trailing spaces and an + * inline `# comment`, safe because publishable keys contain no spaces; then surrounding quotes go. + */ +function normalizeKey(value: string): string { + const token = value.trim().split(/\s/)[0] ?? ""; + const quote = token[0]; + if ((quote === '"' || quote === "'") && token.length >= 2 && token.at(-1) === quote) { + return token.slice(1, -1); + } + return token; +} + async function tryClaim(claimToken: string, name: string): Promise { try { const app = await claimApplication(claimToken, name); @@ -208,26 +231,38 @@ export const CLAIM_OTHER_APP_HINT = /** * Why a claim failed and kept its token. A 5xx, a 429 or a network failure may pass on a - * retry. A 401 means the saved sign-in was rejected, so `--app` would fail the same way. Any - * other 4xx repeats on every run. + * retry. An `AuthError` (a session that can't be refreshed) or a 401 means the credentials + * were rejected, so `--app` would fail the same way; the Platform API client prefers + * `CLERK_PLATFORM_API_KEY`, so with it set a 401 rejects the key, not the saved sign-in. Any + * other `CliError` (e.g. `invalid_key_format`) is the CLI's own check and repeats on every + * run, and its message already says what to fix. Any other 4xx repeats on every run. */ -export type ClaimFailureKind = "temporary" | "sign_in" | "rejected"; +export type ClaimFailureKind = "temporary" | "sign_in" | "api_key" | "cli" | "rejected"; export function claimFailureKind(error: Error): ClaimFailureKind { + if (error instanceof AuthError) return "sign_in"; + if (error instanceof CliError) return "cli"; if (!(error instanceof ApiError) || error.status >= 500 || error.status === 429) { return "temporary"; } - return error.status === 401 ? "sign_in" : "rejected"; + if (error.status === 401) return process.env.CLERK_PLATFORM_API_KEY ? "api_key" : "sign_in"; + return "rejected"; } -/** What `clerk link` and `clerk auth login` advise for each kind of failed claim. */ -export const CLAIM_FAILURE_ADVICE: Record = { +/** + * What `clerk link` and `clerk auth login` advise for each kind of failed claim. A `cli` + * failure has none: its own message is the advice. + */ +export const CLAIM_FAILURE_ADVICE: Record, string> = { temporary: "Couldn't claim the application `clerk init` created because of a temporary error. " + `Rerun \`clerk link\` to retry, ${CLAIM_OTHER_APP_HINT}`, sign_in: "Clerk rejected the saved sign-in while claiming the application `clerk init` created. " + "Run `clerk auth login` to sign in again, then rerun `clerk link`.", + api_key: + "Clerk rejected `CLERK_PLATFORM_API_KEY` while claiming the application `clerk init` created. " + + "Fix or unset it, then rerun `clerk link`.", rejected: "Clerk rejected the claim of the application `clerk init` created. " + "Run `clerk link --app ` with the application this folder should use.", @@ -243,6 +278,7 @@ export function claimWarning(result: AutoclaimResult): string | undefined { if (result.status === "failed") { const { error } = result; const kind = claimFailureKind(error); + if (kind === "cli") return error.message; if (kind !== "rejected") return CLAIM_FAILURE_ADVICE[kind]; const status = error instanceof ApiError ? ` (${error.status})` : ""; return `${CLAIM_FAILURE_ADVICE.rejected} Clerk's reason${status}: ${error.message}`; diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index 2c895a429..588f2bc9c 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -36,6 +36,10 @@ export const NEXT_STEPS = { "Run `clerk auth login` to sign in again", "Run `clerk link` to claim the application", ], + /** After a 401 while `CLERK_PLATFORM_API_KEY` is set: the key, not the sign-in, was rejected. */ + AUTOCLAIM_API_KEY: [ + "Fix or unset `CLERK_PLATFORM_API_KEY`, then run `clerk link` to claim the application", + ], AUTOCLAIM_SWITCH_WORKSPACE: [ "Select a different workspace in the Clerk Dashboard", "Run `clerk auth login` again to proceed", From e4a750e0ae4286936eb90711f0be0020c9c3f01d Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Tue, 3 Nov 2026 01:04:02 -0600 Subject: [PATCH 12/15] Pass the SDK claim link to open as its href - `readSdkClaimUrl` checked that the SDK's claim link parsed as https but returned the original string. On Windows, `clerk open` launches the browser with `cmd /c start "" ""`, so an https link containing a double quote, such as `https://a.com/" & calc & "`, passed the check and its quote ended the argument. - It now returns the parsed URL's `href`, which percent-encodes quotes and spaces, so no raw quote reaches `cmd.exe`. A normal claim link such as `https://dashboard.clerk.com/apps/claim?token=tok_sdk` is unchanged. - Adds a test that an https claim link with an embedded quote and spaces comes back percent-encoded. --- .../src/commands/open/keyless-claim.test.ts | 12 ++++++++++++ packages/cli-core/src/lib/keyless.ts | 15 +++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/cli-core/src/commands/open/keyless-claim.test.ts b/packages/cli-core/src/commands/open/keyless-claim.test.ts index 40a1edd00..0ba5b91ee 100644 --- a/packages/cli-core/src/commands/open/keyless-claim.test.ts +++ b/packages/cli-core/src/commands/open/keyless-claim.test.ts @@ -106,6 +106,18 @@ describe("findKeylessClaimUrl", () => { expect(await findKeylessClaimUrl(projectDir)).toBeUndefined(); }); + test("percent-encodes a quote and spaces in an https claimUrl, so no raw quote reaches cmd.exe", async () => { + await mkdir(join(projectDir, ".clerk", ".tmp"), { recursive: true }); + await writeFile( + join(projectDir, ".clerk", ".tmp", "keyless.json"), + JSON.stringify({ claimUrl: 'https://a.com/" & calc & "' }), + ); + + const destination = await findKeylessClaimUrl(projectDir); + expect(destination?.url).toBe("https://a.com/%22%20&%20calc%20&%20%22"); + expect(destination?.url).not.toContain('"'); + }); + test("URL-encodes the breadcrumb claim token", async () => { await mkdir(join(projectDir, ".clerk"), { recursive: true }); await writeFile( diff --git a/packages/cli-core/src/lib/keyless.ts b/packages/cli-core/src/lib/keyless.ts index e95a6bf29..368bf481d 100644 --- a/packages/cli-core/src/lib/keyless.ts +++ b/packages/cli-core/src/lib/keyless.ts @@ -128,21 +128,20 @@ export async function readSdkClaimUrl(cwd: string): Promise const claimUrl = typeof parsed.claimUrl === "string" ? parsed.claimUrl : undefined; if (!claimUrl) return undefined; - // Written by another process, so untrusted input: anything but well-formed - // https is dropped rather than handed to the browser launcher, which on - // Windows goes through `cmd /c start "" ""`, where a `file:`/ - // `javascript:` scheme or an embedded quote stops being just a URL. - let isHttps = false; + // Written by another process, so untrusted input. The browser launcher on Windows runs + // `cmd /c start "" ""`, so only https goes through, and only as the parsed URL's + // `href`, which percent-encodes quotes and spaces: no raw `"` can end the argument. + let url: URL | undefined; try { - isHttps = new URL(claimUrl).protocol === "https:"; + url = new URL(claimUrl); } catch { // fall through: not a URL at all } - if (!isHttps) { + if (url?.protocol !== "https:") { log.debug(`keyless: ignoring non-https claimUrl in ${SDK_KEYLESS_FILE.join("/")}`); return undefined; } - return claimUrl; + return url.href; } catch { // A half-written file during an SDK refresh isn't worth failing over. return undefined; From 7ad96cb269bec4bc178c815475cb4b69b4d6d79c Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Tue, 3 Nov 2026 09:52:02 -0600 Subject: [PATCH 13/15] Guard instance-less claims and fix mismatch steps - `clerk link` and `clerk auth login` no longer show a raw TypeError when a claim response has no `instances` array. `link()` reads the instances defensively, so `clerk link` ends in "Application has no development instance" (`instance_not_found`). The claim step checks for a development instance before calling `linkApp`, so `clerk auth login` says "This folder wasn't linked: the application has no development instance". The claim response is untrusted JSON, so a missing array must not fail a claim that already succeeded server-side. - After a key mismatch, `clerk auth login`'s only next step is "Run `clerk link --app ` with the application this folder should use", matching its warning. It used to suggest a bare `clerk link` and then `clerk env pull`. The folder isn't linked yet, so there is nothing to pull until `--app` picks the application, and a bare `clerk link` that picks the claimed app, followed by a pull, would write its keys over the other app's. - Corrects two comments that said `clerk link` can't link an unclaimed app. It claims an app a signed-out `clerk init` created; an SDK-created app still needs `clerk open`, which is why `whoami` keeps its keyless next steps. - Defines the SDK keyless file path once, in `lib/keyless.ts`. `lib/keyless-target.ts` imports it, so `clerk open`, the deploy advice, `env pull`, `whoami` and `doctor` can't drift apart if the SDK moves the file. - `ensureAuth` passes `claim` straight to `login()`. `runLogin` treats an undefined `claim` as `true`, so the ternary that dropped the key changed nothing. - Shortens the claim code's doc comments to the constraint each states. The reasoning moved here: - Why a claim failed decides its advice. An expired saved session or a 401 means the credentials were rejected. Any other `CliError` (e.g. `invalid_key_format`) is the CLI's own check, repeats on every run, and its message already says what to fix. Any other 4xx also repeats on every run. - The key check covers the exported variable and the development env files. `clerk env pull` writes `.env.development.local`, `.env.local` or `.env`. Each file is read on its own. - `clerk init`, `clerk migrate` and the `clerk doctor` fix sign in through `login()` without the key check, so they keep linking whatever they claim. They call `link()` without `claimKeylessApp`. A signed-out `clerk link --app` or relink also claims during sign-in, without the check. - For an integration-managed workspace, the user switches to a workspace they own and the next `clerk auth login` or `clerk link` retries. - `login.test.ts` sets the browser opener's default before each test as well as after, so the first test no longer depends on an earlier one. --- packages/cli-core/src/commands/auth/README.md | 2 +- .../cli-core/src/commands/auth/login.test.ts | 9 +++- packages/cli-core/src/commands/auth/login.ts | 12 ++--- .../cli-core/src/commands/link/index.test.ts | 20 +++++++++ packages/cli-core/src/commands/link/index.ts | 15 +++---- .../cli-core/src/commands/whoami/index.ts | 5 +-- packages/cli-core/src/lib/autoclaim.test.ts | 18 ++++++++ packages/cli-core/src/lib/autoclaim.ts | 45 +++++++++---------- packages/cli-core/src/lib/keyless-target.ts | 8 +--- packages/cli-core/src/lib/keyless.ts | 5 ++- packages/cli-core/src/lib/next-steps.ts | 8 +++- 11 files changed, 93 insertions(+), 54 deletions(-) diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index 7b1ffaf68..fc572d9e4 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -27,7 +27,7 @@ When `clerk init` runs in accountless mode it writes `.clerk/keyless.json` conta - **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** (a Platform API error or a network failure): breadcrumb is preserved, so the next `clerk link` or `clerk auth login` claims again. A 5xx, a 429 or a network failure may pass on that retry, so the warning says to rerun `clerk link`. A 401, or a saved session that can no longer be refreshed, means Clerk rejected the saved sign-in, so the warning says to run `clerk auth login` to sign in again and then rerun `clerk link`. With `CLERK_PLATFORM_API_KEY` set, the CLI uses that key before any saved sign-in, so a 401 means the key was rejected and the warning says to fix or unset it. Any other 4xx will fail the same way on every retry, so the warning says Clerk rejected the claim, with the status and reason, and to run `clerk link --app ` with the application the folder should use. `clerk auth login` prints that advice as a warning and exits 0; `clerk link` stops with the original error and the same advice, and also stops for a managed workspace, because its app list can't show the application the token would claim. -- **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), either exported or in a development env file (`.env.development.local`, `.env.local`, `.env.development`, `.env`). `clerk env pull` writes `.env.development.local`, `.env.local` or `.env`; `.env.development` is checked because it outranks `.env` in development, so a different key there would override the pulled one. Each file is read on its own, and values are compared as a framework reads them: trimmed, unquoted, and without an inline comment. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link` check keys; `clerk init`, `clerk migrate`, and the `clerk doctor` fix sign in through `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. +- **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), either exported or in a development env file (`.env.development.local`, `.env.local`, `.env.development`, `.env`). `clerk env pull` writes `.env.development.local`, `.env.local` or `.env`; `.env.development` is checked because it outranks `.env` in development, so a different key there would override the pulled one. Each file is read on its own, and values are compared as a framework reads them: trimmed, unquoted, and without an inline comment. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link` check keys; `clerk init`, `clerk migrate`, and the `clerk doctor` fix sign in through `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0; its next step is `clerk link --app `, with no `clerk env pull`. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. - **Unreadable env file**: the claim succeeds. A different application's key in the exported variable or any readable file still stops it as above. Otherwise the folder is linked, but no keys are written, because the unread file could hold another application's key. A warning names the file and the reason and says to run `clerk env pull` once the file is fixed. - **Claimed but not linked**: saving the link failed, or the application has no development instance. The command prints "Claimed application: ..." and a warning with the reason, and says to run `clerk link` to finish setup. - **Success**: application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. The command prints the Dashboard URL of the claimed app's development instance, since this is the first time the app has a home in an account. diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 930105f9f..46daa6eb1 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -131,6 +131,8 @@ describe("login", () => { // The paste-back prompt only appears on an interactive stdin. Pin it off // so the suite doesn't change behaviour when run from a terminal. process.stdin.isTTY = false; + // Also set after each test; here too, so the first test doesn't run without it. + mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "test" }); }); afterEach(() => { @@ -737,7 +739,12 @@ describe("login", () => { "Run `clerk link --app ` with the application this folder should use.", ); expect(captured.err).not.toContain("Claimed and linked application"); - expect(captured.err).toContain("Run `clerk link` to connect your Clerk application"); + // The next step repeats the warning's advice: if a person ran a bare `clerk link`, picked the + // claimed app in the picker and then ran `clerk env pull`, its keys would overwrite the other app's. + const advice = "Run `clerk link --app ` with the application this folder should use"; + expect(captured.err.split(advice).length - 1).toBe(2); + expect(captured.err).not.toContain("Run `clerk link` to connect your Clerk application"); + expect(captured.err).not.toContain("clerk env pull"); expect(process.exitCode).toBe(exitCodeBefore); }); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 9e7952023..c74f7c6f3 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -43,9 +43,8 @@ interface LoginOptions { */ claim?: boolean; /** - * Compare the folder's publishable keys with the claimed app's before linking it. Only the - * `clerk auth login` command sets this; `clerk init`, `clerk migrate` and `clerk doctor` sign in - * through `login()` and must keep linking what they claim. + * Refuse to link a claimed app when the folder holds another app's publishable key. Only the + * `clerk auth login` command sets it; `clerk init`, `clerk migrate` and `clerk doctor` must not. */ checkClaimKeys?: boolean; } @@ -325,11 +324,8 @@ async function loginNextSteps(result: AutoclaimResult): Promise { expect(mockSetProfile).not.toHaveBeenCalled(); }); + test("a claim response with no instances ends in the no-development-instance error", async () => { + mockIsAgent.mockReturnValue(true); + mockGetToken.mockResolvedValue("token"); + mockClaimApplication.mockResolvedValue({ + application_id: "app_claimed", + name: "My Init App", + }); + // The real `linkApp` throws a TypeError for an app without `instances`. + mockLinkApp.mockRejectedValue(new TypeError("linkApp: no instances")); + + const error = await runLink({ cwd: folder, claimKeylessApp: true }).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).code).toBe(ERROR_CODE.INSTANCE_NOT_FOUND); + expect((error as CliError).message).toBe("Application has no development instance"); + expect(mockSetProfile).not.toHaveBeenCalled(); + }); + test("a failed link write is retried by linking the claimed app directly", async () => { mockIsAgent.mockReturnValue(true); mockGetToken.mockResolvedValue("token"); diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 1a069b18b..ba46ccc4b 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -46,11 +46,8 @@ interface LinkOptions { /** Callers that continue setup themselves (native `clerk init`) skip link's own next steps. */ showNextSteps?: boolean; /** - * Claim the application a signed-out `clerk init` left a claim token for, before listing - * apps. Set only by the `clerk link` command; `clerk init`, `clerk migrate` and the - * `clerk doctor` fix call `link()` without it. Signing in through `login()` still claims, - * without the key check, on every path that doesn't run this step: other callers, and - * `clerk link --app` or a relink when signed out. + * Claim the app a signed-out `clerk init` left a token for before listing apps; set only by + * the `clerk link` command. Without it, signing in through `login()` still claims, unchecked. */ claimKeylessApp?: boolean; } @@ -145,8 +142,10 @@ export async function link(options: LinkOptions = {}): Promise { ) : await resolveApp(cwd, displayPath, !existing)); - const devInstance = app.instances.find((i) => i.environment_type === "development"); - const prodInstance = app.instances.find((i) => i.environment_type === "production"); + // A claim response is untrusted JSON and may carry no instances at all. + const instances = app.instances ?? []; + const devInstance = instances.find((i) => i.environment_type === "development"); + const prodInstance = instances.find((i) => i.environment_type === "production"); if (!devInstance) { throw new CliError("Application has no development instance", { @@ -248,7 +247,7 @@ async function ensureAuth({ claim }: { claim?: boolean } = {}) { const token = await getToken(); if (!token) { log.info("Not logged in. Authenticating first..."); - await login(claim === false ? { showNextSteps: false, claim } : { showNextSteps: false }); + await login({ showNextSteps: false, claim }); } } diff --git a/packages/cli-core/src/commands/whoami/index.ts b/packages/cli-core/src/commands/whoami/index.ts index 0b6da93f8..58d47129d 100644 --- a/packages/cli-core/src/commands/whoami/index.ts +++ b/packages/cli-core/src/commands/whoami/index.ts @@ -202,9 +202,8 @@ function render(identity: Identity): void { log.info( `Not logged in — running on an unclaimed accountless application (key from \`${identity.keySource}\`)`, ); - // Not NEXT_STEPS.WHOAMI: `clerk link` needs an application in an account, - // which an unclaimed app by definition isn't — the same dead end - // `users open`, `doctor`, and `open` all refuse to point at. + // Not NEXT_STEPS.WHOAMI: `clerk link` claims only an app a signed-out `clerk init` created, + // and an SDK-created app needs `clerk open`. These steps work for both. printNextSteps(NEXT_STEPS.WHOAMI_KEYLESS); return; } diff --git a/packages/cli-core/src/lib/autoclaim.test.ts b/packages/cli-core/src/lib/autoclaim.test.ts index c2e52154d..71e727c6e 100644 --- a/packages/cli-core/src/lib/autoclaim.test.ts +++ b/packages/cli-core/src/lib/autoclaim.test.ts @@ -247,6 +247,24 @@ describe("attemptAutoclaim", () => { expect(pullSpy).not.toHaveBeenCalled(); }); + test("a claim response with no instances reports a readable reason, the way clerk auth login claims", async () => { + withBreadcrumb(); + const noInstances = { application_id: "app_claimed", name: "My Claimed App" }; + stubFetch(async () => new Response(JSON.stringify(noInstances), { status: 200 })); + // The real `linkApp`, whose failure text `clerk auth login` prints after "This folder wasn't linked:". + linkAppSpy.mockRestore(); + + const result = await run({ checkKeys: false }); + + expect(result).toMatchObject({ + status: "claimed", + linked: false, + linkError: "the application has no development instance", + envPulled: false, + }); + expect(pullSpy).not.toHaveBeenCalled(); + }); + test("a failed link write is reported as not linked with its reason", async () => { withBreadcrumb(); stubFetch(async () => new Response(JSON.stringify(MOCK_APP), { status: 200 })); diff --git a/packages/cli-core/src/lib/autoclaim.ts b/packages/cli-core/src/lib/autoclaim.ts index 4b716d0fd..40ba1c620 100644 --- a/packages/cli-core/src/lib/autoclaim.ts +++ b/packages/cli-core/src/lib/autoclaim.ts @@ -25,10 +25,9 @@ type Claimed = { /** The claim can never succeed with this token; the breadcrumb is cleared. */ type Terminal = { status: "not_found" | "no_organization" }; /** - * The token's workspace is provisioned by an integration (Vercel Marketplace, - * Stripe), so nothing can be claimed into it. The breadcrumb is kept: the user - * switches to a workspace they own and the next `clerk auth login` or `clerk link` retries. - * `longMessage` is the API's `long_message`, which names the provider. + * The workspace is provisioned by an integration (Vercel Marketplace, Stripe), so nothing can be + * claimed into it; the breadcrumb is kept for a retry from another workspace. `longMessage` is + * the API's `long_message`, which names the provider. */ type ManagedWorkspace = { status: "managed_workspace"; @@ -38,9 +37,8 @@ type ManagedWorkspace = { type Failed = { status: "failed"; error: Error }; type Skipped = { status: "not_keyless" }; /** - * The claim succeeded, but a publishable key in the folder belongs to another application, - * so linking and pulling would pair that key with the claimed app's secret key. Nothing was - * linked or written. `sources` names where each mismatching key was found. + * The claim succeeded but nothing was linked or written: linking and pulling would pair another + * app's publishable key, found in each of `sources`, with the claimed app's secret key. */ export type KeysMismatch = { status: "keys_mismatch"; app: Application; sources: string[] }; @@ -63,9 +61,8 @@ const TERMINAL_BY_STATUS: Record = { }; /** - * Orchestrates the claim of a keyless app. Never throws. `checkKeys` compares the folder's - * publishable keys with the claimed app's before linking; `clerk init`, `clerk migrate` and - * the `clerk doctor` fix sign in without it, so they keep linking whatever they claim. + * Claims, links and pulls the keys of the app `clerk init` left a token for; never throws. + * `checkKeys` refuses to link when the folder holds another app's publishable key. */ export async function attemptAutoclaim( cwd: string, @@ -98,12 +95,10 @@ export async function attemptAutoclaim( } /** - * Where the folder holds a publishable key for another app, or the first env file that - * couldn't be read. Covers the exported variable and the development env files - * (`ENV_FILE_CANDIDATES`): `clerk env pull` writes `.env.development.local`, `.env.local` or - * `.env`, and `.env.development` outranks `.env` in development, so a key there would override - * the pulled one. Each file is read on its own: any mismatch refuses, and an unreadable file - * only matters when no source mismatches, because it could hide one. + * Where the folder holds a publishable key for another app, or the first env file that couldn't + * be read: an unreadable file matters only when nothing mismatches, because it could hide a key. + * `.env.development` is checked although `clerk env pull` never writes it, because it outranks + * `.env` in development. */ async function checkLocalKeys( app: Application, @@ -179,9 +174,15 @@ async function tryLinkApp( app: Application, cwd: string, ): Promise<{ linked: boolean; linkError?: string }> { + const noDevInstance = { linked: false, linkError: "the application has no development instance" }; + // Deserialized API JSON: without this check, a missing `instances` array reaches `linkApp` + // and its raw TypeError becomes the reason `clerk auth login` prints. + if (!app.instances?.some((instance) => instance.environment_type === "development")) { + return noDevInstance; + } try { const linked = await linkApp(app, cwd); - if (!linked) return { linked: false, linkError: "the application has no development instance" }; + if (!linked) return noDevInstance; return { linked: true }; } catch (error) { return { linked: false, linkError: errorMessage(error) }; @@ -230,12 +231,10 @@ export const CLAIM_OTHER_APP_HINT = "or run `clerk link --app ` to link a different application."; /** - * Why a claim failed and kept its token. A 5xx, a 429 or a network failure may pass on a - * retry. An `AuthError` (a session that can't be refreshed) or a 401 means the credentials - * were rejected, so `--app` would fail the same way; the Platform API client prefers - * `CLERK_PLATFORM_API_KEY`, so with it set a 401 rejects the key, not the saved sign-in. Any - * other `CliError` (e.g. `invalid_key_format`) is the CLI's own check and repeats on every - * run, and its message already says what to fix. Any other 4xx repeats on every run. + * Why a claim failed and kept its token: only `temporary` (a 5xx, a 429 or a network failure) + * may pass on a retry, and the `sign_in` and `api_key` advice omits `--app` because it would fail + * with the same rejected credentials. With `CLERK_PLATFORM_API_KEY` set, a 401 rejects that key, + * because the Platform API client uses it before any saved sign-in. */ export type ClaimFailureKind = "temporary" | "sign_in" | "api_key" | "cli" | "rejected"; diff --git a/packages/cli-core/src/lib/keyless-target.ts b/packages/cli-core/src/lib/keyless-target.ts index 81e2c82f1..04b742ebb 100644 --- a/packages/cli-core/src/lib/keyless-target.ts +++ b/packages/cli-core/src/lib/keyless-target.ts @@ -16,6 +16,7 @@ import { CliError, ERROR_CODE, throwUsageError } from "./errors.ts"; import { decodePublishableKey } from "./fapi.ts"; import { detectPublishableKeyName, detectSecretKeyName } from "./framework.ts"; import { keylessCopy, type KeyAccess } from "./copy.ts"; +import { SDK_KEYLESS_FILE } from "./keyless.ts"; import { log } from "./log.ts"; export interface KeylessTarget { @@ -41,13 +42,6 @@ export type InstanceTarget = const ENV_FILES = [".env", ".env.local"]; -/** - * Where the Clerk SDKs park the keys for a keyless app they created themselves - * (running `next dev` with no keys configured). Shape: - * `{ publishableKey, secretKey, claimUrl, apiKeysUrl }`. - */ -const SDK_KEYLESS_FILE = [".clerk", ".tmp", "keyless.json"]; - /** * The `source` a key found in the SDK's own keyless file carries. Exported so * a caller can tell that source apart without restating the path. diff --git a/packages/cli-core/src/lib/keyless.ts b/packages/cli-core/src/lib/keyless.ts index 368bf481d..bb5dcfae6 100644 --- a/packages/cli-core/src/lib/keyless.ts +++ b/packages/cli-core/src/lib/keyless.ts @@ -12,7 +12,10 @@ const BREADCRUMB_DIR = ".clerk"; const BREADCRUMB_FILE = "keyless.json"; const CREATE_TIMEOUT_MS = 15_000; -/** Where a Clerk SDK in keyless mode writes its own app's claim URL and keys. */ +/** + * Where a Clerk SDK in keyless mode writes the app it created: + * `{ publishableKey, secretKey, claimUrl, apiKeysUrl }`. + */ export const SDK_KEYLESS_FILE = [".clerk", ".tmp", "keyless.json"]; interface AccountlessAppResponse { diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index 588f2bc9c..00d192993 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -25,6 +25,10 @@ export const NEXT_STEPS = { "Run `clerk link` to connect your Clerk application", "Run `clerk env pull` to fetch your environment variables", ], + /** No `clerk env pull`: the folder isn't linked until `clerk link --app` picks the application. */ + AUTOCLAIM_KEYS_MISMATCH: [ + "Run `clerk link --app ` with the application this folder should use", + ], AUTOCLAIM_RETRY: [ "Run `clerk auth login` again to retry auto-claim", "Run `clerk link --app ` to link a different application", @@ -77,8 +81,8 @@ export const NEXT_STEPS = { SECURITY_FIX_REMAINING: ["Run `clerk security audit` to see what is still open"], LOGOUT: ["Run `clerk auth login` to sign in again"], WHOAMI: ["Run `clerk link` to connect this directory to an application"], - // An unclaimed keyless app can't be `clerk link`ed — nothing in any account - // to link to. `clerk open` finds the claim link whichever file minted the app. + // `clerk link` claims only an app a signed-out `clerk init` created; an SDK-created app needs + // its Dashboard claim page, which `clerk open` finds whichever file minted the app. WHOAMI_KEYLESS: [ "Run `clerk open` to open this application's claim link", "Run `clerk auth login` to connect your Clerk account", From ae38e8fee0abea72327ae94f607640b946411780 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Tue, 3 Nov 2026 10:10:28 -0600 Subject: [PATCH 14/15] Correct claim docs and the key_pair_mismatch note - The auth README said `clerk link` always signs in with claiming turned off. Only its claim step does; a signed-out `clerk link --app` or relink signs in with claiming on, so that sign-in claims, without the key check. Its list of which commands check keys now includes that case. - The deploy README said the `not_linked` message names the kind of application because only one kind is listed by `clerk link`. The picker lists neither; the reason is that `clerk link` can claim only the kind `clerk init` created. - The `key_pair_mismatch` comment named only `clerk env pull`'s use. `clerk link` now also returns the code when the folder's publishable key belongs to an app other than the one it just claimed, so an agent branching on the code meets both situations. - The changeset adds that `clerk auth login`'s claim-failure messages now say why the claim failed and what to do, and that `clerk init`, `clerk migrate` and `clerk doctor` print them too when they sign in. --- .changeset/grow-1285-link-claim-and-advice.md | 2 ++ packages/cli-core/src/commands/auth/README.md | 4 ++-- packages/cli-core/src/commands/deploy/README.md | 2 +- packages/cli-core/src/lib/errors.ts | 5 ++++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.changeset/grow-1285-link-claim-and-advice.md b/.changeset/grow-1285-link-claim-and-advice.md index e18f20391..1b3a2fb3a 100644 --- a/.changeset/grow-1285-link-claim-and-advice.md +++ b/.changeset/grow-1285-link-claim-and-advice.md @@ -3,3 +3,5 @@ --- Make `clerk link` claim the application a signed-out `clerk init` created and link the folder to it, and make `clerk deploy` and `clerk deploy status` say how to link a folder that runs on an unclaimed application. If the claim fails with an error that keeps the claim token, or the workspace is managed by an integration, `clerk link` now stops with the error and says whether to rerun it or link another app with `--app`, instead of offering apps that can't include the claimed one. If the folder's publishable key belongs to a different application, `clerk link` and `clerk auth login` claim the application but leave the folder unlinked and its env files unchanged, and say which file holds the other key. + +- `clerk auth login`'s claim-failure messages now say why the claim failed and what to do, and `clerk init`, `clerk migrate` and `clerk doctor` print them too when they sign you in. diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index fc572d9e4..824dbe77e 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -18,7 +18,7 @@ Before signing in, the command prints a banner: the Clerk logo beside a link to 6. Exchanges the code for an access token 7. Stores the token and user info in local config 8. If this was a re-authentication over an existing session, revokes the previous grant. The outgoing session is read once the authorization code arrives and just before the token exchange replaces it, and revoked only after the replacement is stored — so an abandoned browser flow leaves the original session intact, and a concurrent refresh has the smallest possible window to rotate the token out from under the revocation. A failure here warns rather than failing the login -9. **Autoclaim**: if `.clerk/keyless.json` exists in the current directory, claims the temporary application, links it to the project, and pulls environment variables. `clerk link` signs in through the same `login()` with claiming turned off (`claim: false`) and then claims the application itself, so it can act on the result +9. **Autoclaim**: if `.clerk/keyless.json` exists in the current directory, claims the temporary application, links it to the project, and pulls environment variables. `clerk link`'s claim step signs in through the same `login()` with claiming turned off (`claim: false`) and then claims the application itself, so it can act on the result. A signed-out `clerk link --app` or relink signs in with claiming on, so its sign-in claims here #### Accountless autoclaim breadcrumb lifecycle @@ -27,7 +27,7 @@ When `clerk init` runs in accountless mode it writes `.clerk/keyless.json` conta - **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** (a Platform API error or a network failure): breadcrumb is preserved, so the next `clerk link` or `clerk auth login` claims again. A 5xx, a 429 or a network failure may pass on that retry, so the warning says to rerun `clerk link`. A 401, or a saved session that can no longer be refreshed, means Clerk rejected the saved sign-in, so the warning says to run `clerk auth login` to sign in again and then rerun `clerk link`. With `CLERK_PLATFORM_API_KEY` set, the CLI uses that key before any saved sign-in, so a 401 means the key was rejected and the warning says to fix or unset it. Any other 4xx will fail the same way on every retry, so the warning says Clerk rejected the claim, with the status and reason, and to run `clerk link --app ` with the application the folder should use. `clerk auth login` prints that advice as a warning and exits 0; `clerk link` stops with the original error and the same advice, and also stops for a managed workspace, because its app list can't show the application the token would claim. -- **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), either exported or in a development env file (`.env.development.local`, `.env.local`, `.env.development`, `.env`). `clerk env pull` writes `.env.development.local`, `.env.local` or `.env`; `.env.development` is checked because it outranks `.env` in development, so a different key there would override the pulled one. Each file is read on its own, and values are compared as a framework reads them: trimmed, unquoted, and without an inline comment. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link` check keys; `clerk init`, `clerk migrate`, and the `clerk doctor` fix sign in through `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0; its next step is `clerk link --app `, with no `clerk env pull`. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. +- **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), either exported or in a development env file (`.env.development.local`, `.env.local`, `.env.development`, `.env`). `clerk env pull` writes `.env.development.local`, `.env.local` or `.env`; `.env.development` is checked because it outranks `.env` in development, so a different key there would override the pulled one. Each file is read on its own, and values are compared as a framework reads them: trimmed, unquoted, and without an inline comment. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link`'s claim step check keys; `clerk init`, `clerk migrate`, the `clerk doctor` fix, and a signed-out `clerk link --app` or relink sign in through `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0; its next step is `clerk link --app `, with no `clerk env pull`. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. - **Unreadable env file**: the claim succeeds. A different application's key in the exported variable or any readable file still stops it as above. Otherwise the folder is linked, but no keys are written, because the unread file could hold another application's key. A warning names the file and the reason and says to run `clerk env pull` once the file is fixed. - **Claimed but not linked**: saving the link failed, or the application has no development instance. The command prints "Claimed application: ..." and a warning with the reason, and says to run `clerk link` to finish setup. - **Success**: application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. The command prints the Dashboard URL of the claimed app's development instance, since this is the first time the app has a home in an account. diff --git a/packages/cli-core/src/commands/deploy/README.md b/packages/cli-core/src/commands/deploy/README.md index f99856434..7d9998078 100644 --- a/packages/cli-core/src/commands/deploy/README.md +++ b/packages/cli-core/src/commands/deploy/README.md @@ -65,7 +65,7 @@ Agent-mode `clerk deploy` without `--domain` is informational and exits 0 for li ### An unlinked folder -`clerk deploy` and `clerk deploy status` stop with `not_linked` in a folder with no link. The message says which kind of application the folder runs on, because only one kind is listed by `clerk link`: +`clerk deploy` and `clerk deploy status` stop with `not_linked` in a folder with no link. The message says which kind of application the folder runs on, because `clerk link` can claim only the kind `clerk init` created: | Folder holds | Message tells you to | | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index c9a0d58da..11651f451 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -134,7 +134,10 @@ export const ERROR_CODE = { * Clerk could not return its domain's status yet (a 404 on the domain). */ DEPLOY_INSTANCE_UNRESOLVED: "deploy_instance_unresolved", - /** Local publishable key and secret key address different applications. */ + /** + * Local publishable key and secret key address different applications (`clerk env pull`), or the + * folder's publishable key belongs to an app other than the one `clerk link` just claimed. + */ KEY_PAIR_MISMATCH: "key_pair_mismatch", /** BAPI returned a response the CLI could not use. */ BAPI_UNEXPECTED_RESPONSE: "bapi_unexpected_response", From 0468dc172e0d53fa8c5220796ced91ce97107ec5 Mon Sep 17 00:00:00 2001 From: Shane Kercheval Date: Tue, 3 Nov 2026 10:16:24 -0600 Subject: [PATCH 15/15] Clarify signed-out relink wording in auth README - The sentence listing who signs in without the key check said "a signed-out `clerk link --app` or relink sign in", which read as if "relink" were a verb; it now says "signed-out `clerk link --app` and relink flows call `login()`". --- packages/cli-core/src/commands/auth/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index 824dbe77e..c56fed1d9 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -27,7 +27,7 @@ When `clerk init` runs in accountless mode it writes `.clerk/keyless.json` conta - **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** (a Platform API error or a network failure): breadcrumb is preserved, so the next `clerk link` or `clerk auth login` claims again. A 5xx, a 429 or a network failure may pass on that retry, so the warning says to rerun `clerk link`. A 401, or a saved session that can no longer be refreshed, means Clerk rejected the saved sign-in, so the warning says to run `clerk auth login` to sign in again and then rerun `clerk link`. With `CLERK_PLATFORM_API_KEY` set, the CLI uses that key before any saved sign-in, so a 401 means the key was rejected and the warning says to fix or unset it. Any other 4xx will fail the same way on every retry, so the warning says Clerk rejected the claim, with the status and reason, and to run `clerk link --app ` with the application the folder should use. `clerk auth login` prints that advice as a warning and exits 0; `clerk link` stops with the original error and the same advice, and also stops for a managed workspace, because its app list can't show the application the token would claim. -- **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), either exported or in a development env file (`.env.development.local`, `.env.local`, `.env.development`, `.env`). `clerk env pull` writes `.env.development.local`, `.env.local` or `.env`; `.env.development` is checked because it outranks `.env` in development, so a different key there would override the pulled one. Each file is read on its own, and values are compared as a framework reads them: trimmed, unquoted, and without an inline comment. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link`'s claim step check keys; `clerk init`, `clerk migrate`, the `clerk doctor` fix, and a signed-out `clerk link --app` or relink sign in through `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0; its next step is `clerk link --app `, with no `clerk env pull`. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. +- **Publishable key for another application**: the claim succeeds, but a publishable key for a different application is in the framework's publishable key variable (for example `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`), either exported or in a development env file (`.env.development.local`, `.env.local`, `.env.development`, `.env`). `clerk env pull` writes `.env.development.local`, `.env.local` or `.env`; `.env.development` is checked because it outranks `.env` in development, so a different key there would override the pulled one. Each file is read on its own, and values are compared as a framework reads them: trimmed, unquoted, and without an inline comment. The folder is not linked and its env files are not changed, because pulling would pair that key with the claimed app's secret key. Only the `clerk auth login` command (and its `clerk login` alias) and `clerk link`'s claim step check keys; `clerk init`, `clerk migrate`, the `clerk doctor` fix, and signed-out `clerk link --app` and relink flows call `login()` without the check and link whatever they claim. The breadcrumb is deleted, since the token is spent. `clerk auth login` prints a warning naming the claimed app and the file, and exits 0; its next step is `clerk link --app `, with no `clerk env pull`. `clerk link` prints the same text as a `key_pair_mismatch` error and exits 1. To finish, run `clerk link --app ` with the application the folder should use. - **Unreadable env file**: the claim succeeds. A different application's key in the exported variable or any readable file still stops it as above. Otherwise the folder is linked, but no keys are written, because the unread file could hold another application's key. A warning names the file and the reason and says to run `clerk env pull` once the file is fixed. - **Claimed but not linked**: saving the link failed, or the application has no development instance. The command prints "Claimed application: ..." and a warning with the reason, and says to run `clerk link` to finish setup. - **Success**: application is claimed and linked, `.env` is updated via `clerk env pull`, breadcrumb is deleted. The command prints the Dashboard URL of the claimed app's development instance, since this is the first time the app has a home in an account.