diff --git a/.changeset/keyless-mode-default.md b/.changeset/keyless-mode-default.md new file mode 100644 index 000000000..ef4e4d6b9 --- /dev/null +++ b/.changeset/keyless-mode-default.md @@ -0,0 +1,10 @@ +--- +"clerk": major +--- + +Make keyless mode the default for unauthenticated `clerk init` runs on keyless-capable frameworks, and let an unclaimed keyless application be configured end to end without an account. + +- `clerk init` while signed out now bootstraps with auto-generated development keys instead of forcing a browser login; running `clerk auth login` later claims the application. `--keyless` forces keyless mode even when signed in, `--login` forces the authenticated flow, and `--template ` shapes the application at creation. Re-running init keeps an existing unclaimed application — whether `clerk init` or a Clerk SDK minted it — unless `--fresh` explicitly asks for a replacement. +- `clerk config pull` and `clerk config patch` work on an unclaimed application through the Backend API, addressing its resources directly: `instance`, `communication`, `restrictions`, `organization_settings`, `protect`, `oauth_application_settings`, and `instance_settings`. Unsupported groups and unrecognized `instance` fields exit with a usage error naming what the API accepts, instead of printing success while the write was silently dropped; applied writes are verified against the API's own response. +- `clerk enable orgs`/`disable orgs`, `clerk whoami`, `clerk env pull`, `clerk doctor`, `clerk open`, `clerk users`, and `clerk api` all operate on an unclaimed keyless application, resolving the project's own secret key from its env files or the SDK's `.clerk/.tmp/keyless.json`. An exported `CLERK_SECRET_KEY` keeps its existing precedence, including over a linked profile. Commands that genuinely need a claimed application (billing, `config put`, `config schema`, `users open`) say so and name the reason instead of failing with "not linked". +- `clerk whoami` and `clerk env pull` detect a publishable/secret key pair belonging to two different applications — `whoami` warns, `env pull` refuses to write the pair. `whoami` in an unlinked directory also points out a local secret key when one is present, since `clerk api` and `clerk users` will use that key's instance rather than the signed-in account. diff --git a/packages/cli-core/src/commands/api/README.md b/packages/cli-core/src/commands/api/README.md index cffc0d4d4..1ddb6b500 100644 --- a/packages/cli-core/src/commands/api/README.md +++ b/packages/cli-core/src/commands/api/README.md @@ -5,6 +5,11 @@ Make authenticated HTTP requests to Clerk APIs directly from the command line. By default, targets the Clerk Backend API (`https://api.clerk.dev/v1/`) using the instance secret key. Use `--platform` to target the Platform API instead. +Works with no login and no linked project on an **unclaimed keyless +application** — the one an SDK creates for itself the first time you run +`next dev` (or similar) with no keys configured — by reading the secret key it +already left on disk. See [Authentication](#authentication) below. + ## Usage ```sh @@ -81,14 +86,19 @@ clerk api --fapi /environment --app app_123 --instance dev Secret key resolution order (Backend API, the default): 1. `--secret-key` flag (explicit) -2. `CLERK_SECRET_KEY` environment variable -3. Auto-resolve from `--app ` via the Platform API (see below) +2. Auto-resolve from `--app ` via the Platform API (see below) +3. This project's own keyless secret key — from `CLERK_SECRET_KEY`, `.env.local` + (or the framework's detected env var name), or the SDK's own `.clerk/.tmp/keyless.json` 4. Auto-resolve from linked project profile via the Platform API (see below) -Steps 3 and 4 both exchange a Platform API token for the target instance's -secret key, so either needs Platform API auth to be available. Step 3 works -from any directory (no `clerk link` required); step 4 uses the app ID stored -by `clerk link`. +Step 2 exchanges a Platform API token for the target instance's secret key and +works from any directory (no `clerk link` required). Step 3 is what makes +`clerk api` work out of the box against an **unclaimed keyless application** — +the one an SDK creates for itself on first `next dev` (or similar) with no keys +configured — with no login and no Platform API auth at all; it only applies when +the directory isn't linked and `--app` wasn't passed, since either of those names +an explicit destination the on-disk key might not belong to. Step 4 uses the app +ID stored by `clerk link` and needs Platform API auth like step 2. Platform API auth (used by `--platform` mode, and by steps 3 and 4 above): diff --git a/packages/cli-core/src/commands/billing/README.md b/packages/cli-core/src/commands/billing/README.md index bd29a806d..2b8c00d53 100644 --- a/packages/cli-core/src/commands/billing/README.md +++ b/packages/cli-core/src/commands/billing/README.md @@ -4,6 +4,12 @@ Toggle Clerk billing for organizations and/or users on the linked instance. The handlers are wired to top-level `clerk enable billing` and `clerk disable billing` commands. +**Requires a claimed application.** Unlike `clerk enable/disable orgs`, these +commands cannot run against an unclaimed keyless application: billing settings +exist only in the account-level config document, and Clerk's Backend API exposes +no billing resource an instance secret key could reach. In a keyless project both +commands exit with an `auth_required` error pointing at `clerk auth login`. + For arbitrary billing config edits (plans, trials, payment-method requirements) use `clerk config patch --json '{"billing":{...}}'` until a dedicated `clerk billing settings` command lands. diff --git a/packages/cli-core/src/commands/billing/index.ts b/packages/cli-core/src/commands/billing/index.ts index 15e0b1ef8..aec98bfb1 100644 --- a/packages/cli-core/src/commands/billing/index.ts +++ b/packages/cli-core/src/commands/billing/index.ts @@ -1,13 +1,14 @@ -import { resolveAppContext } from "../../lib/config.ts"; -import { throwUsageError } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, throwUsageError } from "../../lib/errors.ts"; import { isAgent, isHuman } from "../../mode.ts"; import { log } from "../../lib/log.ts"; +import { keylessCopy } from "../../lib/copy.ts"; import { confirm } from "../../lib/prompts.ts"; import { detectPackageManager } from "../../lib/package-manager.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { withGutter } from "../../lib/spinner.ts"; import { resolveSkillsRunner, runSkillsAdd } from "../../lib/skills.ts"; import { applyConfigPatch } from "../config/apply-patch.ts"; +import { resolveInstanceTarget, type InstanceTarget } from "../../lib/keyless-target.ts"; interface BillingOptions { app?: string; @@ -56,9 +57,24 @@ function describeTargets(targets: Target[]): string { return parts.length === 2 ? `${parts[0]} and ${parts[1]}` : parts[0]!; } +/** + * Billing settings live only in the account-level config document — Clerk's + * Backend API exposes no billing resource — so these commands can't run against + * an unclaimed keyless application the way the org toggles can. Pure assertion: + * resolving the target is `resolveInstanceTarget`'s job, not billing's. + */ +function assertBillingTarget(target: InstanceTarget): void { + if (target.kind === "keyless") { + throw new CliError(keylessCopy.billingNeedsClaimedApplication(), { + code: ERROR_CODE.AUTH_REQUIRED, + }); + } +} + export async function billingEnable(options: BillingOptions): Promise { const targets = parseForTargets(options.for); - const ctx = await resolveAppContext(options); + const target = await resolveInstanceTarget(options); + assertBillingTarget(target); const billing: Record = {}; const payload: Record = { billing }; @@ -73,7 +89,7 @@ export async function billingEnable(options: BillingOptions): Promise { await withGutter("Enabling billing", async ({ setNextSteps }) => { const applied = await applyConfigPatch({ - ctx, + target, payload, verb: `Enabling billing for ${describeTargets(targets)}`, successMessage: `Billing enabled for ${describeTargets(targets)}`, @@ -122,7 +138,8 @@ async function offerBillingSkillInstall(options: BillingOptions): Promise export async function billingDisable(options: BillingOptions): Promise { const targets = parseForTargets(options.for); - const ctx = await resolveAppContext(options); + const target = await resolveInstanceTarget(options); + assertBillingTarget(target); // No cascade: leave organization_settings untouched. const billing: Record = {}; @@ -131,7 +148,7 @@ export async function billingDisable(options: BillingOptions): Promise { await withGutter("Disabling billing", async () => { await applyConfigPatch({ - ctx, + target, payload: { billing }, verb: `Disabling billing for ${describeTargets(targets)}`, successMessage: `Billing disabled for ${describeTargets(targets)}`, diff --git a/packages/cli-core/src/commands/config/README.md b/packages/cli-core/src/commands/config/README.md index ed5c4b9fe..3a40d3d46 100644 --- a/packages/cli-core/src/commands/config/README.md +++ b/packages/cli-core/src/commands/config/README.md @@ -2,6 +2,11 @@ Manage Clerk instance configuration. +Two modes exist, picked automatically: + +- **Account mode** (default) — full instance config document via the Platform API. Used whenever the project is linked or `--app` is passed; requires an account (`clerk auth login` or `CLERK_PLATFORM_API_KEY`). +- **Keyless mode** — a reduced set of settings via the Backend API, using only the instance secret key the project already has on disk. No account, no login, and no platform API key required. See [Keyless mode](#keyless-mode). + ## Commands ### `clerk config pull` @@ -31,6 +36,7 @@ clerk config pull --keys auth_email session - a linked Clerk project in the current directory, or - `--app ` to target an application directly - Authenticated via `CLERK_PLATFORM_API_KEY`, `clerk auth login`, or the interactive human-mode prompt +- **Or neither**: an unlinked project holding an instance secret key falls back to [keyless mode](#keyless-mode), which needs no account #### API Endpoints @@ -69,6 +75,7 @@ clerk config schema --keys auth_email session - a linked Clerk project in the current directory, or - `--app ` to target an application directly - Authenticated via `CLERK_PLATFORM_API_KEY`, `clerk auth login`, or the interactive human-mode prompt +- Account-only: in an unlinked project holding an instance secret key this exits with an error explaining that the schema describes the account-level config document (see [keyless mode](#keyless-mode)) #### API Endpoints @@ -109,6 +116,7 @@ clerk config patch --file partial-config.json --dry-run - a linked Clerk project in the current directory, or - `--app ` to target an application directly - Authenticated via `CLERK_PLATFORM_API_KEY`, `clerk auth login`, or the interactive human-mode prompt +- **Or neither**: an unlinked project holding an instance secret key falls back to [keyless mode](#keyless-mode), which needs no account #### API Endpoints @@ -149,9 +157,108 @@ clerk config put --file full-config.json --dry-run - a linked Clerk project in the current directory, or - `--app ` to target an application directly - Authenticated via `CLERK_PLATFORM_API_KEY`, `clerk auth login`, or the interactive human-mode prompt +- Account-only: in an unlinked project holding an instance secret key this exits with an error pointing at `clerk config patch` (see [keyless mode](#keyless-mode)) #### API Endpoints | Method | Endpoint | Description | | ------ | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PUT` | `/v1/platform/applications/{appID}/instances/{instanceID}/config` | Replaces the full instance configuration. Sends `?dry_run=true` under `--dry-run` to validate and preview without persisting. Authenticated via `Bearer` token from `CLERK_PLATFORM_API_KEY`. | + +--- + +## Keyless mode + +A keyless application created by `clerk init` has no Clerk account behind it until someone claims it, so the Platform API — which authenticates an _account_ — cannot reach it. `clerk config pull` and `clerk config patch` fall back to Clerk's Backend API, authenticated with the instance secret key the project already keeps locally, so an unclaimed app can be configured without logging in. + +### When it engages + +Both must hold, otherwise the account-authenticated path runs unchanged: + +1. No `--app` was passed. +2. No linked project in the current directory. + +**Account credentials are not part of this decision.** Keyless mode works with or without `CLERK_PLATFORM_API_KEY` and with or without a `clerk auth login` session — the instance secret key is sufficient on its own. What rules it out is an explicit destination (`--app` or a linked profile), because that names an application the local secret key may not belong to. + +When credentials _are_ present and the directory simply isn't linked, the command prints a warning that it's using the reduced key-based view and points at `clerk link`, so the narrower output is never a silent surprise. + +The secret key is resolved the way the app itself would resolve one, and this order is shared by every keyless-capable command (`lib/keyless-target.ts`): + +1. `CLERK_SECRET_KEY`, or the framework's secret key variable (e.g. `NUXT_CLERK_SECRET_KEY`), in the environment +2. `.env`, then `.env.local` — the later file wins +3. `.clerk/.tmp/keyless.json` — the keys a Clerk SDK minted for itself when the app ran with no keys configured + +A key that doesn't start with `sk_` is rejected. The SDK file comes last because SDKs only create their own application when nothing else supplies keys. + +### Payload shape + +The Backend API has no single config document — it exposes independent resources — so keyless payloads name them directly instead of translating between the two shapes. Each top-level key maps 1:1 to one endpoint: + +```sh +clerk config patch --json '{ + "instance": { "support_email": "dev@acme.com" }, + "organization_settings": { "enabled": true } +}' +``` + +| Top-level key | Endpoint | Readable | Covers | +| ---------------------------- | ----------------------------------------- | -------- | ----------------------------------------------------------------------------------- | +| `instance` | `/v1/instance` | Yes | Support email, home URL, allowed origins | +| `communication` | `/v1/instance/communication` | Yes | Blocked country codes and communication settings | +| `restrictions` | `/v1/instance/restrictions` | No | Allowlist / blocklist sign-up restrictions | +| `organization_settings` | `/v1/instance/organization_settings` | Yes | Organizations: enabled, membership limits, domains, creation defaults | +| `protect` | `/v1/instance/protect` | Yes | Bot protection | +| `oauth_application_settings` | `/v1/instance/oauth_application_settings` | Yes | Dynamic OAuth client registration | +| `instance_settings` | `/v1/beta_features/instance_settings` | No | `test_mode`, `progressive_sign_up`, `from_email_address`, `restricted_to_allowlist` | + +Any other top-level key exits with a usage error naming the supported ones. Most of them (`session`, `sign_up`, `auth_email`, …) are genuinely account-only and the error points at `clerk auth login`. A handful — `enterprise_connections`, `saml_connections`, `oauth_applications`, `domains` — are BAPI resource _collections_ reachable on an unclaimed application today; they're just not part of this config document, so the error points at `clerk api /` instead of a login that wouldn't add them anyway. + +`instance_settings` is backed by a beta route, and is the only way to reach those four auth-config fields without an account — which is why it's included. + +`clerk config pull` returns the same envelope. `restrictions` and `instance_settings` are omitted because the Backend API has no read route for either (see the Readable column above) — a `pull` run to confirm a write to them will not show the field, which does not mean the write failed. Asking for either by name prints a warning rather than failing. + +`GET /v1/instance` returns a subset of what `PATCH /v1/instance` accepts — `support_email`, for example, is writable but not readable. Fields the read omits have no "before" value to compare against, so they always appear as additions in the diff and never trigger "No changes detected". The write itself is unaffected: patching a field to the value it already holds is a no-op server-side. + +### Round-trip verification + +A 200 or 204 from a keyless write only means Clerk's Backend API accepted the request — it silently drops fields it doesn't recognize inside a group instead of rejecting them, and at least one route (`PATCH /v1/instance` with `allowed_origins: null` or `[]`) accepts a value it then ignores. Printing "Config pushed successfully" off the HTTP status alone would paper over both. + +After a write, the CLI checks every field it sent against the PATCH response body, and against nothing else. Fields whose value round-trips are reported as applied; fields the response doesn't reflect are named explicitly instead of folded into an unconditional success line. + +A follow-up GET looks like stronger evidence and is in fact weaker. BAPI omits writable-but-not-readable fields from its reads — `instance.support_email` is accepted and never echoed — and reads are eventually consistent, so a GET issued straight after a write routinely returns the pre-write value. Verifying against one reports perfectly good writes as dropped. This applies equally to the six groups that do have a GET route: the response body is the only read that is guaranteed to be about _this_ write. + +That leaves `PATCH /v1/instance`, which answers `204` with no body at all. Nothing can confirm it after the fact, so the check moves to before the request instead: the fields that route accepts are a closed set in BAPI's own schema (`additionalProperties: false`), and `assertKeylessPayload` rejects anything outside it. This matters because that route is also the one people reach for when trying to enable password auth or a social provider — none of which it accepts, and all of which it used to swallow with a `204` and a success message. The group is still reported as unconfirmed, and contributes no state to the printed envelope rather than a possibly-stale re-read. + +`restrictions` and `instance_settings` have no GET route, but both echo their new state in the PATCH response, so their writes verify normally. + +### Differences from account mode + +| Behavior | Account mode | Keyless mode | +| ------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Coverage | Full config document | Seven Backend API resources | +| `--instance` | Selects dev/prod | Usage error — the secret key already targets exactly one instance | +| `--dry-run` | Server-side validation of the projection | Local diff only; nothing is sent | +| `config put` | Replaces the whole document | Errors — no full document exists to replace | +| `config schema` | Returns the JSON Schema | Errors — the schema describes the account-level document | +| Write confirmation | Trusts the response body outright | Verifies each sent field round-tripped and names what couldn't be confirmed (see [Round-trip verification](#round-trip-verification)) | + +Run `clerk auth login` to claim the application; auto-claim links it, and every config command then uses account mode with full coverage. + +### API Endpoints (keyless mode) + +All requests go to the Clerk Backend API (default `https://api.clerk.dev`, overridable via `CLERK_BACKEND_API_URL`), authenticated with a `Bearer sk_…` instance secret key. + +| Method | Endpoint | Description | +| ------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/instance` | Reads instance settings for `config pull` and for the pre-write diff. | +| `PATCH` | `/v1/instance` | Updates instance settings. Answers `204` with no body, so the write can't be confirmed afterwards — field names are validated against BAPI's schema beforehand instead. | +| `GET` | `/v1/instance/communication` | Reads communication settings (e.g. blocked country codes) for `config pull` and for the pre-write diff. | +| `PATCH` | `/v1/instance/communication` | Updates communication settings. | +| `PATCH` | `/v1/instance/restrictions` | Updates sign-up restrictions (allowlist, blocklist). No read route, but the response echoes the new state, so the write verifies normally. | +| `GET` | `/v1/instance/organization_settings` | Reads organization settings for `config pull` and for the pre-write diff. | +| `PATCH` | `/v1/instance/organization_settings` | Updates organization settings. | +| `GET` | `/v1/instance/protect` | Reads bot-protection settings for `config pull` and for the pre-write diff. | +| `PATCH` | `/v1/instance/protect` | Updates bot-protection settings. | +| `GET` | `/v1/instance/oauth_application_settings` | Reads dynamic OAuth client registration settings for `config pull` and for the pre-write diff. | +| `PATCH` | `/v1/instance/oauth_application_settings` | Updates dynamic OAuth client registration settings. | +| `PATCH` | `/v1/beta_features/instance_settings` | Updates `test_mode`, `progressive_sign_up`, `from_email_address`, `restricted_to_allowlist`. No read route, but the response echoes the new state. | diff --git a/packages/cli-core/src/commands/config/apply-patch.ts b/packages/cli-core/src/commands/config/apply-patch.ts index b3368fee7..b7e520145 100644 --- a/packages/cli-core/src/commands/config/apply-patch.ts +++ b/packages/cli-core/src/commands/config/apply-patch.ts @@ -1,13 +1,20 @@ -import { fetchInstanceConfig, patchInstanceConfig } from "../../lib/plapi.ts"; -import { throwUserAbort, withApiContext } from "../../lib/errors.ts"; +import { throwUserAbort } from "../../lib/errors.ts"; import { withSpinner } from "../../lib/spinner.ts"; import { confirm } from "../../lib/prompts.ts"; import { isHuman } from "../../mode.ts"; import { log } from "../../lib/log.ts"; -import { hasConfigChanges, printDiff } from "./push.ts"; +import { hasConfigChanges, printDiff, reportWriteOutcome } from "./push.ts"; +import type { InstanceTarget } from "../../lib/keyless-target.ts"; +import { + assertPayloadWritable, + LOCAL_DRY_RUN_MESSAGE, + readInstanceConfig, + supportsServerDryRun, + writeInstanceConfig, +} from "./io.ts"; export interface ApplyPatchOptions { - ctx: { appId: string; instanceId: string; appLabel: string; instanceLabel: string }; + target: InstanceTarget; payload: Record; verb: string; successMessage: string; @@ -21,22 +28,20 @@ export interface ApplyPatchOptions { /** Fetch + diff + confirm + PATCH, matching `clerk config patch` semantics. */ export async function applyConfigPatch(opts: ApplyPatchOptions): Promise { - const { ctx, payload, verb, successMessage, failureContext, yes, dryRun, warning } = opts; + const { target, payload, verb, successMessage, failureContext, yes, dryRun, warning } = opts; + + assertPayloadWritable(target, payload); const current = opts.currentConfig ?? - (await withSpinner("Fetching current config...", () => - withApiContext(fetchInstanceConfig(ctx.appId, ctx.instanceId), "Failed to fetch config"), - )); + (await withSpinner("Fetching current config...", () => readInstanceConfig(target, payload))); if (!hasConfigChanges(current, payload, true)) { log.info(dryRun ? "[dry-run] No changes detected" : "No changes detected"); return false; } - const headline = dryRun - ? `[dry-run] Proposing PATCH on ${ctx.appLabel} (${ctx.instanceLabel}):` - : `${verb} on ${ctx.appLabel} (${ctx.instanceLabel}):`; + const headline = `${dryRun ? "[dry-run] " : ""}${verb} on ${target.label}:`; log.info(`\n${headline}\n`); printDiff(current, payload, true); @@ -44,22 +49,26 @@ export async function applyConfigPatch(opts: ApplyPatchOptions): Promise - withApiContext( - patchInstanceConfig(ctx.appId, ctx.instanceId, payload, { dryRun }), - dryRun ? "Dry-run failed" : failureContext, - ), + writeInstanceConfig(target, payload, { method: "PATCH", dryRun, failureContext }), ); - log.debug(`plapi: ${JSON.stringify(result)}`); - log.success(dryRun ? "[dry-run] Validation passed — no changes applied" : successMessage); + log.debug(`config: ${JSON.stringify(result.body)}`); + if (dryRun) { + log.success("[dry-run] Validation passed — no changes applied"); + } else { + reportWriteOutcome(result.verification, successMessage); + } return true; } diff --git a/packages/cli-core/src/commands/config/io.ts b/packages/cli-core/src/commands/config/io.ts new file mode 100644 index 000000000..48fa28190 --- /dev/null +++ b/packages/cli-core/src/commands/config/io.ts @@ -0,0 +1,104 @@ +/** + * Reading and writing instance configuration, whichever way the instance is + * addressed. + * + * This is the only place that knows an account target and a keyless target + * reach different APIs. Callers take an `InstanceTarget`, ask for a read or a + * write, and never branch on `kind` themselves. + */ + +import { withApiContext } from "../../lib/errors.ts"; +import type { InstanceTarget } from "../../lib/keyless-target.ts"; +import { fetchInstanceConfig, patchInstanceConfig, putInstanceConfig } from "../../lib/plapi.ts"; +import { + assertKeylessPayload, + patchKeylessConfig, + readCurrentGroups, + type KeylessWriteVerification, +} from "./keyless.ts"; + +export type ConfigMethod = "PUT" | "PATCH"; + +export interface WriteResult { + /** What the API reported back — printed to the user as-is. */ + body: Record; + /** + * Only present for a keyless write. An account write's response body IS the + * config document, trusted outright; a keyless write only gets a 200/204 + * meaning "request accepted", so what actually landed is checked separately. + */ + verification?: KeylessWriteVerification; +} + +/** + * Rejects a payload the target can't accept, before any diff is printed or any + * prompt is shown. Only the keyless path constrains the payload — the account + * API validates the document server-side. + */ +export function assertPayloadWritable( + target: InstanceTarget, + payload: Record, +): void { + if (target.kind === "keyless") assertKeylessPayload(payload); +} + +/** + * Current configuration, limited to what the write will touch. + * + * The account API returns one document regardless of `scope`. The keyless API + * has no document, so `scope` selects which resources to read — passing the + * pending payload keeps the read to the groups being diffed. + */ +export function readInstanceConfig( + target: InstanceTarget, + scope: Record, +): Promise> { + if (target.kind === "keyless") return readCurrentGroups(target.keyless, scope); + + return withApiContext( + fetchInstanceConfig(target.ctx.appId, target.ctx.instanceId), + "Failed to fetch current config", + ); +} + +/** + * Server-side dry run is a Platform API feature. The Backend API has no + * equivalent, so a keyless preview can only be produced locally. + */ +export function supportsServerDryRun(target: InstanceTarget): boolean { + return target.kind === "account"; +} + +export const LOCAL_DRY_RUN_MESSAGE = "[dry-run] Nothing sent — no changes applied"; + +export async function writeInstanceConfig( + target: InstanceTarget, + payload: Record, + options: { + method: ConfigMethod; + destructive?: boolean; + dryRun?: boolean; + failureContext: string; + }, +): Promise { + if (target.kind === "keyless") { + // PUT is rejected before reaching here: there is no full document to + // replace when the instance is addressed by its own key. The payload was + // validated by `assertPayloadWritable` before the diff was shown. + const { applied, verification } = await patchKeylessConfig( + target.keyless, + payload as Record>, + ); + return { body: applied, verification }; + } + + const apiFn = options.method === "PUT" ? putInstanceConfig : patchInstanceConfig; + const body = await withApiContext( + apiFn(target.ctx.appId, target.ctx.instanceId, payload, { + destructive: options.destructive, + dryRun: options.dryRun, + }), + options.dryRun ? "Dry-run failed" : options.failureContext, + ); + return { body }; +} diff --git a/packages/cli-core/src/commands/config/keyless.test.ts b/packages/cli-core/src/commands/config/keyless.test.ts new file mode 100644 index 000000000..6ae707159 --- /dev/null +++ b/packages/cli-core/src/commands/config/keyless.test.ts @@ -0,0 +1,783 @@ +import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { _setConfigDir, setProfile } from "../../lib/config.ts"; +import { useCaptureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; + +mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); +mock.module("../../lib/git.ts", () => gitStubs); +mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, + withGutter: async ( + _title: string, + fn: (controls: { setNextSteps: (steps: readonly string[]) => void }) => Promise, + ) => fn({ setNextSteps: () => {} }), + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); + +const SECRET_KEY = "sk_test_keyless"; +const BAPI_URL = "https://test-bapi.clerk.com"; + +const INSTANCE = { object: "instance", id: "ins_1", support_email: "old@example.com" }; +const ORG_SETTINGS = { object: "organization_settings", enabled: false }; +const COMMUNICATION = { object: "instance_communication", blocked_country_codes: [] }; +const PROTECT = { object: "instance_protect", rules_enabled: false }; +const OAUTH_SETTINGS = { object: "oauth_application_settings", dynamic_registration: false }; + +/** Readable groups keyed by BAPI path, mirroring what a pull collects. */ +const READABLE_BODIES: Record = { + "/v1/instance": INSTANCE, + "/v1/instance/communication": COMMUNICATION, + "/v1/instance/organization_settings": ORG_SETTINGS, + "/v1/instance/protect": PROTECT, + "/v1/instance/oauth_application_settings": OAUTH_SETTINGS, +}; + +const FULL_ENVELOPE = { + instance: INSTANCE, + communication: COMMUNICATION, + organization_settings: ORG_SETTINGS, + protect: PROTECT, + oauth_application_settings: OAUTH_SETTINGS, +}; + +describe("keyless config", () => { + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + const originalCwd = process.cwd(); + let tempDir: string; + let projectDir: string; + let exitSpy: ReturnType; + const captured = useCaptureLog(); + + /** Mutable per-test state so a PATCH's effect actually shows up on the next GET/re-read. */ + let bapiState: Record; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-keyless-config-")); + projectDir = await mkdtemp(join(tmpdir(), "clerk-keyless-project-")); + _setConfigDir(tempDir); + process.env.CLERK_BACKEND_API_URL = BAPI_URL; + delete process.env.CLERK_SECRET_KEY; + delete process.env.CLERK_PLATFORM_API_KEY; + + exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + bapiState = structuredClone(READABLE_BODIES); + + stubFetch(async (input, init) => { + const path = input.toString().replace(BAPI_URL, ""); + const method = (init?.method ?? "GET").toUpperCase(); + + if (method === "GET") { + const body = bapiState[path]; + if (body) return new Response(JSON.stringify(body), { status: 200 }); + throw new Error(`Unexpected fetch: ${method} ${path}`); + } + + // PATCH /v1/instance answers 204 with no body but does persist the + // fields it accepts, so a re-read after it reflects the write. + if (path === "/v1/instance" && method === "PATCH") { + bapiState[path] = { ...(bapiState[path] as object), ...JSON.parse(init?.body as string) }; + return new Response(null, { status: 204 }); + } + if (path === "/v1/instance/restrictions" && method === "PATCH") { + return new Response(JSON.stringify({ object: "instance_restrictions", allowlist: true }), { + status: 200, + }); + } + if (path === "/v1/beta_features/instance_settings" && method === "PATCH") { + return new Response(JSON.stringify({ object: "instance_settings", test_mode: true }), { + status: 200, + }); + } + if (method === "PATCH" && bapiState[path]) { + bapiState[path] = { ...(bapiState[path] as object), ...JSON.parse(init?.body as string) }; + return new Response(JSON.stringify(bapiState[path]), { status: 200 }); + } + + throw new Error(`Unexpected fetch: ${method} ${path}`); + }); + }); + + afterEach(async () => { + process.chdir(originalCwd); + _setConfigDir(undefined); + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + exitSpy.mockRestore(); + await rm(tempDir, { recursive: true, force: true }); + await rm(projectDir, { recursive: true, force: true }); + }); + + async function writeEnv(file: string, contents: string): Promise { + await writeFile(join(projectDir, file), contents); + } + + describe("findLocalSecretKey", () => { + test("returns undefined when the project has no secret key", async () => { + const { findLocalSecretKey } = await import("../../lib/keyless-target.ts"); + expect(await findLocalSecretKey(projectDir)).toBeUndefined(); + }); + + test("reads the key from .env", async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + const { findLocalSecretKey } = await import("../../lib/keyless-target.ts"); + + expect(await findLocalSecretKey(projectDir)).toEqual({ + secretKey: SECRET_KEY, + source: ".env", + }); + }); + + test(".env.local wins over .env", async () => { + await writeEnv(".env", "CLERK_SECRET_KEY=sk_test_stale\n"); + await writeEnv(".env.local", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + const { findLocalSecretKey } = await import("../../lib/keyless-target.ts"); + + expect(await findLocalSecretKey(projectDir)).toEqual({ + secretKey: SECRET_KEY, + source: ".env.local", + }); + }); + + test("falls back to the keys an SDK created for itself", async () => { + await mkdir(join(projectDir, ".clerk", ".tmp"), { recursive: true }); + await writeFile( + join(projectDir, ".clerk", ".tmp", "keyless.json"), + JSON.stringify({ + publishableKey: "pk_test_sdk", + secretKey: SECRET_KEY, + claimUrl: "https://dashboard.clerk.com/apps/claim?token=x", + }), + ); + const { findLocalSecretKey } = await import("../../lib/keyless-target.ts"); + + expect(await findLocalSecretKey(projectDir)).toEqual({ + secretKey: SECRET_KEY, + source: ".clerk/.tmp/keyless.json", + }); + }); + + test("prefers env files over the SDK's own keys", async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + await mkdir(join(projectDir, ".clerk", ".tmp"), { recursive: true }); + await writeFile( + join(projectDir, ".clerk", ".tmp", "keyless.json"), + JSON.stringify({ secretKey: "sk_test_sdk_stale" }), + ); + const { findLocalSecretKey } = await import("../../lib/keyless-target.ts"); + + expect(await findLocalSecretKey(projectDir)).toEqual({ + secretKey: SECRET_KEY, + source: ".env", + }); + }); + + test("ignores a malformed SDK keyless file", async () => { + await mkdir(join(projectDir, ".clerk", ".tmp"), { recursive: true }); + await writeFile(join(projectDir, ".clerk", ".tmp", "keyless.json"), "{ not json"); + const { findLocalSecretKey } = await import("../../lib/keyless-target.ts"); + + expect(await findLocalSecretKey(projectDir)).toBeUndefined(); + }); + + test("the environment wins over env files", async () => { + await writeEnv(".env", "CLERK_SECRET_KEY=sk_test_from_file\n"); + process.env.CLERK_SECRET_KEY = SECRET_KEY; + const { findLocalSecretKey } = await import("../../lib/keyless-target.ts"); + + expect(await findLocalSecretKey(projectDir)).toEqual({ + secretKey: SECRET_KEY, + source: "CLERK_SECRET_KEY env var", + }); + }); + }); + + describe("resolveKeylessTarget", () => { + test("resolves for an unlinked project holding a secret key", async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + const { resolveKeylessTarget } = await import("../../lib/keyless-target.ts"); + + expect(await resolveKeylessTarget({ cwd: projectDir })).toEqual({ + secretKey: SECRET_KEY, + source: ".env", + }); + }); + + test("defers to the account path when --app is passed", async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + const { resolveKeylessTarget } = await import("../../lib/keyless-target.ts"); + + expect(await resolveKeylessTarget({ app: "app_1", cwd: projectDir })).toBeUndefined(); + }); + + test("resolves even when a platform API key is set", async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + process.env.CLERK_PLATFORM_API_KEY = "ak_test_platform"; + const { resolveKeylessTarget } = await import("../../lib/keyless-target.ts"); + + expect(await resolveKeylessTarget({ cwd: projectDir })).toEqual({ + secretKey: SECRET_KEY, + source: ".env", + }); + }); + + // The reduced-coverage warning belongs to the config surface, not to + // resolution — `whoami`, `open` and `doctor` want the same keyless answer + // whether or not an account exists, and a resolver that warns is one a + // diagnostic tool can't call without polluting its own report. + test("stays quiet about reduced coverage — that warning is the config surface's", async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + process.env.CLERK_PLATFORM_API_KEY = "ak_test_platform"; + const { resolveKeylessTarget } = await import("../../lib/keyless-target.ts"); + + await resolveKeylessTarget({ cwd: projectDir }); + + expect(captured.err).not.toContain("isn't linked to an application"); + }); + + test("resolveInstanceTarget is the one that says the keyless view covers less", async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + process.env.CLERK_PLATFORM_API_KEY = "ak_test_platform"; + const { resolveInstanceTarget } = await import("../../lib/keyless-target.ts"); + + const target = await resolveInstanceTarget({ cwd: projectDir }); + + expect(target.kind).toBe("keyless"); + expect(captured.err).toContain("isn't linked to an application"); + }); + + test("says nothing about linking when there is no account at all", async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + const { resolveInstanceTarget } = await import("../../lib/keyless-target.ts"); + + await resolveInstanceTarget({ cwd: projectDir }); + + expect(captured.err).not.toContain("isn't linked to an application"); + }); + + test("defers to the account path when the project is linked", async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + await setProfile(projectDir, { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev" }, + }); + const { resolveKeylessTarget } = await import("../../lib/keyless-target.ts"); + + expect(await resolveKeylessTarget({ cwd: projectDir })).toBeUndefined(); + }); + + test("defers to the account path when no secret key is present", async () => { + const { resolveKeylessTarget } = await import("../../lib/keyless-target.ts"); + expect(await resolveKeylessTarget({ cwd: projectDir })).toBeUndefined(); + }); + + test("rejects --instance, which the secret key already determines", async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + const { resolveKeylessTarget } = await import("../../lib/keyless-target.ts"); + + await expect(resolveKeylessTarget({ instance: "prod", cwd: projectDir })).rejects.toThrow( + /--instance is not supported for an unclaimed keyless application/, + ); + }); + + test("rejects a key that is not a secret key", async () => { + await writeEnv(".env", "CLERK_SECRET_KEY=pk_test_not_a_secret\n"); + const { resolveKeylessTarget } = await import("../../lib/keyless-target.ts"); + + await expect(resolveKeylessTarget({ cwd: projectDir })).rejects.toThrow( + /Expected a secret key starting with/, + ); + }); + }); + + describe("assertKeylessPayload", () => { + test("accepts the supported groups", async () => { + const { assertKeylessPayload } = await import("./keyless.ts"); + expect(() => + assertKeylessPayload({ instance: { support_email: "a@b.com" }, restrictions: {} }), + ).not.toThrow(); + }); + + test("rejects unknown keys and names the supported ones", async () => { + const { assertKeylessPayload } = await import("./keyless.ts"); + expect(() => assertKeylessPayload({ session: { lifetime: 10 } })).toThrow( + /Unsupported config key .*session.*\n.*instance, communication, restrictions, organization_settings, protect, oauth_application_settings, instance_settings/s, + ); + }); + + test("points account-only keys at `clerk auth login`", async () => { + const { assertKeylessPayload } = await import("./keyless.ts"); + expect(() => assertKeylessPayload({ session: { lifetime: 10 } })).toThrow( + /Run `clerk auth login` to claim the application/, + ); + }); + + // enterprise_connections/saml_connections/oauth_applications/domains are + // BAPI resource collections reachable on an unclaimed application today — + // verified live via `clerk api /enterprise_connections`. Telling the user + // to claim the app for these would be a detour to nowhere: claiming + // doesn't add them to the config document either. + test("points BAPI resource collections at `clerk api` instead of login", async () => { + const { assertKeylessPayload } = await import("./keyless.ts"); + expect(() => assertKeylessPayload({ enterprise_connections: {} })).toThrow( + /use `clerk api \/enterprise_connections` directly instead of this config document/, + ); + }); + + test("does not suggest `clerk auth login` for a BAPI resource collection", async () => { + const { assertKeylessPayload } = await import("./keyless.ts"); + expect(() => assertKeylessPayload({ domains: {} })).not.toThrow(/clerk auth login/); + }); + + test("rejects a group whose value is not an object", async () => { + const { assertKeylessPayload } = await import("./keyless.ts"); + expect(() => assertKeylessPayload({ instance: "nope" })).toThrow(/must be a JSON object/); + }); + + // `PATCH /v1/instance` answers 204 and drops field names it doesn't know, + // so an unrecognised field there is invisible in the response — the only + // place it can be caught is before the request goes out. + test("rejects an `instance` field the Backend API would silently drop", async () => { + const { assertKeylessPayload } = await import("./keyless.ts"); + expect(() => assertKeylessPayload({ instance: { suport_email: "a@b.com" } })).toThrow( + /Unsupported field on `instance`.*suport_email/s, + ); + }); + + test.each([ + ["password", "which authentication strategies are enabled"], + ["social", "social sign-in providers"], + ["second_factors", "multi-factor authentication policy"], + ])("explains that `instance.%s` has no Backend API route at all", async (field, reason) => { + const { assertKeylessPayload } = await import("./keyless.ts"); + expect(() => assertKeylessPayload({ instance: { [field]: true } })).toThrow( + new RegExp(`no route for ${reason}`), + ); + }); + + test.each([ + "test_mode", + "hibp", + "support_email", + "clerk_js_version", + "development_origin", + "allowed_origins", + "cookieless_dev", + "url_based_session_syncing", + "preferred_sign_in_strategy_when_password_required", + ])("accepts the documented `instance` field %s", async (field) => { + const { assertKeylessPayload } = await import("./keyless.ts"); + expect(() => assertKeylessPayload({ instance: { [field]: "x" } })).not.toThrow(); + }); + + test("leaves fields on other groups alone — their writes echo back", async () => { + const { assertKeylessPayload } = await import("./keyless.ts"); + expect(() => assertKeylessPayload({ protect: { nonsense_field: true } })).not.toThrow(); + }); + }); + + describe("pullKeylessConfig", () => { + test("returns an envelope of the readable groups", async () => { + const { pullKeylessConfig } = await import("./keyless.ts"); + + const config = await pullKeylessConfig({ secretKey: SECRET_KEY, source: ".env" }); + + expect(config).toEqual(FULL_ENVELOPE); + }); + + test("stays quiet about restrictions on a default pull", async () => { + const { pullKeylessConfig } = await import("./keyless.ts"); + + await pullKeylessConfig({ secretKey: SECRET_KEY, source: ".env" }); + + expect(captured.err).not.toContain("no read route"); + }); + + test("warns that restrictions cannot be read and omits it", async () => { + const { pullKeylessConfig } = await import("./keyless.ts"); + + const config = await pullKeylessConfig({ secretKey: SECRET_KEY, source: ".env" }, [ + "restrictions", + ]); + + expect(config).toEqual({}); + expect(captured.err).toContain("no read route for restrictions"); + }); + + test("rejects unknown keys", async () => { + const { pullKeylessConfig } = await import("./keyless.ts"); + + await expect( + pullKeylessConfig({ secretKey: SECRET_KEY, source: ".env" }, ["session"]), + ).rejects.toThrow(/Unsupported config key/); + }); + }); + + describe("patchKeylessConfig", () => { + test("sends each group to its own endpoint and confirms the fields landed", async () => { + const requests: string[] = []; + stubFetch(async (input, init) => { + const path = input.toString().replace(BAPI_URL, ""); + const method = (init?.method ?? "GET").toUpperCase(); + requests.push(`${method} ${path}`); + if (path === "/v1/instance" && method === "PATCH") { + bapiState[path] = { ...(bapiState[path] as object), ...JSON.parse(init?.body as string) }; + return new Response(null, { status: 204 }); + } + if (method === "GET") return new Response(JSON.stringify(bapiState[path]), { status: 200 }); + const updated = { ...(bapiState[path] as object), ...JSON.parse(init?.body as string) }; + bapiState[path] = updated; + return new Response(JSON.stringify(updated), { status: 200 }); + }); + const { patchKeylessConfig } = await import("./keyless.ts"); + + const result = await patchKeylessConfig( + { secretKey: SECRET_KEY, source: ".env" }, + { + instance: { support_email: "new@example.com" }, + organization_settings: { enabled: true }, + }, + ); + + expect(requests).toEqual(["PATCH /v1/instance", "PATCH /v1/instance/organization_settings"]); + // `instance` answered 204, so it contributes no state — deliberately + // absent rather than re-read, because that read is eventually consistent + // and would show the pre-write value under a success message. + expect(result.applied).toEqual({ + organization_settings: { ...ORG_SETTINGS, enabled: true }, + }); + expect(result.verification).toEqual({ + verifiedFields: ["organization_settings.enabled"], + droppedFields: [], + unverifiableGroups: ["instance"], + }); + }); + + test("never re-reads a group whose write answered with no body", async () => { + // Regression: the re-read this asserts against returned the pre-write + // value often enough that `Config pushed successfully` was printed + // directly above stale state, reading as though nothing had been applied. + const requests: string[] = []; + stubFetch(async (input, init) => { + const method = (init?.method ?? "GET").toUpperCase(); + requests.push(`${method} ${input.toString().replace(BAPI_URL, "")}`); + return new Response(null, { status: 204 }); + }); + const { patchKeylessConfig } = await import("./keyless.ts"); + + const result = await patchKeylessConfig( + { secretKey: SECRET_KEY, source: ".env" }, + { instance: { support_email: "new@example.com" } }, + ); + + expect(requests).toEqual(["PATCH /v1/instance"]); + expect(result.applied).toEqual({}); + expect(result.verification.unverifiableGroups).toEqual(["instance"]); + expect(result.verification.droppedFields).toEqual([]); + }); + + test("names an unconfirmed group as already applied when a later group fails", async () => { + // `instance` leaves no trace in the envelope, but it did land — a failure + // downstream still has to say so or the user can't tell how far it got. + stubFetch(async (input) => { + const path = input.toString().replace(BAPI_URL, ""); + if (path === "/v1/instance") return new Response(null, { status: 204 }); + return new Response(JSON.stringify({ errors: [{ message: "nope" }] }), { status: 500 }); + }); + const { patchKeylessConfig } = await import("./keyless.ts"); + + // `withApiContext` attaches the context to the error rather than to its + // message; the global handler prints the two together. + const error = await patchKeylessConfig( + { secretKey: SECRET_KEY, source: ".env" }, + { instance: { support_email: "new@example.com" }, protect: { rules_enabled: true } }, + ).catch((thrown: unknown) => thrown); + + expect((error as { context?: string }).context).toBe( + "Failed to update protect (already applied: instance)", + ); + }); + + test("reports a field the API silently dropped instead of claiming it landed", async () => { + // A typo'd field: BAPI's PATCH routes ignore unknown fields inside a + // group rather than rejecting them, so the request answers 200 with the + // resource as it actually stands — without the typo'd key. + stubFetch(async () => new Response(JSON.stringify(PROTECT), { status: 200 })); + const { patchKeylessConfig } = await import("./keyless.ts"); + + const result = await patchKeylessConfig( + { secretKey: SECRET_KEY, source: ".env" }, + { protect: { rules_enabledx: true } as Record }, + ); + + expect(result.verification).toEqual({ + verifiedFields: [], + droppedFields: ["protect.rules_enabledx"], + unverifiableGroups: [], + }); + }); + + // The whole point of checking the PATCH response and not a follow-up read. + // `instance.support_email` is writable but never echoed by `GET /v1/instance`, + // and BAPI's reads are eventually consistent — verifying against a re-read + // reported both as dropped when the write had in fact landed. + test("never calls a successful instance write dropped just because the read can't show it", async () => { + stubFetch(async (input, init) => { + const path = input.toString().replace(BAPI_URL, ""); + const method = (init?.method ?? "GET").toUpperCase(); + if (path === "/v1/instance" && method === "PATCH") + return new Response(null, { status: 204 }); + // The read never carries support_email, and still shows the pre-write + // allowed_origins — exactly what the real API does. + return new Response( + JSON.stringify({ ...INSTANCE, allowed_origins: ["https://stale.example.com"] }), + { status: 200 }, + ); + }); + const { patchKeylessConfig } = await import("./keyless.ts"); + + const result = await patchKeylessConfig( + { secretKey: SECRET_KEY, source: ".env" }, + { + instance: { + support_email: "new@example.com", + allowed_origins: ["https://fresh.example.com"], + }, + }, + ); + + expect(result.verification.droppedFields).toEqual([]); + expect(result.verification.unverifiableGroups).toEqual(["instance"]); + }); + + test("applies groups in table order, not payload order", async () => { + const requests: string[] = []; + stubFetch(async (input, init) => { + const path = input.toString().replace(BAPI_URL, ""); + const method = (init?.method ?? "GET").toUpperCase(); + if (method === "PATCH") requests.push(path); + if (path === "/v1/instance" && method === "PATCH") + return new Response(null, { status: 204 }); + return new Response(JSON.stringify(READABLE_BODIES[path] ?? {}), { status: 200 }); + }); + const { patchKeylessConfig } = await import("./keyless.ts"); + + await patchKeylessConfig( + { secretKey: SECRET_KEY, source: ".env" }, + { protect: { rules_enabled: true }, instance: { support_email: "a@b.com" } }, + ); + + expect(requests).toEqual(["/v1/instance", "/v1/instance/protect"]); + }); + + test("names the already-applied groups when a later group fails", async () => { + stubFetch(async (input, init) => { + const path = input.toString().replace(BAPI_URL, ""); + const method = (init?.method ?? "GET").toUpperCase(); + if (path === "/v1/instance/protect" && method === "PATCH") { + return new Response(JSON.stringify({ errors: [{ message: "nope" }] }), { status: 422 }); + } + if (path === "/v1/instance" && method === "PATCH") { + return new Response(null, { status: 204 }); + } + return new Response(JSON.stringify(READABLE_BODIES[path] ?? {}), { status: 200 }); + }); + const { patchKeylessConfig } = await import("./keyless.ts"); + + // `withApiContext` attaches the explanation as `error.context`, which the + // global handler prints alongside the API message. + await expect( + patchKeylessConfig( + { secretKey: SECRET_KEY, source: ".env" }, + { instance: { support_email: "a@b.com" }, protect: { rules_enabled: true } }, + ), + ).rejects.toMatchObject({ context: "Failed to update protect (already applied: instance)" }); + }); + + // A group having no GET route doesn't make its write unverifiable: what + // the PATCH itself answers with is the resource as it now stands, which is + // the only evidence the check ever uses. + test("verifies a write-only group from the body its own PATCH returns", async () => { + const { patchKeylessConfig } = await import("./keyless.ts"); + + const result = await patchKeylessConfig( + { secretKey: SECRET_KEY, source: ".env" }, + { restrictions: { allowlist: true } }, + ); + + expect(result.applied).toEqual({ + restrictions: { object: "instance_restrictions", allowlist: true }, + }); + expect(result.verification).toEqual({ + verifiedFields: ["restrictions.allowlist"], + droppedFields: [], + unverifiableGroups: [], + }); + }); + }); + + describe("config commands in a keyless project", () => { + beforeEach(async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + process.chdir(projectDir); + }); + + test("pull prints the BAPI envelope without an account", async () => { + const { configPull } = await import("./pull.ts"); + + await configPull({}); + + expect(captured.out).toContain(JSON.stringify(FULL_ENVELOPE, null, 2)); + }); + + test("patch applies the payload without an account", async () => { + const { configPatch } = await import("./push.ts"); + + await configPatch({ json: '{"instance":{"support_email":"new@example.com"}}', yes: true }); + + expect(captured.err).toContain("Config pushed successfully"); + }); + + test("patch --dry-run sends nothing", async () => { + const requests: string[] = []; + stubFetch(async (input, init) => { + requests.push(`${(init?.method ?? "GET").toUpperCase()} ${input.toString()}`); + return new Response(JSON.stringify(INSTANCE), { status: 200 }); + }); + const { configPatch } = await import("./push.ts"); + + await configPatch({ json: '{"instance":{"support_email":"new@example.com"}}', dryRun: true }); + + expect(requests.every((request) => request.startsWith("GET"))).toBe(true); + expect(captured.err).toContain("[dry-run] Nothing sent"); + }); + + test("patch rejects unreachable keys before showing a diff or touching the API", async () => { + let requested = false; + stubFetch(async () => { + requested = true; + return new Response(JSON.stringify(INSTANCE), { status: 200 }); + }); + const { configPatch } = await import("./push.ts"); + + await expect(configPatch({ json: '{"session":{"lifetime":1}}', yes: true })).rejects.toThrow( + /Unsupported config key/, + ); + expect(requested).toBe(false); + expect(captured.err).not.toContain("Updating config"); + }); + + test("patch rejects config keys BAPI cannot reach", async () => { + const { configPatch } = await import("./push.ts"); + + await expect( + configPatch({ json: '{"session":{"lifetime":3600}}', yes: true }), + ).rejects.toThrow(/Unsupported config key/); + }); + + test("put explains that a full replace needs a claimed application", async () => { + const { configPut } = await import("./push.ts"); + + await expect(configPut({ json: '{"instance":{}}', yes: true })).rejects.toThrow( + /Replacing the entire configuration is only available for a claimed application/, + ); + }); + + test("enable orgs applies organization settings without an account", async () => { + const requests: string[] = []; + stubFetch(async (input, init) => { + const path = input.toString().replace(BAPI_URL, ""); + const method = (init?.method ?? "GET").toUpperCase(); + requests.push(`${method} ${path}`); + if (method === "PATCH") { + // Reflect the write on the next read so the round-trip check confirms it. + bapiState[path] = { ...(bapiState[path] as object), ...JSON.parse(init?.body as string) }; + } + return new Response(JSON.stringify(bapiState[path] ?? ORG_SETTINGS), { status: 200 }); + }); + const { orgsEnable } = await import("../orgs/index.ts"); + + await orgsEnable({ yes: true, maxMembers: "7" }); + + expect(requests).toContain("PATCH /v1/instance/organization_settings"); + expect(captured.err).toContain("Organizations enabled"); + }); + + test("disable orgs works without an account and without the billing pre-flight", async () => { + // Start from orgs enabled, or the disable is a no-op and nothing is sent. + bapiState["/v1/instance/organization_settings"] = { ...ORG_SETTINGS, enabled: true }; + const requests: string[] = []; + stubFetch(async (input, init) => { + const path = input.toString().replace(BAPI_URL, ""); + const method = (init?.method ?? "GET").toUpperCase(); + requests.push(`${method} ${path}`); + if (method === "PATCH") { + bapiState[path] = { ...(bapiState[path] as object), ...JSON.parse(init?.body as string) }; + } + return new Response(JSON.stringify(bapiState[path] ?? ORG_SETTINGS), { status: 200 }); + }); + const { orgsDisable } = await import("../orgs/index.ts"); + + // Keyless has no account config document, so `current` is undefined and + // the org-billing pre-flight is skipped — this pins that the disable + // path tolerates that instead of reaching for `current.billing`. + await orgsDisable({ yes: true }); + + expect(requests).toContain("PATCH /v1/instance/organization_settings"); + // Every request is a bare BAPI path (the BAPI base URL was stripped) — + // a Platform API config fetch would show up here as a full foreign URL. + expect(requests.every((line) => / \/v1\//.test(line))).toBe(true); + expect(captured.err).toContain("Organizations disabled"); + }); + + test("whoami reports the keyless instance instead of demanding a login", async () => { + const { whoami } = await import("../whoami/index.ts"); + + await whoami({ json: true }); + + const payload = JSON.parse(captured.out); + expect(payload.email).toBeNull(); + expect(payload.keyless.instanceId).toBe("ins_1"); + expect(payload.keyless.keySource).toBe(".env"); + }); + + test("env pull writes the locally-held keyless keys", async () => { + await writeEnv( + ".env", + `CLERK_SECRET_KEY=${SECRET_KEY}\nCLERK_PUBLISHABLE_KEY=pk_test_local\n`, + ); + const { pull } = await import("../env/pull.ts"); + + await pull({ cwd: projectDir, file: join(projectDir, ".env.written") }); + + const written = await Bun.file(join(projectDir, ".env.written")).text(); + expect(written).toContain(`CLERK_SECRET_KEY=${SECRET_KEY}`); + expect(captured.err).toContain("Keyless application keys"); + }); + + test("enable billing explains that billing needs a claimed application", async () => { + const { billingEnable } = await import("../billing/index.ts"); + + await expect(billingEnable({ for: ["users"], yes: true })).rejects.toThrow( + /Billing can only be configured on a claimed application/, + ); + }); + + test("schema explains that it needs a claimed application", async () => { + const { configSchema } = await import("./schema.ts"); + + await expect(configSchema({})).rejects.toThrow( + /Config schema is only available for a claimed application/, + ); + }); + }); +}); diff --git a/packages/cli-core/src/commands/config/keyless.ts b/packages/cli-core/src/commands/config/keyless.ts new file mode 100644 index 000000000..f05de4852 --- /dev/null +++ b/packages/cli-core/src/commands/config/keyless.ts @@ -0,0 +1,360 @@ +/** + * Keyless config access — reading and updating an unclaimed keyless application + * through Clerk's Backend API, using only its instance secret key. + * + * The account-authenticated path (`lib/plapi.ts`) addresses one config document + * per instance. BAPI has no such document: it exposes independent resources. + * Rather than guess a mapping between the two shapes, the keyless payload + * mirrors BAPI directly — one top-level key per resource — so what you write is + * exactly what gets sent. + * + * Finding and addressing the application itself lives in `lib/keyless-target.ts`, + * which the feature-toggle, whoami, and env commands share. + */ + +import { bapiRequest } from "../../lib/bapi.ts"; +import { ERROR_CODE, throwUsageError, withApiContext } from "../../lib/errors.ts"; +import type { KeylessTarget } from "../../lib/keyless-target.ts"; +import { keylessCopy } from "../../lib/copy.ts"; +import { log } from "../../lib/log.ts"; + +/** + * BAPI resources reachable with an instance secret key, keyed by the name they + * take in a keyless config payload. `readable: false` marks a resource BAPI + * exposes for writes only (no GET route), so it never appears in a pull. + * + * Names follow the `object` field each endpoint returns, so what you write here + * matches what the API calls it. + */ +const KEYLESS_GROUPS = { + instance: { path: "/v1/instance", readable: true }, + communication: { path: "/v1/instance/communication", readable: true }, + restrictions: { path: "/v1/instance/restrictions", readable: false }, + organization_settings: { path: "/v1/instance/organization_settings", readable: true }, + protect: { path: "/v1/instance/protect", readable: true }, + oauth_application_settings: { path: "/v1/instance/oauth_application_settings", readable: true }, + // Backed by a beta route (`/v1/beta_features/instance_settings`) that updates + // the auth config: restricted_to_allowlist, from_email_address, + // progressive_sign_up, test_mode. + instance_settings: { path: "/v1/beta_features/instance_settings", readable: false }, +} as const; + +export type KeylessGroup = keyof typeof KEYLESS_GROUPS; + +export const KEYLESS_GROUP_NAMES = Object.keys(KEYLESS_GROUPS) as KeylessGroup[]; + +function isGroupName(name: string): name is KeylessGroup { + return name in KEYLESS_GROUPS; +} + +function isReadable(name: KeylessGroup): boolean { + return KEYLESS_GROUPS[name].readable; +} + +/** + * Top-level keys that aren't a config group but ARE reachable on an unclaimed + * keyless application — BAPI resource collections with their own routes, + * verified live (`clerk api /enterprise_connections` lists and creates them + * with just an instance secret key). Naming these in the "claim the app" + * advice would send someone on a detour they don't need: `clerk api` already + * works, and claiming wouldn't add them to the config document either. + */ +const API_REACHABLE_KEYLESS_KEYS = new Set([ + "enterprise_connections", + "saml_connections", + "oauth_applications", + "domains", + "allowlist_identifiers", + "blocklist_identifiers", +]); + +/** + * Every field `PATCH /v1/instance` accepts, from the Backend API's own schema + * (`additionalProperties: false`). + * + * This is the one group whose write can't be checked against anything: it + * answers 204 with no body, so an unrecognised field name is indistinguishable + * from an applied one — BAPI drops what it doesn't know rather than rejecting + * it. Naming the fields here moves that failure forward to a usage error, and + * is worth the maintenance precisely because the alternative is silent. Sending + * `{"instance": {"password": "on"}}` used to report success and change nothing. + * + * Every other group echoes its new state back, so a typo there already surfaces + * as a dropped field and needs no list. + */ +const INSTANCE_FIELDS = new Set([ + "test_mode", + "hibp", + "support_email", + "clerk_js_version", + "development_origin", + "allowed_origins", + "cookieless_dev", + "url_based_session_syncing", + "preferred_sign_in_strategy_when_password_required", +]); + +/** + * Auth settings people reach for on the `instance` group that BAPI has no route + * for at all, mapped to the reason. Worth naming individually: "unsupported + * field" reads like a typo, and someone who just tried to turn on GitHub sign-in + * deserves to know no amount of retyping will do it. + */ +const ACCOUNT_ONLY_INSTANCE_FIELDS: Record = { + password: "which authentication strategies are enabled", + phone_number: "which authentication strategies are enabled", + username: "which authentication strategies are enabled", + email_address: "which authentication strategies are enabled", + passkey: "which authentication strategies are enabled", + social: "social sign-in providers", + oauth: "social sign-in providers", + second_factors: "multi-factor authentication policy", + application_name: "the application's name and branding", +}; + +/** + * Rejects `instance` fields BAPI would silently discard, before the request is + * sent. Runs only for that group — see `INSTANCE_FIELDS`. + */ +function assertInstanceFields(fields: Record): void { + const unknown = Object.keys(fields).filter((field) => !INSTANCE_FIELDS.has(field)); + if (unknown.length === 0) return; + + const lines = [ + keylessCopy.unsupportedInstanceFieldsLine(unknown), + keylessCopy.supportedInstanceFieldsLine([...INSTANCE_FIELDS]), + ]; + + // Say why, once per distinct reason, for the fields people actually try. + const reasons = [ + ...new Set( + unknown + .map((field) => ACCOUNT_ONLY_INSTANCE_FIELDS[field]) + .filter((reason): reason is string => reason !== undefined), + ), + ]; + for (const reason of reasons) { + lines.push(keylessCopy.noRouteForInstanceFieldLine(reason)); + } + + throwUsageError(lines.join("\n")); +} + +/** + * Validates caller-supplied names once, at the boundary, so everything + * downstream works with a known group instead of re-checking strings. + */ +function asGroupNames(names: string[]): KeylessGroup[] { + const unknown = names.filter((name) => !isGroupName(name)); + if (unknown.length > 0) { + throwUsageError(keylessCopy.unsupportedConfigKeys(unknown, KEYLESS_GROUP_NAMES)); + } + return names.filter(isGroupName); +} + +/** Rejects payload keys that don't name a BAPI resource, before anything is sent. */ +export function assertKeylessPayload( + payload: Record, +): asserts payload is Record> { + const unknown = Object.keys(payload).filter((key) => !(key in KEYLESS_GROUPS)); + if (unknown.length > 0) { + const apiReachable = unknown.filter((key) => API_REACHABLE_KEYLESS_KEYS.has(key)); + const accountOnly = unknown.filter((key) => !API_REACHABLE_KEYLESS_KEYS.has(key)); + + const lines = [ + keylessCopy.unsupportedPayloadKeysLine(unknown), + keylessCopy.supportedPayloadKeysLine(KEYLESS_GROUP_NAMES), + ]; + + // Point these at `clerk api` — they're reachable today, and claiming the + // application wouldn't move them into the config document anyway. + if (apiReachable.length > 0) { + lines.push(keylessCopy.apiReachableKeysLine(apiReachable)); + } + + // Everything left really is part of the account-mode config document. + if (accountOnly.length > 0) { + lines.push(keylessCopy.claimForFullConfigLine()); + } + + throwUsageError(lines.join("\n")); + } + + for (const [key, value] of Object.entries(payload)) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throwUsageError(keylessCopy.configKeyMustBeObject(key), undefined, ERROR_CODE.INVALID_JSON); + } + } + + if (payload.instance) { + assertInstanceFields(payload.instance as Record); + } +} + +/** + * Reads every readable group into a single envelope. Write-only groups are + * skipped — a caller that asked for one by name is told rather than left to + * wonder where it went. + */ +export async function pullKeylessConfig( + target: KeylessTarget, + keys?: string[], +): Promise> { + const requested = keys?.length ? asGroupNames(keys) : KEYLESS_GROUP_NAMES; + + // Only worth saying when the caller named a write-only group. A default pull + // asks for everything, and reporting the same omission every time is noise. + const unreadable = keys?.length ? requested.filter((name) => !isReadable(name)) : []; + if (unreadable.length > 0) { + log.warn( + `Clerk's Backend API has no read route for ${unreadable.join(", ")} — omitted from the output.`, + ); + } + + const config: Record = {}; + for (const name of requested.filter(isReadable)) { + const response = await withApiContext( + bapiRequest({ method: "GET", path: KEYLESS_GROUPS[name].path, secretKey: target.secretKey }), + `Failed to fetch ${name}`, + ); + config[name] = response.body; + } + + return config; +} + +export interface KeylessWriteVerification { + /** Dotted `group.field` paths confirmed to hold the value that was sent. */ + verifiedFields: string[]; + /** + * Dotted `group.field` paths the API's own response to the write doesn't + * reflect — a 200 there means "request accepted", not "field applied", and + * BAPI silently drops fields it doesn't recognize instead of rejecting them. + */ + droppedFields: string[]; + /** Groups whose write answered with no body, so it can't be confirmed either way. */ + unverifiableGroups: KeylessGroup[]; +} + +export interface KeylessPatchResult { + /** Per-group state as reported back by the API — same envelope shape a pull returns. */ + applied: Record; + verification: KeylessWriteVerification; +} + +/** + * Walks every leaf the caller sent and records whether the observed value + * (the API's own read-back) matches it. `sent` is always an object — payload + * groups are validated by `assertKeylessPayload` before this runs — so only + * `observed` needs a runtime check; a group BAPI dropped won't have it. + */ +function collectVerifiedLeaves( + sent: Record, + observed: unknown, + path: string, + out: { path: string; matched: boolean }[], +): void { + const observedObj = + observed !== null && typeof observed === "object" && !Array.isArray(observed) + ? (observed as Record) + : undefined; + + for (const [key, value] of Object.entries(sent)) { + const fieldPath = path ? `${path}.${key}` : key; + const observedValue = observedObj?.[key]; + + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + collectVerifiedLeaves(value as Record, observedValue, fieldPath, out); + continue; + } + + out.push({ path: fieldPath, matched: JSON.stringify(value) === JSON.stringify(observedValue) }); + } +} + +/** + * Applies each group in the payload to its own BAPI resource and returns what + * the API reported back, plus which of the sent fields that report actually + * confirms landed. + * + * Verification uses the PATCH response body and nothing else. A follow-up GET + * looks like better evidence and isn't: BAPI omits writable-but-not-readable + * fields from its reads (`instance.support_email` is never echoed), and reads + * are eventually consistent, so a GET issued straight after a write can still + * be showing the old value. Either would report a perfectly good write as + * dropped. + * + * `PATCH /v1/instance` answers 204 with no body at all, and that group is + * reported with no state rather than re-read. An earlier version did re-read it + * "so the caller sees something", which turned out to be the worst of both: the + * eventually-consistent GET routinely returned the pre-write value, and printing + * it directly under `Config pushed successfully` read as though the write had + * been ignored. Nothing is more honest than a stale something here — the fields + * that group accepts are validated before the request goes out, so a 204 is + * already good evidence the write landed. + */ +export async function patchKeylessConfig( + target: KeylessTarget, + payload: Record>, +): Promise { + const results: Record = {}; + const verifiedFields: string[] = []; + const droppedFields: string[] = []; + const unverifiableGroups: KeylessGroup[] = []; + // Tracked separately from `results`, which only carries groups that returned + // state to show. A group can be applied and still contribute nothing to the + // envelope, and a later failure has to name it regardless. + const appliedGroups: KeylessGroup[] = []; + + // Each group is its own request and the Backend API has no transaction, so a + // failure part-way through leaves earlier groups applied. Name them in the + // error rather than letting the user guess how far it got. + for (const name of KEYLESS_GROUP_NAMES.filter((group) => group in payload)) { + const group = KEYLESS_GROUPS[name]; + const context = + appliedGroups.length > 0 + ? `Failed to update ${name} (already applied: ${appliedGroups.join(", ")})` + : `Failed to update ${name}`; + + const response = await withApiContext( + bapiRequest({ + method: "PATCH", + path: group.path, + secretKey: target.secretKey, + body: JSON.stringify(payload[name]), + }), + context, + ); + + const written = response.body ?? null; + appliedGroups.push(name); + + if (!written) { + // Nothing came back to check against, and nothing worth inventing: see + // the note above on why the re-read that used to live here was removed. + // The group is left out of the envelope entirely rather than shown as + // null, and the success line names it as unconfirmed. + unverifiableGroups.push(name); + continue; + } + + results[name] = written; + + const leaves: { path: string; matched: boolean }[] = []; + collectVerifiedLeaves(payload[name] ?? {}, written, "", leaves); + for (const leaf of leaves) { + (leaf.matched ? verifiedFields : droppedFields).push(`${name}.${leaf.path}`); + } + } + + return { applied: results, verification: { verifiedFields, droppedFields, unverifiableGroups } }; +} + +/** Current state of the groups a payload touches, for diffing before a write. */ +export async function readCurrentGroups( + target: KeylessTarget, + payload: Record, +): Promise> { + const readable = Object.keys(payload).filter(isGroupName).filter(isReadable); + return readable.length > 0 ? pullKeylessConfig(target, readable) : {}; +} diff --git a/packages/cli-core/src/commands/config/pull.ts b/packages/cli-core/src/commands/config/pull.ts index 64a68dbd3..3cd713295 100644 --- a/packages/cli-core/src/commands/config/pull.ts +++ b/packages/cli-core/src/commands/config/pull.ts @@ -1,8 +1,9 @@ -import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfig } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; +import { resolveInstanceTarget } from "../../lib/keyless-target.ts"; +import { pullKeylessConfig } from "./keyless.ts"; interface ConfigPullOptions { app?: string; @@ -13,15 +14,15 @@ interface ConfigPullOptions { export async function configPull(options: ConfigPullOptions): Promise { await withGutter("Pulling configuration", async () => { - const ctx = await resolveAppContext(options); + const target = await resolveInstanceTarget(options); - const config = await withSpinner( - `Pulling config from ${ctx.appLabel} (${ctx.instanceLabel})...`, - () => - withApiContext( - fetchInstanceConfig(ctx.appId, ctx.instanceId, options.keys), - "Failed to fetch config", - ), + const config = await withSpinner(`Pulling config from ${target.label}...`, () => + target.kind === "keyless" + ? pullKeylessConfig(target.keyless, options.keys) + : withApiContext( + fetchInstanceConfig(target.ctx.appId, target.ctx.instanceId, options.keys), + "Failed to fetch config", + ), ); const json = JSON.stringify(config, null, 2); diff --git a/packages/cli-core/src/commands/config/push.ts b/packages/cli-core/src/commands/config/push.ts index 36c61be1c..e60656191 100644 --- a/packages/cli-core/src/commands/config/push.ts +++ b/packages/cli-core/src/commands/config/push.ts @@ -1,19 +1,28 @@ -import { resolveAppContext } from "../../lib/config.ts"; -import { fetchInstanceConfig, putInstanceConfig, patchInstanceConfig } from "../../lib/plapi.ts"; import { isHuman } from "../../mode.ts"; import { + CliError, UserAbortError, isPromptExitError, throwUsageError, throwUserAbort, - withApiContext, ERROR_CODE, } from "../../lib/errors.ts"; import { confirm } from "../../lib/prompts.ts"; import { dim, bold, red, green } from "../../lib/color.ts"; import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; +import { keylessCopy } from "../../lib/copy.ts"; import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { resolveInstanceTarget } from "../../lib/keyless-target.ts"; +import type { KeylessWriteVerification } from "./keyless.ts"; +import { + assertPayloadWritable, + LOCAL_DRY_RUN_MESSAGE, + readInstanceConfig, + supportsServerDryRun, + writeInstanceConfig, + type ConfigMethod, +} from "./io.ts"; interface ConfigPushOptions { app?: string; @@ -26,15 +35,9 @@ interface ConfigPushOptions { } type Operation = { - method: "PUT" | "PATCH"; + method: ConfigMethod; verb: string; warning?: string; - apiFn: ( - appId: string, - instId: string, - config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, - ) => Promise>; title: string; }; @@ -42,14 +45,12 @@ const PUT_OP: Operation = { method: "PUT", verb: "Replacing", warning: "This will overwrite the entire instance configuration.", - apiFn: putInstanceConfig, title: "Replacing configuration", }; const PATCH_OP: Operation = { method: "PATCH", verb: "Updating", - apiFn: patchInstanceConfig, title: "Patching configuration", }; @@ -62,26 +63,16 @@ export async function configPatch(options: ConfigPushOptions): Promise { } async function configPush(options: ConfigPushOptions, op: Operation): Promise { - const ctx = await resolveAppContext(options); - const rawInput = await readInput(options); - - let configPayload: Record; - try { - configPayload = JSON.parse(rawInput); - } catch { - throwUsageError( - "Invalid JSON input. Please provide valid JSON.", - undefined, - ERROR_CODE.INVALID_JSON, - ); - } + const target = await resolveInstanceTarget(options); - if (typeof configPayload !== "object" || configPayload === null || Array.isArray(configPayload)) { - throwUsageError("Config must be a JSON object.", undefined, ERROR_CODE.INVALID_JSON); + if (target.kind === "keyless" && op.method === "PUT") { + throw new CliError(keylessCopy.putNeedsClaimedApplication(), { + code: ERROR_CODE.AUTH_REQUIRED, + }); } - // Strip config_version — it's returned by pull but not accepted by the backend - delete configPayload.config_version; + const configPayload = parsePayload(await readInput(options)); + assertPayloadWritable(target, configPayload); const shouldWrap = !isInsideGutter(); if (shouldWrap) intro(op.title); @@ -89,10 +80,7 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise - withApiContext( - fetchInstanceConfig(ctx.appId, ctx.instanceId), - "Failed to fetch current config", - ), + readInstanceConfig(target, configPayload), ); delete currentConfig.config_version; @@ -105,9 +93,16 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise - withApiContext( - op.apiFn(ctx.appId, ctx.instanceId, configPayload, { - destructive: options.destructive, - dryRun: options.dryRun, - }), - options.dryRun ? "Dry-run failed" : "Failed to push config", - ), - ); - log.data(JSON.stringify(result, null, 2)); - log.success( - options.dryRun - ? "[dry-run] Validation passed — no changes applied" - : "Config pushed successfully", + writeInstanceConfig(target, configPayload, { + method: op.method, + destructive: options.destructive, + dryRun: options.dryRun, + failureContext: "Failed to push config", + }), ); + log.data(JSON.stringify(result.body, null, 2)); if (options.dryRun) { + log.success("[dry-run] Validation passed — no changes applied"); printNextSteps( op.method === "PATCH" ? NEXT_STEPS.CONFIG_DRY_RUN_PATCH : NEXT_STEPS.CONFIG_DRY_RUN_PUT, ); } else { + reportWriteOutcome(result.verification, "Config pushed successfully"); printNextSteps(NEXT_STEPS.CONFIG_PUSH); } closeStatus = "success"; @@ -160,6 +151,28 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise { + let payload: Record; + try { + payload = JSON.parse(rawInput); + } catch { + throwUsageError( + "Invalid JSON input. Please provide valid JSON.", + undefined, + ERROR_CODE.INVALID_JSON, + ); + } + + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + throwUsageError("Config must be a JSON object.", undefined, ERROR_CODE.INVALID_JSON); + } + + // Strip config_version — it's returned by pull but not accepted by the backend + delete payload.config_version; + return payload; +} + export async function readInput(options: { file?: string; json?: string }): Promise { if (options.json) { return options.json; @@ -309,3 +322,41 @@ export function printDiff( } } } + +/** + * Prints what actually took after a write, instead of an unconditional + * success line. Account-mode writes have no `verification` — the Platform + * API's response body is the config document, trusted outright. A keyless + * write only gets a 200/204 for "the request was accepted": Clerk's Backend + * API silently drops fields it doesn't recognize inside a group rather than + * rejecting them, so dropped fields are named instead of folded into a + * "successfully" that isn't true for them. + */ +export function reportWriteOutcome( + verification: KeylessWriteVerification | undefined, + successMessage: string, +): void { + if (!verification) { + log.success(successMessage); + return; + } + + const { droppedFields, unverifiableGroups } = verification; + + if (droppedFields.length > 0) { + const [one, them] = + droppedFields.length === 1 ? ["This field", "it"] : ["These fields", "them"]; + log.warn( + `${one} didn't come back in Clerk's Backend API response: ${droppedFields.join(", ")}. ` + + `The API ignores field names it doesn't recognise rather than rejecting them, so check ${them} for a typo against the diff above.`, + ); + } + + // Always close with a success line: the write was accepted, and a run that + // ends on a warning alone reads as a failure that never happened. + log.success( + unverifiableGroups.length > 0 + ? `${successMessage} — ${unverifiableGroups.join(", ")} answered with no body, so ${unverifiableGroups.length === 1 ? "that group" : "those groups"} couldn't be confirmed` + : successMessage, + ); +} diff --git a/packages/cli-core/src/commands/config/schema.ts b/packages/cli-core/src/commands/config/schema.ts index 4762f4970..6102dfa77 100644 --- a/packages/cli-core/src/commands/config/schema.ts +++ b/packages/cli-core/src/commands/config/schema.ts @@ -1,8 +1,9 @@ -import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfigSchema } from "../../lib/plapi.ts"; -import { withApiContext } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, withApiContext } from "../../lib/errors.ts"; import { withGutter } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; +import { keylessCopy } from "../../lib/copy.ts"; +import { resolveInstanceTarget } from "../../lib/keyless-target.ts"; interface ConfigSchemaOptions { app?: string; @@ -13,7 +14,15 @@ interface ConfigSchemaOptions { export async function configSchema(options: ConfigSchemaOptions): Promise { await withGutter("Fetching configuration schema", async () => { - const ctx = await resolveAppContext(options); + // Same shape as config push/put: resolve the target once, branch on kind. + const target = await resolveInstanceTarget(options); + if (target.kind === "keyless") { + throw new CliError(keylessCopy.schemaNeedsClaimedApplication(), { + code: ERROR_CODE.AUTH_REQUIRED, + }); + } + + const ctx = target.ctx; log.info(`Pulling config schema from ${ctx.appLabel} (${ctx.instanceLabel})...`); diff --git a/packages/cli-core/src/commands/doctor/README.md b/packages/cli-core/src/commands/doctor/README.md index 8eef28a31..443043cc7 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -37,6 +37,34 @@ clerk doctor --fix # Offer to auto-fix issues | Shell completion | Configuration | Shell autocompletion is installed for the detected shell | | MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers the `initialize` handshake; warns on an unreadable client config (skipped when nothing is installed; warns, never fails) | +### Keyless applications + +The Authentication token, Token validity, and Project linkage checks resolve +the same keyless fallback the rest of the CLI uses (`lib/keyless-target.ts`): +a project with no account session and no linked profile, but a `sk_...` key +on disk (or in `CLERK_SECRET_KEY`/framework env var), is running on an +**unclaimed keyless application** — a legitimate, healthy state, not a broken +one. + +- No token, keyless key present → **pass**, naming the instance. The claim + hint depends on where the app came from: with a `.clerk/keyless.json` + breadcrumb (left by `clerk init`) it says `clerk auth login` claims it; + without one — an SDK-minted `.clerk/.tmp/keyless.json`, or a hand-copied + `CLERK_SECRET_KEY` — it says to claim from the Clerk Dashboard instead, + because `clerk auth login` only auto-claims apps `clerk init` created. +- Stored session expired, keyless key present → **warn** (not fail): the + keyless key still works, logging in again is optional. +- Signed in (has account credentials) but this directory isn't linked, keyless + key present → **warn**: the account could reach the fuller configuration by + running `clerk link`, so that's called out unlike the fully unclaimed case. +- No token **and** no keyless key found anywhere → still **fail**. Keyless + only changes the outcome when there's actually a secret key to fall back to. + +The Linked application and Instances checks are account-only (the Platform +API application/instance-list concepts have no keyless equivalent), so they +continue to skip for a keyless project — the skip reason names the keyless +application instead of reading like a problem. + ## Auto-Fix (`--fix`) When `--fix` is passed in human mode, the command prompts to fix each @@ -89,7 +117,8 @@ Exit code 1 signals one or more checks failed. ## API Endpoints -| Method | Endpoint | Description | -| ------ | ----------------------------------- | ----------------------------------------------- | -| `GET` | `/oauth/userinfo` | Validates the stored auth token | -| `GET` | `/v1/platform/applications/{appId}` | Verifies the linked app and its instances exist | +| Method | Endpoint | Description | +| ------ | ----------------------------------- | --------------------------------------------------------------- | +| `GET` | `/oauth/userinfo` | Validates the stored auth token | +| `GET` | `/v1/platform/applications/{appId}` | Verifies the linked app and its instances exist | +| `GET` | `/v1/instance` | Names the keyless application (best-effort, via its secret key) | diff --git a/packages/cli-core/src/commands/doctor/checks.ts b/packages/cli-core/src/commands/doctor/checks.ts index b8bf9afad..6fa63ec35 100644 --- a/packages/cli-core/src/commands/doctor/checks.ts +++ b/packages/cli-core/src/commands/doctor/checks.ts @@ -5,6 +5,8 @@ import { fetchUserInfo } from "../../lib/token-exchange.ts"; import { errorMessage, isAuthError, PlapiError } from "../../lib/errors.ts"; import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts"; import { parseEnvFile } from "../../lib/dotenv.ts"; +import { hasAccountCredentials } from "../../lib/credential-store.ts"; +import type { KeylessTarget } from "../../lib/keyless-target.ts"; import { getCurrentVersion, getUpdateChannel, @@ -17,7 +19,7 @@ import { } from "../../lib/update-check.ts"; import { formatHostStateProbeFailures, getAgentHostStateProbe } from "../../lib/host-execution.ts"; import { isAgent } from "../../mode.ts"; -import type { CheckResult, DoctorContext, FixAction } from "./types.ts"; +import type { CheckResult, DoctorContext, FixAction, KeylessInstanceInfo } from "./types.ts"; interface CheckOptions { remedy?: string; @@ -66,15 +68,74 @@ function defineCheck(name: string, fixFactory?: () => FixAction): CheckBuilder { }; } +/** How to refer to an unclaimed keyless application in check output. */ +function keylessLabel(keyless: KeylessTarget, instance: KeylessInstanceInfo | null): string { + const name = instance?.id ? `\`${instance.id}\`` : "this application"; + const env = instance?.environmentType ? ` (${instance.environmentType})` : ""; + return `${name}${env} — secret key from \`${keyless.source}\``; +} + +/** + * How this particular application can be claimed, which is not one answer. + * `clerk auth login` only claims silently when `clerk init` left a claim token + * behind; a key that arrived any other way (an SDK's own keyless.json, a + * hand-copied secret) has no token to redeem and has to be claimed from the + * dashboard instead. + * + * This rides in the check's message rather than its detail: `detail` only + * renders under `--verbose`, and guidance nobody sees by default isn't + * guidance. + */ +async function claimHint(ctx: DoctorContext): Promise { + return (await ctx.hasClaimBreadcrumb()) + ? "Run `clerk auth login` to claim it." + : "Claim it from the Clerk Dashboard — `clerk auth login` only claims applications `clerk init` created."; +} + export async function checkLoggedIn(ctx: DoctorContext): Promise { const check = defineCheck("Logged in", ctx.fixes.login); const token = await ctx.getToken(); - if (!token) { - return check.fail("Not logged in", { - remedy: "Run `clerk auth login` to authenticate.", + + // Malformed-key detection is a side effect of resolving the keyless target + // (see getKeylessKeyError), so resolve it before any early return — a + // stored account token must not hide a broken local CLERK_SECRET_KEY that + // other commands still prefer over the account session. + const keyless = await ctx.getKeylessTarget(); + const keyError = await ctx.getKeylessKeyError(); + + if (token) { + if (keyError) { + return check.warn(`Logged in, but the local secret key is unusable: ${keyError.message}`, { + remedy: + "Fix or remove the malformed key — some commands prefer it over your account session.", + fixable: false, + }); + } + return check.pass("Logged in (token found in credential store)"); + } + + // No account session doesn't mean the project is broken: an unclaimed + // keyless application is a legitimate, healthy way to run the CLI. + if (keyless) { + const instance = await ctx.getKeylessInstance(); + return check.pass( + `Not logged in — running on the unclaimed keyless application ${keylessLabel(keyless, instance)}. ${await claimHint(ctx)}`, + ); + } + + // A local key that isn't a secret key at all is the one keyless state that + // is genuinely broken — report it here as the named diagnosis, once, rather + // than letting it crash every keyless-aware check (see getKeylessKeyError). + if (keyError) { + return check.fail(`Not logged in, and the local secret key is unusable: ${keyError.message}`, { + remedy: "Fix or remove the malformed key, or run `clerk auth login` to authenticate.", + fixable: false, }); } - return check.pass("Logged in (token found in credential store)"); + + return check.fail("Not logged in", { + remedy: "Run `clerk auth login` to authenticate.", + }); } export async function checkHostExecution(): Promise { @@ -99,7 +160,12 @@ export async function checkHostExecution(): Promise { export async function checkTokenValid(ctx: DoctorContext): Promise { const check = defineCheck("Authentication valid", ctx.fixes.login); const storedToken = await ctx.getToken(); - if (!storedToken) return check.skip("no token"); + if (!storedToken) { + const keyless = await ctx.getKeylessTarget(); + return keyless + ? check.pass("No account session — not required for this keyless application") + : check.skip("no token"); + } try { const token = await ctx.getValidToken(); @@ -108,6 +174,21 @@ export async function checkTokenValid(ctx: DoctorContext): Promise return check.pass(`Authenticated as ${userInfo.email}`); } catch (error) { if (isAuthError(error)) { + // Same fallback whoami uses: an expired session doesn't strand a keyless + // project, so don't tell the user their setup is broken. + const keyless = await ctx.getKeylessTarget(); + if (keyless) { + const instance = await ctx.getKeylessInstance(); + return check.warn( + `Stored session is expired — falling back to the keyless application ${keylessLabel(keyless, instance)}`, + { + remedy: + "Run `clerk auth login` to re-authenticate your account (optional for keyless work).", + fixable: false, + }, + ); + } + return check.fail("Token is expired or invalid", { remedy: "Run `clerk auth login` to re-authenticate.", }); @@ -126,29 +207,61 @@ export async function checkTokenValid(ctx: DoctorContext): Promise export async function checkProjectLinked(ctx: DoctorContext): Promise { const check = defineCheck("Project linked", ctx.fixes.link); const resolved = await ctx.getProfile(); - if (!resolved) { - return check.fail("Not linked to a Clerk application", { - remedy: "Run `clerk link` to associate this project with a Clerk app.", - }); + if (resolved) { + const RESOLUTION_LABELS: Record = { + remote: "git remote", + "git-common-dir": "git repo", + directory: "directory", + }; + const via = `via ${RESOLUTION_LABELS[resolved.resolvedVia] ?? resolved.resolvedVia} (${resolved.path})`; + + return check.pass( + `Linked ${via}`, + `Workspace: ${resolved.profile.workspaceId || "(none)"}\nDev instance: ${resolved.profile.instances.development}\nProd instance: ${resolved.profile.instances.production ?? "(not set)"}`, + ); } - const RESOLUTION_LABELS: Record = { - remote: "git remote", - "git-common-dir": "git repo", - directory: "directory", - }; - const via = `via ${RESOLUTION_LABELS[resolved.resolvedVia] ?? resolved.resolvedVia} (${resolved.path})`; + // Unlinked isn't automatically broken: a project running on an unclaimed + // keyless application has nothing to link yet. + const keyless = await ctx.getKeylessTarget(); + if (keyless) { + const instance = await ctx.getKeylessInstance(); + const label = keylessLabel(keyless, instance); + + // Someone with an account who hasn't linked this directory *could* reach + // the full account configuration — say so, unlike the fully unclaimed case. + if (await hasAccountCredentials()) { + return check.warn( + `Not linked — using the keyless application ${label}, which covers fewer settings`, + { + remedy: "Run `clerk link` to use the full account configuration.", + fixable: true, + }, + ); + } - return check.pass( - `Linked ${via}`, - `Workspace: ${resolved.profile.workspaceId || "(none)"}\nDev instance: ${resolved.profile.instances.development}\nProd instance: ${resolved.profile.instances.production ?? "(not set)"}`, - ); + return check.pass( + `Not linked — running on the unclaimed keyless application ${label}. ${await claimHint(ctx)}`, + ); + } + + return check.fail("Not linked to a Clerk application", { + remedy: "Run `clerk link` to associate this project with a Clerk app.", + }); } export async function checkLinkedAppExists(ctx: DoctorContext): Promise { const check = defineCheck("Application reachable", ctx.fixes.link); const token = await ctx.getToken(); - if (!token) return check.skip("not authenticated"); + if (!token) { + // This check is account-only — the Platform API application record has no + // keyless equivalent — so an unclaimed keyless project has nothing to skip + // *over*, just nothing to verify. + const keyless = await ctx.getKeylessTarget(); + return check.skip( + keyless ? "keyless application, no linked app to verify" : "not authenticated", + ); + } const resolved = await ctx.getProfile(); if (!resolved) return check.skip("no project linked"); @@ -175,7 +288,14 @@ export async function checkLinkedAppExists(ctx: DoctorContext): Promise { const check = defineCheck("Instance IDs", ctx.fixes.link); const token = await ctx.getToken(); - if (!token) return check.skip("not authenticated"); + if (!token) { + // A linked profile's dev/prod instance IDs are an account-only concept — + // the secret key on disk already addresses its one instance directly. + const keyless = await ctx.getKeylessTarget(); + return check.skip( + keyless ? "keyless application, no linked instances to verify" : "not authenticated", + ); + } const resolved = await ctx.getProfile(); if (!resolved) return check.skip("no project linked"); diff --git a/packages/cli-core/src/commands/doctor/context.test.ts b/packages/cli-core/src/commands/doctor/context.test.ts index d0e852eb8..878aa731f 100644 --- a/packages/cli-core/src/commands/doctor/context.test.ts +++ b/packages/cli-core/src/commands/doctor/context.test.ts @@ -1,5 +1,11 @@ import { test, expect, describe, mock, spyOn, beforeEach, afterEach, afterAll } from "bun:test"; -import { useCaptureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; +import { + useCaptureLog, + credentialStoreStubs, + gitStubs, + keylessTargetStubs, + stubFetch, +} from "../../test/lib/stubs.ts"; import * as config from "../../lib/config.ts"; import type { Application } from "../../lib/plapi.ts"; @@ -20,6 +26,17 @@ afterAll(() => resolveProfileSpy.mockRestore()); mock.module("../../lib/git.ts", () => gitStubs); +const mockResolveKeylessTarget = mock(); +mock.module("../../lib/keyless-target.ts", () => ({ + ...keylessTargetStubs, + resolveKeylessTarget: (...args: unknown[]) => mockResolveKeylessTarget(...args), +})); + +const mockBapiRequest = mock(); +mock.module("../../lib/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + // stubFetch instead of mock.module for plapi — mock.module leaks globally in Bun let mockAppResponse: Application | null = null; let mockAppError: Error | null = null; @@ -48,6 +65,10 @@ describe("createDoctorContext", () => { stubFetch((...args: unknown[]) => mockFetch(...args)); process.env.CLERK_PLATFORM_API_KEY = "test_key"; + + mockResolveKeylessTarget.mockReset(); + mockResolveKeylessTarget.mockResolvedValue(undefined); + mockBapiRequest.mockReset(); }); afterEach(() => { @@ -56,6 +77,8 @@ describe("createDoctorContext", () => { mockGetToken.mockReset(); mockResolveProfile.mockReset(); mockFetch.mockReset(); + mockResolveKeylessTarget.mockReset(); + mockBapiRequest.mockReset(); }); describe("getToken", () => { @@ -149,6 +172,77 @@ describe("createDoctorContext", () => { }); }); + describe("getKeylessTarget", () => { + test("returns the same promise on repeated calls", async () => { + const target = { secretKey: "sk_test_keyless", source: ".env.local" }; + mockResolveKeylessTarget.mockResolvedValue(target); + + const ctx = createDoctorContext(); + const p1 = ctx.getKeylessTarget(); + const p2 = ctx.getKeylessTarget(); + + expect(p1).toBe(p2); + expect(await p1).toEqual(target); + expect(mockResolveKeylessTarget).toHaveBeenCalledTimes(1); + }); + + test("returns undefined when no keyless target resolves", async () => { + mockResolveKeylessTarget.mockResolvedValue(undefined); + + const ctx = createDoctorContext(); + expect(await ctx.getKeylessTarget()).toBeUndefined(); + }); + }); + + describe("getKeylessInstance", () => { + test("returns null without hitting BAPI when there is no keyless target", async () => { + mockResolveKeylessTarget.mockResolvedValue(undefined); + + const ctx = createDoctorContext(); + const result = await ctx.getKeylessInstance(); + + expect(result).toBeNull(); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("fetches instance info via the keyless secret key, only once", async () => { + mockResolveKeylessTarget.mockResolvedValue({ + secretKey: "sk_test_keyless", + source: ".env.local", + }); + mockBapiRequest.mockResolvedValue({ + status: 200, + body: { id: "ins_keyless_1", environment_type: "development" }, + }); + + const ctx = createDoctorContext(); + const p1 = ctx.getKeylessInstance(); + const p2 = ctx.getKeylessInstance(); + + expect(p1).toBe(p2); + expect(await p1).toEqual({ id: "ins_keyless_1", environmentType: "development" }); + expect(mockBapiRequest).toHaveBeenCalledTimes(1); + expect(mockBapiRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + path: "/v1/instance", + secretKey: "sk_test_keyless", + }), + ); + }); + + test("returns null (not a throw) when the instance fetch fails", async () => { + mockResolveKeylessTarget.mockResolvedValue({ + secretKey: "sk_test_keyless", + source: ".env.local", + }); + mockBapiRequest.mockRejectedValue(new Error("network down")); + + const ctx = createDoctorContext(); + expect(await ctx.getKeylessInstance()).toBeNull(); + }); + }); + describe("fixes", () => { test("fix factories return FixAction objects with labels", () => { const ctx = createDoctorContext(); diff --git a/packages/cli-core/src/commands/doctor/context.ts b/packages/cli-core/src/commands/doctor/context.ts index 9bcde632d..a9abb6881 100644 --- a/packages/cli-core/src/commands/doctor/context.ts +++ b/packages/cli-core/src/commands/doctor/context.ts @@ -1,13 +1,22 @@ import { getToken, getValidToken } from "../../lib/credential-store.ts"; import { resolveProfile } from "../../lib/config.ts"; import { fetchApplication, type Application } from "../../lib/plapi.ts"; -import type { DoctorContext, ResolvedProfile } from "./types.ts"; +import { resolveKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts"; +import { peekKeylessBreadcrumb } from "../../lib/keyless.ts"; +import { bapiRequest } from "../../lib/bapi.ts"; +import { log } from "../../lib/log.ts"; +import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts"; +import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./types.ts"; export function createDoctorContext(): DoctorContext { let tokenPromise: Promise | undefined; let validTokenPromise: Promise | undefined; let profilePromise: Promise | undefined; let appPromise: Promise | undefined; + let keylessPromise: Promise | undefined; + let keylessInstancePromise: Promise | undefined; + let claimBreadcrumbPromise: Promise | undefined; + let keylessKeyError: CliError | undefined; const ctx: DoctorContext = { getToken() { @@ -44,6 +53,66 @@ export function createDoctorContext(): DoctorContext { return appPromise; }, + getKeylessTarget() { + if (!keylessPromise) { + // Same resolution every other command uses: an explicit --app/link rules + // keyless out, but account credentials alone don't (see keyless-target.ts). + // + // A malformed local key (not `sk_`-prefixed) is caught here rather than + // propagated: every keyless-aware check calls this getter, so letting it + // throw turns one misconfiguration into a "Check crashed" line per check, + // each stripped of its check name. It's cached as a diagnosable state + // instead, and checkLoggedIn reports it once, by name, with a remedy. + keylessPromise = resolveKeylessTarget({ cwd: process.cwd() }).catch((error) => { + if (error instanceof CliError && error.code === ERROR_CODE.INVALID_KEY_FORMAT) { + keylessKeyError = error; + return undefined; + } + throw error; + }); + } + return keylessPromise; + }, + + async getKeylessKeyError() { + await ctx.getKeylessTarget(); + return keylessKeyError; + }, + + getKeylessInstance() { + if (!keylessInstancePromise) { + keylessInstancePromise = (async () => { + const keyless = await ctx.getKeylessTarget(); + if (!keyless) return null; + + try { + const response = await bapiRequest({ + method: "GET", + path: "/v1/instance", + secretKey: keyless.secretKey, + }); + const body = response.body as { id?: string; environment_type?: string }; + return { id: body.id ?? null, environmentType: body.environment_type ?? null }; + } catch (error) { + // Naming the instance is a nice-to-have here — the checks that + // actually need the target already have it via getKeylessTarget(). + log.debug(`doctor: could not fetch keyless instance info (${errorMessage(error)})`); + return null; + } + })(); + } + return keylessInstancePromise; + }, + + hasClaimBreadcrumb() { + if (!claimBreadcrumbPromise) { + // peek, not read: readKeylessBreadcrumb clears a malformed file as a + // side effect, and doctor must leave the project exactly as found. + claimBreadcrumbPromise = peekKeylessBreadcrumb(process.cwd()).then(Boolean); + } + return claimBreadcrumbPromise; + }, + fixes: { login: () => ({ label: "Log in with clerk auth login", diff --git a/packages/cli-core/src/commands/doctor/doctor.test.ts b/packages/cli-core/src/commands/doctor/doctor.test.ts index cfc174668..60c213fb9 100644 --- a/packages/cli-core/src/commands/doctor/doctor.test.ts +++ b/packages/cli-core/src/commands/doctor/doctor.test.ts @@ -2,11 +2,23 @@ import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { ApiError, AuthError } from "../../lib/errors.ts"; +import { ApiError, AuthError, CliError, ERROR_CODE } from "../../lib/errors.ts"; import { _setConfigDir } from "../../lib/config.ts"; -import { gitStubs, tokenExchangeStubs, stubFetch } from "../../test/lib/stubs.ts"; -import type { CheckResult, CheckStatus, DoctorContext, ResolvedProfile } from "./types.ts"; +import { + credentialStoreStubs, + gitStubs, + tokenExchangeStubs, + stubFetch, +} from "../../test/lib/stubs.ts"; +import type { + CheckResult, + CheckStatus, + DoctorContext, + KeylessInstanceInfo, + ResolvedProfile, +} from "./types.ts"; import type { Application } from "../../lib/plapi.ts"; +import type { KeylessTarget } from "../../lib/keyless-target.ts"; let mockUserInfo: { userId: string; email: string } | null = null; let mockUserInfoError: Error | null = null; @@ -23,6 +35,7 @@ mock.module("../../lib/token-exchange.ts", () => ({ }, })); +mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); mock.module("../../lib/host-execution.ts", () => ({ getAgentHostStateProbe: async () => mockHostStateProbe, @@ -92,6 +105,10 @@ function createMockContext( }; application?: Application | null; applicationError?: Error; + keylessTarget?: KeylessTarget; + keylessInstance?: KeylessInstanceInfo | null; + claimBreadcrumb?: boolean; + keylessKeyError?: CliError; } = {}, ): DoctorContext { return { @@ -105,6 +122,10 @@ function createMockContext( if (overrides.applicationError) throw overrides.applicationError; return overrides.application ?? null; }, + getKeylessTarget: async () => overrides.keylessTarget, + getKeylessInstance: async () => overrides.keylessInstance ?? null, + hasClaimBreadcrumb: async () => overrides.claimBreadcrumb ?? false, + getKeylessKeyError: async () => overrides.keylessKeyError, fixes: { login: noopFix, link: noopFix, @@ -113,6 +134,16 @@ function createMockContext( }; } +const mockKeylessTarget: KeylessTarget = { + secretKey: "sk_test_keyless", + source: ".env.local", +}; + +const mockKeylessInstance: KeylessInstanceInfo = { + id: "ins_keyless_1", + environmentType: "development", +}; + interface ExpectedCheck { name: string; status: CheckStatus; @@ -191,6 +222,50 @@ describe("checkLoggedIn", () => { fix: true, }); }); + + test("pass (not fail) when no token but an unclaimed keyless application is present", async () => { + const ctx = createMockContext({ + token: null, + keylessTarget: mockKeylessTarget, + keylessInstance: mockKeylessInstance, + }); + const result = await checkLoggedIn(ctx); + expectCheck(result, { + name: "Logged in", + status: "pass", + // The claim hint has to ride in the message: `detail` only renders + // under --verbose, and guidance nobody sees by default isn't guidance. + message: ["unclaimed keyless application", "ins_keyless_1", "Claim it"], + }); + }); + + test("fail when no token and the local secret key is malformed", async () => { + const ctx = createMockContext({ + token: null, + keylessKeyError: new CliError("not a secret key", { code: ERROR_CODE.INVALID_KEY_FORMAT }), + }); + const result = await checkLoggedIn(ctx); + expectCheck(result, { + name: "Logged in", + status: "fail", + message: ["local secret key is unusable", "not a secret key"], + remedy: "Fix or remove the malformed key", + }); + }); + + test("warn when a stored token exists but the local secret key is malformed", async () => { + const ctx = createMockContext({ + token: "test_token", + keylessKeyError: new CliError("not a secret key", { code: ERROR_CODE.INVALID_KEY_FORMAT }), + }); + const result = await checkLoggedIn(ctx); + expectCheck(result, { + name: "Logged in", + status: "warn", + message: ["Logged in", "local secret key is unusable", "not a secret key"], + remedy: "Fix or remove the malformed key", + }); + }); }); describe("checkHostExecution", () => { @@ -299,6 +374,29 @@ describe("checkTokenValid", () => { const result = await checkTokenValid(ctx); expectCheck(result, { name: "Authentication valid", status: "warn", message: "Skipped" }); }); + + test("pass (not skip) when no token but an unclaimed keyless application is present", async () => { + const ctx = createMockContext({ token: null, keylessTarget: mockKeylessTarget }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "pass", + message: "No account session", + }); + }); + + test("warn (not fail) when token is expired but a keyless application is present", async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ token: "expired_token", keylessTarget: mockKeylessTarget }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "warn", + message: ["Stored session is expired", "keyless application"], + remedy: "clerk auth login", + fix: false, + }); + }); }); describe("checkProjectLinked", () => { @@ -324,6 +422,39 @@ describe("checkProjectLinked", () => { fix: true, }); }); + + test("pass (not fail) when unlinked but running an unclaimed keyless application", async () => { + delete process.env.CLERK_PLATFORM_API_KEY; // no account credentials at all + const ctx = createMockContext({ + keylessTarget: mockKeylessTarget, + keylessInstance: mockKeylessInstance, + }); + const result = await checkProjectLinked(ctx); + expectCheck(result, { + name: "Project linked", + status: "pass", + // The claim hint has to ride in the message: `detail` only renders + // under --verbose, and guidance nobody sees by default isn't guidance. + message: ["unclaimed keyless application", "ins_keyless_1", "Claim it"], + }); + }); + + test("warn (not fail) when signed in but unlinked, falling back to a keyless application", async () => { + // beforeEach sets CLERK_PLATFORM_API_KEY, so hasAccountCredentials() is true here — + // the directory *could* reach the full account configuration by linking. + const ctx = createMockContext({ + keylessTarget: mockKeylessTarget, + keylessInstance: mockKeylessInstance, + }); + const result = await checkProjectLinked(ctx); + expectCheck(result, { + name: "Project linked", + status: "warn", + message: ["keyless application", "fewer settings"], + remedy: "clerk link", + fix: true, + }); + }); }); describe("checkLinkedAppExists", () => { @@ -384,6 +515,16 @@ describe("checkLinkedAppExists", () => { const result = await checkLinkedAppExists(ctx); expectCheck(result, { name: "Application reachable", status: "warn", message: "Skipped" }); }); + + test("skip reason names the keyless application instead of implying a problem", async () => { + const ctx = createMockContext({ token: null, keylessTarget: mockKeylessTarget }); + const result = await checkLinkedAppExists(ctx); + expectCheck(result, { + name: "Application reachable", + status: "warn", + message: ["Skipped", "keyless application"], + }); + }); }); describe("checkInstances", () => { @@ -453,6 +594,16 @@ describe("checkInstances", () => { const result = await checkInstances(ctx); expectCheck(result, { name: "Instance IDs", status: "warn", message: "Skipped" }); }); + + test("skip reason names the keyless application instead of implying a problem", async () => { + const ctx = createMockContext({ token: null, keylessTarget: mockKeylessTarget }); + const result = await checkInstances(ctx); + expectCheck(result, { + name: "Instance IDs", + status: "warn", + message: ["Skipped", "keyless application"], + }); + }); }); describe("checkEnvVars", () => { diff --git a/packages/cli-core/src/commands/doctor/types.ts b/packages/cli-core/src/commands/doctor/types.ts index dfe87efaf..1b665b8e8 100644 --- a/packages/cli-core/src/commands/doctor/types.ts +++ b/packages/cli-core/src/commands/doctor/types.ts @@ -1,5 +1,7 @@ import type { resolveProfile } from "../../lib/config.ts"; +import type { CliError } from "../../lib/errors.ts"; import type { Application } from "../../lib/plapi.ts"; +import type { KeylessTarget } from "../../lib/keyless-target.ts"; export type CheckStatus = "pass" | "warn" | "fail"; @@ -19,11 +21,39 @@ export interface CheckResult { fix?: FixAction; } +/** The identity of an unclaimed keyless application, fetched via its own secret key. */ +export interface KeylessInstanceInfo { + id: string | null; + environmentType: string | null; +} + export interface DoctorContext { getToken(): Promise; getValidToken(): Promise; getProfile(): Promise; getApplication(): Promise; + /** + * Resolves the same keyless fallback the rest of the CLI uses (see + * `lib/keyless-target.ts`), so doctor treats an unclaimed keyless project as + * the legitimate state it is instead of failing the auth/link checks. + */ + getKeylessTarget(): Promise; + /** Best-effort identity of the keyless instance, for naming it in check output. */ + getKeylessInstance(): Promise; + /** + * The malformed-local-key error `getKeylessTarget()` swallowed, if any. A + * key that doesn't start with `sk_` is precisely the misconfiguration doctor + * exists to diagnose, so it surfaces as one named failing check instead of + * crashing every keyless-aware check anonymously. + */ + getKeylessKeyError(): Promise; + /** + * Whether a `clerk init` claim breadcrumb is present, read once and without + * side effects — `readKeylessBreadcrumb` clears a malformed file as it goes, + * which a diagnostic command must not do, and two checks reading the disk + * independently could otherwise print contradictory claim hints. + */ + hasClaimBreadcrumb(): Promise; fixes: { login: () => FixAction; link: () => FixAction; diff --git a/packages/cli-core/src/commands/env/README.md b/packages/cli-core/src/commands/env/README.md index 379b5a2a0..af3a08183 100644 --- a/packages/cli-core/src/commands/env/README.md +++ b/packages/cli-core/src/commands/env/README.md @@ -2,6 +2,10 @@ Pulls Clerk API keys for the linked instance and merges them into the project's `.env` file. +For an unclaimed **keyless** application there is no account to pull from — its keys only exist on this machine. When the directory isn't linked and no `--app` is passed, `env pull` instead copies the keys it finds locally (env var, `.env`/`.env.local`, or the `.clerk/.tmp/keyless.json` an SDK wrote for itself) into the env file the framework reads. This is what materializes an SDK-created keyless app into `.env.local`. If only the secret key can be found, it's written and a warning names the missing publishable key. Resolution order lives in [`lib/keyless-target.ts`](../../lib/keyless-target.ts). + +The secret key and publishable key are found independently and can each belong to a _different_ application (e.g. leftovers from two keyless apps in the same `.env.local`). Before writing, `env pull` calls `GET /v1/domains` with the secret key and confirms the publishable key's Frontend API host (decoded via `decodePublishableKey` in `lib/fapi.ts`) matches one of that instance's own domains. A mismatch aborts the pull with an error and writes nothing — a wrong pair on disk produces an app that fails at runtime in a way that's very hard to trace, so this is stricter than `clerk whoami`, which only warns about the same mismatch (see [`commands/whoami/README.md`](../whoami/README.md)). + ## Usage ```sh @@ -27,19 +31,30 @@ sequenceDiagram Note over CLI: clerk env pull [--app app_123] [--instance dev|prod] [--file .env] - alt --app flag provided + alt --app flag provided or project linked + alt --app flag provided + CLI->>API: GET /v1/platform/applications/{appId} + API-->>CLI: { instances } + else Resolve project profile + CLI->>FS: Read CLI config file + FS-->>CLI: { appId, instances } + end + + %% Fetch application with keys CLI->>API: GET /v1/platform/applications/{appId} - API-->>CLI: { instances } - else Resolve project profile - CLI->>FS: Read CLI config file - FS-->>CLI: { appId, instances } + API-->>CLI: { instances: [{ instance_id, publishable_key, secret_key }] } + CLI->>CLI: Find matching instance by instance_id + else Unclaimed keyless application (no --app, not linked) + CLI->>FS: Find local keys (env vars, .env/.env.local, .clerk/.tmp/keyless.json) + FS-->>CLI: { secret_key, publishable_key? } + opt Publishable key found locally + CLI->>API: GET /v1/domains (Backend API, secret key auth) + API-->>CLI: { frontend_api_url } + CLI->>CLI: Compare against host decoded from publishable key + Note over CLI: Mismatch → error, nothing written + end end - %% Fetch application with keys - CLI->>API: GET /v1/platform/applications/{appId} - API-->>CLI: { instances: [{ instance_id, publishable_key, secret_key }] } - CLI->>CLI: Find matching instance by instance_id - %% Detect framework CLI->>FS: Read package.json FS-->>CLI: { dependencies } @@ -67,10 +82,11 @@ sequenceDiagram ## API Endpoints -| Step | Method | Endpoint | Notes | -| ----------------- | ------ | ----------------------------------- | ----------------------------------------------------------------------- | -| Auth | — | Local config | Uses `CLERK_PLATFORM_API_KEY`, `clerk auth login`, or human-mode prompt | -| Fetch application | `GET` | `/v1/platform/applications/{appId}` | Returns all instances with keys | +| Step | Method | Endpoint | Notes | +| ------------------------------------------ | ------ | ----------------------------------- | ------------------------------------------------------------------------------ | +| Auth | — | Local config | Uses `CLERK_PLATFORM_API_KEY`, `clerk auth login`, or human-mode prompt | +| Fetch application | `GET` | `/v1/platform/applications/{appId}` | Returns all instances with keys | +| Verify keyless pairing (keyless path only) | `GET` | `/v1/domains` | Only when a local publishable key was found; authenticated with the secret key | ## Framework Detection diff --git a/packages/cli-core/src/commands/env/pull.test.ts b/packages/cli-core/src/commands/env/pull.test.ts index 9d253c63d..e5aa89f47 100644 --- a/packages/cli-core/src/commands/env/pull.test.ts +++ b/packages/cli-core/src/commands/env/pull.test.ts @@ -677,4 +677,66 @@ describe("env pull", () => { expect(content).toContain("CLERK_PUBLISHABLE_KEY=pk_test_abc123"); expect(content).not.toContain("CLERK_SECRET_KEY"); }); + + describe("keyless", () => { + // Encodes `.clerk.accounts.dev$` the way a real publishable key + // would, so `decodePublishableKey` (used by the pairing check) sees the + // same shape it would in production instead of a fixture-only format. + const encodeFapiHost = (host: string) => + `pk_test_${Buffer.from(`${host}.clerk.accounts.dev$`).toString("base64").replace(/=+$/, "")}`; + const MATCHING_PK = encodeFapiHost("match"); + const MISMATCHED_PK = encodeFapiHost("other"); + + function stubDomains(frontendApiUrl: string): void { + stubFetch(async (input) => { + const url = input.toString(); + if (url.includes("/v1/domains")) { + return new Response(JSON.stringify({ data: [{ frontend_api_url: frontendApiUrl }] }), { + status: 200, + }); + } + throw new Error(`unexpected fetch in keyless test: ${url}`); + }); + } + + beforeEach(() => { + // No linked profile and no --app is what routes env pull to the keyless + // path (see lib/keyless-target.ts); CLERK_PLATFORM_API_KEY would only + // matter for the account path. + delete process.env.CLERK_PLATFORM_API_KEY; + process.env.CLERK_BACKEND_API_URL = "https://test-bapi.clerk.com"; + process.env.CLERK_SECRET_KEY = "sk_test_keyless"; + }); + + test("writes a matching pair", async () => { + process.env.CLERK_PUBLISHABLE_KEY = MATCHING_PK; + stubDomains("https://match.clerk.accounts.dev"); + + await runEnvPull(); + + const content = await Bun.file(join(tempDir, ".env.local")).text(); + expect(content).toContain("CLERK_SECRET_KEY=sk_test_keyless"); + expect(content).toContain(`CLERK_PUBLISHABLE_KEY=${MATCHING_PK}`); + }); + + test("refuses to write a publishable key that addresses a different application", async () => { + process.env.CLERK_PUBLISHABLE_KEY = MISMATCHED_PK; + stubDomains("https://match.clerk.accounts.dev"); + + await expect(runEnvPull()).rejects.toThrow(/doesn't belong to the application/); + + // Nothing should have been written — a partial or wrong pair on disk is + // worse than the command simply refusing. + expect(await Bun.file(join(tempDir, ".env.local")).exists()).toBe(false); + }); + + test("writes the secret key alone, with a warning, when no publishable key is found locally", async () => { + await runEnvPull(); + + const content = await Bun.file(join(tempDir, ".env.local")).text(); + expect(content).toContain("CLERK_SECRET_KEY=sk_test_keyless"); + expect(content).not.toContain("CLERK_PUBLISHABLE_KEY"); + expect(captured.err).toContain("No publishable key found locally"); + }); + }); }); diff --git a/packages/cli-core/src/commands/env/pull.ts b/packages/cli-core/src/commands/env/pull.ts index d30fd3d00..50d4aa7d8 100644 --- a/packages/cli-core/src/commands/env/pull.ts +++ b/packages/cli-core/src/commands/env/pull.ts @@ -10,6 +10,12 @@ import { isNpmFramework, } from "../../lib/framework.ts"; import { CliError, ERROR_CODE, withApiContext } from "../../lib/errors.ts"; +import { + findLocalPublishableKey, + hasKeyPairMismatch, + resolveKeylessTarget, + type KeylessTarget, +} from "../../lib/keyless-target.ts"; import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; @@ -52,6 +58,16 @@ async function resolveTargetFile( export async function pull(options: EnvPullOptions): Promise { await withGutter("Pulling environment variables", async () => { const cwd = options.cwd ?? process.cwd(); + + // A keyless application's keys are already on this machine — that's the only + // place they exist. "Pulling" them means copying what an SDK minted into the + // env file the framework reads, not fetching from an account. + const keyless = await resolveKeylessTarget({ ...options, cwd }); + if (keyless) { + await pullKeylessKeys(cwd, keyless, options.file); + return; + } + const [ctx, preferredEnvFile] = await Promise.all([ resolveAppContext({ ...options, cwd }), detectEnvFile(cwd), @@ -79,22 +95,74 @@ export async function pull(options: EnvPullOptions): Promise { const framework = await detectFramework(cwd); const includeSecretKey = isNpmFramework(framework ?? {}); - const file = Bun.file(targetFile); - const existingContent = (await file.exists()) ? await file.text() : ""; - - const lines = parseEnvFile(existingContent); - const vars: Record = { + await mergeKeysIntoEnvFile(targetFile, { [publishableKeyName]: matched.publishable_key, - }; - if (matched.secret_key && includeSecretKey) { - vars[secretKeyName] = matched.secret_key; - } - const merged = mergeEnvVars(lines, vars); - const output = serializeEnvFile(merged); - - await Bun.write(targetFile, output); + ...(matched.secret_key && includeSecretKey && { [secretKeyName]: matched.secret_key }), + }); }); log.info(`Environment variables written to ${displayPath}`); }); } + +/** Merges keys into an env file, preserving everything already in it. */ +async function mergeKeysIntoEnvFile( + targetFile: string, + vars: Record, +): Promise { + const file = Bun.file(targetFile); + const existingContent = (await file.exists()) ? await file.text() : ""; + + await Bun.write(targetFile, serializeEnvFile(mergeEnvVars(parseEnvFile(existingContent), vars))); +} + +/** + * Writes a keyless application's local keys into the project's env file. The + * publishable key can be missing when an SDK holds only part of the pair; the + * secret key is always present because it's what identified the target. + */ +async function pullKeylessKeys( + cwd: string, + keyless: KeylessTarget, + fileFlag?: string, +): Promise { + const [preferredEnvFile, publishableKeyName, secretKeyName, publishableKey] = await Promise.all([ + detectEnvFile(cwd), + detectPublishableKeyName(cwd), + detectSecretKeyName(cwd), + findLocalPublishableKey(cwd), + ]); + + // The secret and publishable key were found independently and may not name + // the same application. Writing a mismatched pair is worse than writing + // nothing: it produces an app that fails at runtime in a way that's very + // hard to trace back to its cause, so this is the stricter of the two + // checks — whoami only warns, this refuses to write. + if (publishableKey) { + const mismatch = await withApiContext( + hasKeyPairMismatch(keyless, publishableKey), + `Failed to verify the keyless secret key from \`${keyless.source}\``, + ); + if (mismatch) { + throw new CliError( + `The publishable key found locally doesn't belong to the application the secret key from \`${keyless.source}\` addresses. Writing this pair would leave the server trusting one app while the browser talks to another.\n` + + `Remove the mismatched ${publishableKeyName} from your env files, or run \`clerk auth login\` to claim the intended application, then pull again.`, + ); + } + } + + const targetFile = await resolveTargetFile(cwd, fileFlag, preferredEnvFile); + const displayPath = fileFlag ?? basename(targetFile); + + await mergeKeysIntoEnvFile(targetFile, { + [secretKeyName]: keyless.secretKey, + ...(publishableKey && { [publishableKeyName]: publishableKey }), + }); + + log.info(`Keyless application keys from \`${keyless.source}\` written to ${displayPath}`); + if (!publishableKey) { + log.warn( + `No publishable key found locally — set ${publishableKeyName} manually, or run \`clerk auth login\` to claim the application.`, + ); + } +} diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 8b330ed02..5da4c01b3 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -1,6 +1,6 @@ # Init Command -Initializes Clerk in a project by detecting the framework, installing the SDK, and scaffolding framework-specific boilerplate. By default, init logs the user in (interactively) and links the project to a real Clerk application. Pass `--keyless` to opt into auto-generated temporary development keys instead — useful when you want to scaffold a new project without authenticating. +Initializes Clerk in a project by detecting the framework, installing the SDK, and scaffolding framework-specific boilerplate. When the user is unauthenticated and the framework supports keyless, init defaults to keyless mode — auto-generated temporary development keys that a later `clerk auth login` claims automatically — during bootstrap (new projects) in human mode and in all agent-mode runs. Otherwise init logs the user in (interactively) and links a real Clerk application. `--keyless` forces keyless (even when logged in); `--login` forces the authenticated flow. ## Usage @@ -12,6 +12,9 @@ clerk init --starter clerk init --starter --framework next --pm bun clerk init --starter --framework next --pm bun --name my-app clerk init --starter --framework next --keyless +clerk init --login +clerk init --template b2b-saas +clerk init --keyless --fresh clerk init -y clerk init --yes clerk init --no-skills @@ -19,16 +22,19 @@ clerk init --no-skills ## Options -| Option | Description | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--framework ` | Framework to set up (skips auto-detection). Valid values: `next`, `astro`, `nuxt`, `tanstack-start`, `react-router`, `vue`, `expo`, `react`, `javascript`, `js`, `express`, `fastify`, `ios`, `android` | -| `--pm ` | Package manager to use. Valid values: `bun`, `pnpm`, `yarn`, `npm`. Skips the PM prompt (bootstrap) or overrides lockfile detection (existing project) | -| `--name ` | Project name for `--starter` (skips prompt). Must be lowercase, no spaces, no path separators | -| `--app ` | Application ID to link (skips the interactive app picker during authenticated linking) | -| `--starter` | Bootstrap a new project from a starter template (runs the framework generator, installs deps, and scaffolds Clerk) | -| `--keyless` | Use auto-generated temporary development keys instead of logging in. Only valid when bootstrapping a new project on a keyless-capable framework | -| `-y, --yes` | Skip y/n confirmation prompts. Authentication is still required — unauthenticated users are prompted to log in via the browser unless `--keyless` is also passed | -| `--no-skills` | Skip the optional agent skills install prompt at the end of init | +| Option | Description | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--framework ` | Framework to set up (skips auto-detection). Valid values: `next`, `astro`, `nuxt`, `tanstack-start`, `react-router`, `vue`, `expo`, `react`, `javascript`, `js`, `express`, `fastify`, `ios`, `android` | +| `--pm ` | Package manager to use. Valid values: `bun`, `pnpm`, `yarn`, `npm`. Skips the PM prompt (bootstrap) or overrides lockfile detection (existing project) | +| `--name ` | Project name for `--starter` (skips prompt). Must be lowercase, no spaces, no path separators | +| `--app ` | Application ID to link (skips the interactive app picker during authenticated linking) | +| `--starter` | Bootstrap a new project from a starter template (runs the framework generator, installs deps, and scaffolds Clerk) | +| `--keyless` | Force auto-generated temporary development keys, even when logged in. Only valid on a keyless-capable framework; cannot be combined with `--login` or `--app` | +| `--login` | Force the authenticated flow: log in (interactively if needed) and link a real application instead of keyless keys. Errors in agent mode when unauthenticated (agents can't run OAuth) | +| `--template ` | Pre-configure the keyless application at creation: `b2b-saas`, `b2c-saas`, `native`, `waitlist`. Only applies when the run resolves to keyless — errors otherwise (see [Application templates](#application-templates)); cannot be combined with `--login` | +| `--fresh` | Replace an existing unclaimed keyless application with a new one, instead of keeping it (see [Keyless breadcrumb](#keyless-breadcrumb)). Only applies when the run resolves to keyless — errors otherwise; cannot be combined with `--login` | +| `-y, --yes` | Skip y/n confirmation prompts only. It neither forces nor bypasses keyless — the strategy is picked by auth state, mode, and flags. It does **not** replace an existing unclaimed keyless app — that still requires `--fresh` | +| `--no-skills` | Skip the optional agent skills install prompt at the end of init | ## Agent Mode @@ -40,20 +46,25 @@ When running in agent mode (`--mode agent` or non-TTY), the command runs the ful - Project name defaults to the framework's default (e.g. `my-clerk-next-app`) unless `--name` is provided - For keyless-capable frameworks with no `--app` and no linked profile: - When **authenticated**, init creates a real Clerk app named after the project (`package.json#name`, `--name`, or directory basename) and links it. - - When **unauthenticated**, init prints manual setup guidance (pointing to `--keyless` or `clerk auth login`) and exits cleanly. Pass `--keyless` to opt into auto-generated dev keys; init writes a breadcrumb so the next `clerk auth login` claims the app automatically. + - When **unauthenticated**, init uses keyless: the app runs on auto-generated dev keys, and init writes a `.clerk/keyless.json` breadcrumb so the next `clerk auth login` claims the app automatically. - For frameworks that require API keys, init will not pick or create an app in agent mode; pass `--app ` or link the project first to pull real keys +- `--login` while unauthenticated exits with a usage error (agents can't complete the interactive browser login) +- Agent mode never trusts the mere _presence_ of a stored credential the way human mode does — a stored session that turns out to be expired/broken (e.g. keyring holds a stale OAuth session) is validated before init decides it's "authenticated". A broken credential is treated as unauthenticated, which routes a keyless-capable framework to keyless instead of blocking on a browser OAuth round-trip an agent can never complete. If `--login` (or a real app target) forces the authenticated flow anyway and the credential turns out broken, init exits with a usage error instead of attempting an interactive login +- Agent mode never mints a fresh keyless application over an existing unclaimed one on re-run — see [Keyless breadcrumb](#keyless-breadcrumb) ## Flow 1. Gathers project context (framework, router variant, TypeScript, `src/` directory, package manager) -2. Determines auth mode: - - **`--keyless`**: opt-in to keyless mode. Only valid on a keyless-capable framework (otherwise init exits with a usage error). The app runs on auto-generated dev keys; init writes a `.clerk/keyless.json` breadcrumb so the next `clerk auth login` claims the app automatically +2. Determines the strategy (in precedence order). In agent mode, "authenticated" here means a _validated_ credential (a real `CLERK_PLATFORM_API_KEY`, or a stored session that still exchanges for a valid token) — not just the presence of something in the keyring, since agent mode has no interactive fallback if a stale credential turns out to be unusable: + - **`--keyless`**: forces keyless mode, even when logged in. Only valid on a keyless-capable framework, and cannot be combined with `--login` or `--app` (usage errors otherwise). The app runs on auto-generated dev keys; init writes a `.clerk/keyless.json` breadcrumb so the next `clerk auth login` claims the app automatically + - **`--login`**: forces the authenticated flow. In agent mode while unauthenticated (or while stored credentials are broken) this exits with a usage error, since agents can't complete the interactive browser login - **Real app target** (`--app` or linked profile): authenticates, links if needed, and pulls real API keys into `.env` - - **Agent + keyless-capable framework + authenticated + no real app target**: creates a real Clerk app named after the project, links it, and pulls real API keys into `.env` - - **Agent + unauthenticated + no real app target + no `--keyless`**: scaffolds locally and prints manual setup guidance (`--keyless` or `clerk auth login`) - **Agent + non-keyless framework + no real app target**: scaffolds locally and prints manual setup instructions instead of selecting or creating an app - - **Human mode + not authenticated + no `--keyless`**: triggers an interactive `clerk auth login` and links a real app. `-y` does not bypass this — it only suppresses y/n confirmation prompts, not authentication - - **Human mode + existing project + not authenticated**: runs the authenticated flow, which triggers an interactive login so real keys can be pulled + - **Agent + keyless-capable framework + authenticated + no real app target**: creates a real Clerk app named after the project, links it, and pulls real API keys into `.env` + - **Agent + keyless-capable framework + unauthenticated + no real app target**: uses keyless mode — the app runs on auto-generated dev keys and the breadcrumb lets the next `clerk auth login` claim it. A broken/stale stored credential (present in the keyring but no longer valid) is treated the same as unauthenticated, so this is also the fallback when the presence-only check would have wrongly said "authenticated" + - **Human mode + bootstrap + keyless-capable framework + not authenticated**: uses keyless mode + - **Human mode + existing project + not authenticated**: runs the authenticated flow, which triggers an interactive login so real keys can be pulled. `-y` does not bypass this — it only suppresses y/n confirmation prompts, not authentication + - `--template` and `--fresh` are rejected with a usage error whenever the resolved strategy above isn't keyless — see [Application templates](#application-templates) and [Keyless breadcrumb](#keyless-breadcrumb) 3. **Authenticated mode only**: authenticates via `clerk auth login` (skipped if already authenticated) and links the project via `clerk link` (skipped if already linked) 4. Displays detected framework and variant 5. Detects existing auth libraries (NextAuth, Auth0, Supabase, Firebase, Passport, Better Auth, Kinde) and shows migration guidance @@ -66,7 +77,7 @@ When running in agent mode (`--mode agent` or non-TTY), the command runs the ful 12. Scans for issues: hardcoded keys, leftover auth-library imports, stale API calls 13. Prints a summary of created, modified, and skipped files with recommendations 14. **Authenticated mode**: pulls development instance API keys via `clerk env pull` -15. **Unauthenticated mode**: prints instructions for development without API keys and how to connect a Clerk account later +15. **Keyless mode** (unauthenticated runs whose resolved strategy in step 2 is keyless — an unauthenticated human-mode rerun on an existing project resolves to the authenticated flow instead): mints a keyless application and prints instructions for development without API keys and how to connect a Clerk account later — unless an unclaimed keyless app already exists for this project (see [Re-running init on an already-keyless project](#re-running-init-on-an-already-keyless-project)), in which case the existing keys are kept and reported instead 16. Optionally installs Clerk agent skills (cli + core + features, plus a framework-specific skill) via the project's package runner (see [Agent skills install](#agent-skills-install)) ## Framework Detection @@ -96,7 +107,7 @@ Native mobile platforms may not have a `package.json`, so they are detected from A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. For native platforms the Clerk SDK cannot be installed by a JS package manager, so init skips the SDK install step and the scaffold plan prints Swift Package Manager / Gradle install steps instead. The publishable key is configured in source code (`Clerk.configure(...)` / `Clerk.initialize(...)`), so init still pulls keys into the env file and instructs the user to copy the key over. -The **Keyless** column indicates whether the framework's Clerk SDK supports keyless mode (auto-generated temporary dev keys). Keyless mode is opt-in via `--keyless` and is only valid when bootstrapping a new project on a Yes-row framework — passing `--keyless` for a No-row framework or for an existing project exits with a usage error. By default, init authenticates the user (interactively when needed) and links a real app. In agent mode, an authenticated run on a keyless-capable framework creates a real app named after the project and links it; an unauthenticated agent run without `--keyless` prints manual setup guidance instead of selecting or creating an app. +The **Keyless** column indicates whether the framework's Clerk SDK supports keyless mode (auto-generated temporary dev keys). Keyless is the default for unauthenticated runs on Yes-row frameworks — during bootstrap (new projects) in human mode, and in all agent-mode runs. In human mode, an unauthenticated re-run in an existing project still triggers the authenticated flow. `--keyless` forces keyless anywhere a Yes-row framework is detected (existing projects included, even when logged in); passing it for a No-row framework exits with a usage error. In agent mode, an authenticated run on a keyless-capable framework creates a real app named after the project and links it. Package manager is detected from lock files: `bun.lockb`/`bun.lock` → bun, `yarn.lock` → yarn, `pnpm-lock.yaml` → pnpm, else npm. @@ -267,6 +278,19 @@ Implementation lives in [`skills.ts`](./skills.ts). Note that the E2E fixture se See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), and [env/README.md](../env/README.md) for the API endpoints used by each step. +## Application templates + +`--template ` is forwarded to `POST /v1/accountless_applications`, which pre-configures the application server-side before the first key is used. This is the one-shot way for an agent to get a shaped instance without an account — a `b2b-saas` keyless app comes back with organizations already enabled, where a default one does not. + +| Template | Shape | +| ---------- | -------------------------------- | +| `b2b-saas` | Organizations-first B2B setup | +| `b2c-saas` | Consumer setup with user billing | +| `native` | Native/mobile application | +| `waitlist` | Waitlist sign-up mode | + +The template only applies when a _new_ application is actually created, so `--template` is rejected with a usage error whenever the resolved strategy isn't keyless — whether that's because of an explicit conflicting flag (`--login`, or `--app` once the strategy resolves) or because the run is simply already authenticated (e.g. `CLERK_PLATFORM_API_KEY` is set) or the framework doesn't support keyless at all. The error names the reason, so `--template` is never silently dropped: add `--keyless` to force a keyless app, or drop `--template`. Settings can still be changed afterwards with `clerk config patch`, which also works without an account (see [config keyless mode](../config/README.md#keyless-mode)). + ## Keyless breadcrumb In keyless mode, after calling `POST /v1/accountless_applications`, `clerk init` writes `.clerk/keyless.json` to the project root. This file records the claim token extracted from `claim_url` so that `clerk auth login` can automatically claim the temporary application the next time the user authenticates. @@ -279,3 +303,13 @@ In keyless mode, after calling `POST /v1/accountless_applications`, `clerk init` ``` `.clerk/` is automatically added to `.gitignore` when the breadcrumb is written. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed). + +### Re-running init on an already-keyless project + +The breadcrumb is also what protects an unclaimed keyless app from being orphaned by a later `clerk init` run. As long as `.clerk/keyless.json` is present, the application it points at hasn't been claimed yet. The application and everything configured on it keep existing server-side either way — what the breadcrumb and env keys hold is the only local way to claim or reach it, so overwriting them can strand an application that still has configuration or users on it. So whenever init resolves to keyless mode and finds an existing breadcrumb, it does **not** silently mint a replacement application and overwrite the env keys and breadcrumb with the new one's: + +- **Human mode** (no `-y`): prompts `This project already has an unclaimed keyless application (created ). Replace it with a new one?`, defaulting to **no**. Declining keeps the existing keys and breadcrumb untouched. +- **Human mode with `-y`, and all agent-mode runs**: never prompt, and default to the same safe answer — **keep the existing application**. `-y` and agent mode both mean "skip confirmations", not "consent to destroying an app that might already have configuration or users on it". +- **`--fresh`**: the explicit escape hatch. Skips the check entirely and mints a new application (and overwrites the env keys and breadcrumb), even in agent mode or with `-y`. Like `--template`, it's a usage error when combined with `--login` or whenever the run doesn't resolve to keyless. + +If no breadcrumb exists (first run, or the previous app was already claimed and the breadcrumb removed), init proceeds exactly as before — there's nothing to protect. diff --git a/packages/cli-core/src/commands/init/heuristics.ts b/packages/cli-core/src/commands/init/heuristics.ts index fab992ba9..6b22e10c2 100644 --- a/packages/cli-core/src/commands/init/heuristics.ts +++ b/packages/cli-core/src/commands/init/heuristics.ts @@ -3,7 +3,7 @@ import { mkdir } from "node:fs/promises"; import { dim, cyan, green, yellow, bold } from "../../lib/color.js"; import { printNextSteps } from "../../lib/next-steps.js"; import { log } from "../../lib/log.js"; -import { getValidToken, hasStoredCredentials } from "../../lib/credential-store.js"; +import { getValidToken, hasAccountCredentials } from "../../lib/credential-store.js"; import { fetchUserInfo } from "../../lib/token-exchange.js"; import { printFindings } from "./scan.js"; import { pmInstallCommand } from "../../lib/package-manager.js"; @@ -147,8 +147,7 @@ export async function getAuthenticatedEmail(): Promise { * calls, which surface real errors instead of swallowing them. */ export async function isAuthenticated(): Promise { - if (process.env.CLERK_PLATFORM_API_KEY) return true; - return hasStoredCredentials(); + return hasAccountCredentials(); } export function printKeylessInfo(envFile: string): void { @@ -158,3 +157,15 @@ export function printKeylessInfo(envFile: string): void { ]; log.info(lines.map(dim).join("\n")); } + +/** + * Printed instead of `printKeylessInfo` when init keeps an existing unclaimed + * keyless app rather than minting a replacement (see `shouldKeepExistingKeyless`). + */ +export function printExistingKeylessInfo(envFile: string): void { + const lines = [ + `\n This project already has an unclaimed keyless application (keys in ${envFile}).`, + ` Run ${bold("clerk auth login")} to claim it, or ${bold("clerk init --keyless --fresh")} to replace it with a new one.\n`, + ]; + log.info(lines.map(dim).join("\n")); +} diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index a571d323a..42b7765db 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -1,143 +1,29 @@ -import { test, expect, describe, afterEach, spyOn } from "bun:test"; -import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { test, expect, describe, spyOn } from "bun:test"; // Pure spyOn approach — Bun's mock.module globally replaces modules for the -// entire test run, which pollutes other test files (link, env/pull, config, -// context, etc.) that import the same modules. spyOn restores cleanly. -import * as loginMod from "../auth/login.ts"; -import * as linkMod from "../link/index.ts"; -import * as pullMod from "../env/pull.ts"; -import * as mode from "../../mode.ts"; -import * as config from "../../lib/config.ts"; -import * as frameworkMod from "../../lib/framework.ts"; -import * as context from "./context.ts"; -import * as scaffoldMod from "./scaffold.ts"; -import * as previewMod from "./preview.ts"; -import * as formatMod from "./format.ts"; -import * as scanMod from "./scan.ts"; -import * as heuristics from "./heuristics.ts"; -import * as skillsMod from "./skills.ts"; -import * as bootstrapMod from "./bootstrap.ts"; -import * as nextStepsMod from "../../lib/next-steps.ts"; -import * as keylessMod from "../../lib/keyless.ts"; +// entire test run, which pollutes other test files that import the same +// modules. spyOn restores cleanly. Shared setup lives in the harness. +import { + useInitHarness, + FAKE_CTX, + FAKE_BOOTSTRAP, + loginMod, + linkMod, + pullMod, + config, + frameworkMod, + context, + scaffoldMod, + previewMod, + heuristics, + skillsMod, + bootstrapMod, + nextStepsMod, +} from "../../test/lib/init-harness.ts"; import { init } from "./index.ts"; -const FAKE_CTX = { - cwd: "/tmp/test", - framework: { - dep: "react", - name: "React", - sdk: "@clerk/react", - envVar: "VITE_CLERK_PUBLISHABLE_KEY", - envFile: ".env" as const, - }, - typescript: true, - srcDir: false, - packageManager: "npm" as const, - existingClerk: true, - deps: { react: "^19.0.0" }, - envFile: ".env", -}; - -const FAKE_BOOTSTRAP = { - projectDir: "/tmp/test/my-app", - projectName: "my-app", - packageManager: "npm" as const, -}; - -type FakeFramework = { - dep: string; - name: string; - sdk: string; - envVar: string; - envFile: ".env" | ".env.local"; - supportsKeyless?: boolean; -}; - -type FakeCtx = Omit & { framework: FakeFramework }; - -const KEYLESS_CTX: FakeCtx = { - ...FAKE_CTX, - existingClerk: false, - framework: { ...FAKE_CTX.framework, supportsKeyless: true }, -}; - -function mockBootstrapTo(ctx: FakeCtx): void { - spyOn(context, "gatherContext").mockResolvedValueOnce(null).mockResolvedValueOnce(ctx); -} - -function mockExistingProject(ctx: FakeCtx): void { - spyOn(context, "gatherContext").mockResolvedValue(ctx); -} - -function mockMiddlewareScaffold(): void { - spyOn(scaffoldMod, "scaffold").mockResolvedValue({ - actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }], - postInstructions: [], - }); -} - describe("init", () => { - let spies: ReturnType[]; - const captured = useCaptureLog(); - - afterEach(() => { - for (const s of spies) s.mockRestore(); - }); - - function setup(overrides: { email?: string | null; apiKey?: boolean; isAgent?: boolean } = {}) { - const email = overrides.email ?? null; - const apiKey = overrides.apiKey ?? false; - const agent = overrides.isAgent ?? false; - const authed = email != null || apiKey; - const gatherContextSpy = spyOn(context, "gatherContext").mockResolvedValue(null); - - spies = [ - spyOn(mode, "isAgent").mockReturnValue(agent), - spyOn(mode, "isHuman").mockReturnValue(!agent), - spyOn(config, "resolveProfile").mockResolvedValue(undefined), - spyOn(frameworkMod, "lookupFramework").mockReturnValue(null), - gatherContextSpy, - spyOn(context, "hasPackageJson").mockResolvedValue(false), - spyOn(scaffoldMod, "scaffold").mockResolvedValue({ actions: [], postInstructions: [] }), - spyOn(scaffoldMod, "enrichProjectContext").mockResolvedValue(undefined), - spyOn(previewMod, "previewPlan").mockReturnValue(undefined), - spyOn(previewMod, "previewAndConfirm").mockResolvedValue(true), - spyOn(formatMod, "runFormatters").mockResolvedValue(undefined), - spyOn(scanMod, "detectAuthLibraries").mockReturnValue(undefined), - spyOn(scanMod, "scanForIssues").mockResolvedValue([]), - spyOn(heuristics, "getAuthenticatedEmail").mockResolvedValue(email), - spyOn(heuristics, "isAuthenticated").mockResolvedValue(authed), - spyOn(heuristics, "printKeylessInfo").mockReturnValue(undefined), - spyOn(heuristics, "installSdk").mockResolvedValue(undefined), - spyOn(heuristics, "installDeps").mockResolvedValue(undefined), - spyOn(heuristics, "writePlan").mockResolvedValue([]), - spyOn(heuristics, "checkGitDirty").mockResolvedValue(false), - spyOn(heuristics, "printOutro").mockReturnValue(undefined), - spyOn(skillsMod, "installSkills").mockResolvedValue(undefined), - spyOn(loginMod, "login").mockResolvedValue(undefined as never), - spyOn(linkMod, "link").mockResolvedValue(undefined), - spyOn(pullMod, "pull").mockResolvedValue(undefined), - spyOn(bootstrapMod, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP), - spyOn(bootstrapMod, "confirmOverwrite").mockResolvedValue(undefined), - spyOn(keylessMod, "createAccountlessApp").mockResolvedValue({ - publishable_key: "pk_test_stub", - secret_key: "sk_test_stub", - claim_url: "/apps/claim?token=stub_token", - }), - spyOn(keylessMod, "writeKeysToEnvFile").mockResolvedValue(undefined), - spyOn(keylessMod, "writeKeylessBreadcrumb").mockResolvedValue(undefined), - ]; - - return { gatherContextSpy, captured }; - } - - function setupBootstrapSuccess() { - const gatherSpy = - spies.find((s) => s.getMockName?.() === "gatherContext") ?? spyOn(context, "gatherContext"); - gatherSpy.mockResolvedValueOnce(null).mockResolvedValueOnce(FAKE_CTX); - } - + const { setup, setupBootstrapSuccess, track } = useInitHarness(); test("suppresses auth next-steps when login runs during init", async () => { setup({ email: null }); spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); @@ -263,12 +149,12 @@ describe("init", () => { }); const callOrder: string[] = []; - spies.push( + track( spyOn(skillsMod, "installSkills").mockImplementation(async () => { callOrder.push("installSkills"); }), ); - spies.push( + track( spyOn(nextStepsMod, "printNextSteps").mockImplementation(() => { callOrder.push("printNextSteps"); }), @@ -279,302 +165,6 @@ describe("init", () => { expect(callOrder.indexOf("installSkills")).toBeLessThan(callOrder.indexOf("printNextSteps")); }); - test("blank dir with keyless framework triggers login by default when unauthenticated", async () => { - setup(); - mockBootstrapTo(KEYLESS_CTX); - mockMiddlewareScaffold(); - - await init({}); - - expect(bootstrapMod.promptAndBootstrap).toHaveBeenCalled(); - // Default flow now requires login; keyless is opt-in via --keyless. - expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); - expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); - expect(linkMod.link).toHaveBeenCalled(); - }); - - test("--keyless on a keyless-capable framework uses keyless mode without logging in", async () => { - setup(); - mockBootstrapTo(KEYLESS_CTX); - mockMiddlewareScaffold(); - - await init({ keyless: true }); - - expect(bootstrapMod.promptAndBootstrap).toHaveBeenCalled(); - expect(heuristics.printKeylessInfo).toHaveBeenCalled(); - expect(loginMod.login).not.toHaveBeenCalled(); - expect(linkMod.link).not.toHaveBeenCalled(); - expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); - }); - - test("--keyless takes precedence over an authed user", async () => { - setup({ email: "user@example.com" }); - mockBootstrapTo(KEYLESS_CTX); - mockMiddlewareScaffold(); - - await init({ keyless: true }); - - expect(heuristics.printKeylessInfo).toHaveBeenCalled(); - expect(linkMod.link).not.toHaveBeenCalled(); - expect(pullMod.pull).not.toHaveBeenCalled(); - }); - - test("--keyless on a non-keyless framework throws a usage error", async () => { - setup(); - const nonKeylessCtx: FakeCtx = { - ...FAKE_CTX, - existingClerk: false, - framework: { - dep: "vue", - name: "Vue", - sdk: "@clerk/vue", - envVar: "VITE_CLERK_PUBLISHABLE_KEY", - envFile: ".env.local", - }, - envFile: ".env.local", - }; - mockBootstrapTo(nonKeylessCtx); - - await expect(init({ keyless: true })).rejects.toThrow(/--keyless is not supported for Vue/); - expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); - expect(linkMod.link).not.toHaveBeenCalled(); - }); - - test("--keyless on an existing keyless-capable project uses keyless mode", async () => { - setup(); - mockExistingProject(KEYLESS_CTX); - mockMiddlewareScaffold(); - - await init({ keyless: true }); - - expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); - expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); - expect(heuristics.printKeylessInfo).toHaveBeenCalled(); - expect(linkMod.link).not.toHaveBeenCalled(); - expect(loginMod.login).not.toHaveBeenCalled(); - }); - - test("bootstrap with keyless framework goes authenticated when already signed in", async () => { - setup({ email: "user@example.com" }); - mockBootstrapTo({ ...KEYLESS_CTX, existingClerk: true }); - - await init({}); - - expect(heuristics.isAuthenticated).toHaveBeenCalled(); - expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); - expect(linkMod.link).toHaveBeenCalled(); - }); - - test("-y flag with keyless framework uses authenticated flow when signed in", async () => { - setup({ email: "user@example.com" }); - mockBootstrapTo({ ...KEYLESS_CTX, existingClerk: true }); - - await init({ yes: true }); - - expect(heuristics.isAuthenticated).toHaveBeenCalled(); - expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); - }); - - test("-y flag with keyless framework uses authenticated flow when CLERK_PLATFORM_API_KEY is set", async () => { - setup({ apiKey: true }); - mockBootstrapTo({ ...KEYLESS_CTX, existingClerk: true }); - - await init({ yes: true }); - - expect(heuristics.isAuthenticated).toHaveBeenCalled(); - expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); - expect(linkMod.link).toHaveBeenCalled(); - }); - - test("-y flag with keyless framework triggers login when unauthenticated (no --keyless)", async () => { - // `-y` skips y/n confirmations but does not skip authentication. Without - // `--keyless`, init must prompt the user to log in. - setup(); - mockBootstrapTo(KEYLESS_CTX); - mockMiddlewareScaffold(); - - await init({ yes: true }); - - expect(heuristics.isAuthenticated).toHaveBeenCalled(); - expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); - expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); - expect(linkMod.link).toHaveBeenCalled(); - }); - - test("-y --keyless with keyless framework uses keyless mode", async () => { - setup(); - mockBootstrapTo(KEYLESS_CTX); - mockMiddlewareScaffold(); - - await init({ yes: true, keyless: true }); - - expect(heuristics.printKeylessInfo).toHaveBeenCalled(); - expect(linkMod.link).not.toHaveBeenCalled(); - expect(loginMod.login).not.toHaveBeenCalled(); - }); - - test("agent mode with keyless framework prints manual setup when unauthenticated", async () => { - // Agents can't run interactive OAuth and didn't opt into keyless via - // --keyless, so the safe path is to scaffold locally and emit guidance. - const { captured } = setup({ isAgent: true, email: null }); - mockExistingProject(KEYLESS_CTX); - mockMiddlewareScaffold(); - - await init({}); - - expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); - expect(linkMod.link).not.toHaveBeenCalled(); - expect(pullMod.pull).not.toHaveBeenCalled(); - expect(loginMod.login).not.toHaveBeenCalled(); - expect(captured.err).toContain("clerk init --keyless"); - }); - - test("agent mode with --keyless uses keyless mode without authentication", async () => { - setup({ isAgent: true, email: null }); - mockExistingProject(KEYLESS_CTX); - mockMiddlewareScaffold(); - - await init({ keyless: true }); - - expect(heuristics.printKeylessInfo).toHaveBeenCalled(); - expect(linkMod.link).not.toHaveBeenCalled(); - expect(loginMod.login).not.toHaveBeenCalled(); - expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); - }); - - test("agent mode with keyless framework + authed creates and links a real app", async () => { - setup({ isAgent: true, email: "user@example.com" }); - mockExistingProject(KEYLESS_CTX); - // Override potential leakage from earlier tests that spy on resolveProfile - // with a non-undefined value but don't track those spies for restoration. - spyOn(config, "resolveProfile").mockResolvedValue(undefined); - mockMiddlewareScaffold(); - - await init({}); - - expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); - expect(linkMod.link).toHaveBeenCalledWith({ - skipIfLinked: true, - app: undefined, - cwd: KEYLESS_CTX.cwd, - createIfMissing: expect.any(String), - }); - expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: KEYLESS_CTX.cwd }); - }); - - test("agent mode with keyless framework uses linked profile as a real app target", async () => { - setup({ isAgent: true, email: "user@example.com" }); - mockExistingProject(KEYLESS_CTX); - spyOn(config, "resolveProfile").mockResolvedValue({ - profile: { appId: "app_123" }, - } as never); - mockMiddlewareScaffold(); - - await init({}); - - expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); - expect(linkMod.link).not.toHaveBeenCalled(); - expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: KEYLESS_CTX.cwd }); - }); - - test("agent mode with keyless framework and --app uses real app flow", async () => { - setup({ isAgent: true, email: "user@example.com" }); - mockExistingProject(KEYLESS_CTX); - mockMiddlewareScaffold(); - - await init({ app: "app_abc" }); - - expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); - expect(linkMod.link).toHaveBeenCalledWith({ - skipIfLinked: true, - app: "app_abc", - cwd: KEYLESS_CTX.cwd, - createIfMissing: expect.any(String), - }); - expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: KEYLESS_CTX.cwd }); - }); - - test("agent mode with non-keyless framework and no app target prints manual setup", async () => { - const { captured } = setup({ isAgent: true, email: "user@example.com" }); - - const noKeylessCtx = { - ...FAKE_CTX, - existingClerk: false, - framework: { - dep: "vue", - name: "Vue", - sdk: "@clerk/vue", - envVar: "VITE_CLERK_PUBLISHABLE_KEY", - envFile: ".env.local" as const, - }, - envFile: ".env.local", - }; - spyOn(context, "gatherContext").mockResolvedValue(noKeylessCtx); - spyOn(scaffoldMod, "scaffold").mockResolvedValue({ - actions: [{ type: "create", path: "src/main.ts", content: "", description: "" }], - postInstructions: [], - }); - - await init({}); - - expect(linkMod.link).not.toHaveBeenCalled(); - expect(pullMod.pull).not.toHaveBeenCalled(); - expect(loginMod.login).not.toHaveBeenCalled(); - expect(captured.err).toContain("clerk init --app "); - }); - - test("agent mode with real app target and no auth launches login", async () => { - setup({ isAgent: true }); - spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); - - await init({ app: "app_abc" }); - - expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); - expect(linkMod.link).toHaveBeenCalledWith({ - skipIfLinked: true, - app: "app_abc", - cwd: FAKE_CTX.cwd, - createIfMissing: expect.any(String), - }); - }); - - test("-y flag triggers login when unauthenticated", async () => { - setup(); - setupBootstrapSuccess(); - - await init({ yes: true }); - - expect(bootstrapMod.promptAndBootstrap).toHaveBeenCalled(); - expect(heuristics.isAuthenticated).toHaveBeenCalled(); - // `-y` skips y/n confirmations but not authentication. - expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); - }); - - test("-y flag triggers login for non-keyless frameworks in bootstrap", async () => { - setup(); - - const noKeylessCtx = { - ...FAKE_CTX, - framework: { - dep: "vue", - name: "Vue", - sdk: "@clerk/vue", - envVar: "VITE_CLERK_PUBLISHABLE_KEY", - envFile: ".env.local" as const, - }, - existingClerk: false, - }; - - spyOn(context, "gatherContext").mockResolvedValueOnce(null).mockResolvedValueOnce(noKeylessCtx); - - await init({ yes: true }); - - expect(bootstrapMod.promptAndBootstrap).toHaveBeenCalled(); - expect(heuristics.isAuthenticated).toHaveBeenCalled(); - expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); - expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); - }); - test("blank dir bootstrap declined throws UserAbortError", async () => { setup(); spyOn(bootstrapMod, "promptAndBootstrap").mockRejectedValue( @@ -606,56 +196,6 @@ describe("init", () => { expect(context.hasPackageJson).not.toHaveBeenCalled(); }); - test("existing repo with keyless framework uses authenticated flow when signed in", async () => { - setup({ email: "user@example.com" }); - - const keylessCtx = { - ...FAKE_CTX, - framework: { ...FAKE_CTX.framework, supportsKeyless: true }, - }; - spyOn(context, "gatherContext").mockResolvedValue(keylessCtx); - spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never); - - await init({ yes: true }); - - expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); - expect(heuristics.isAuthenticated).toHaveBeenCalled(); - expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); - }); - - test("existing repo with keyless framework uses authenticated flow when not signed in", async () => { - // Keyless auto-selection is scoped to bootstrap (new-project) flows. On an - // existing repo, an unauthenticated re-run should fall through to the - // authenticated flow (which prompts login) rather than silently skip - // `env pull`. - setup(); - - const keylessCtx = { - ...FAKE_CTX, - existingClerk: false, - framework: { ...FAKE_CTX.framework, supportsKeyless: true }, - }; - spyOn(context, "gatherContext").mockResolvedValue(keylessCtx); - spyOn(scaffoldMod, "scaffold").mockResolvedValue({ - actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }], - postInstructions: [], - }); - spyOn(loginMod, "login").mockResolvedValue({ - userId: "user_1", - email: "test@test.com", - } as never); - - await init({}); - - expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); - expect(heuristics.isAuthenticated).toHaveBeenCalled(); - expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); - // Unauthenticated + existing repo → login + link run via authenticateAndLink. - expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); - expect(linkMod.link).toHaveBeenCalled(); - expect(pullMod.pull).toHaveBeenCalled(); - }); - test("passes frameworkOverride to bootstrap when provided", async () => { const fwOverride = { dep: "next", diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index d358a2992..424abdbc8 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -15,12 +15,17 @@ import { import { resolveProfile } from "../../lib/config.js"; import { deriveProjectName } from "../../lib/project-name.js"; import { log } from "../../lib/log.js"; +import { confirm } from "../../lib/prompts.ts"; import { createAccountlessApp, writeKeysToEnvFile, parseClaimToken, writeKeylessBreadcrumb, + readKeylessBreadcrumb, + KEYLESS_TEMPLATES, + type KeylessTemplate, } from "../../lib/keyless.js"; +import { readSdkKeylessApp } from "../../lib/keyless-target.ts"; import { printNextSteps } from "../../lib/next-steps.js"; import { gatherContext, hasPackageJson } from "./context.js"; import { scaffold, enrichProjectContext } from "./scaffold.js"; @@ -34,6 +39,7 @@ import { checkGitDirty, printOutro, printKeylessInfo, + printExistingKeylessInfo, getAuthenticatedEmail, isAuthenticated, } from "./heuristics.js"; @@ -60,14 +66,22 @@ type InitOptions = { starter?: boolean; /** Link to a specific Clerk application by ID (skips the interactive picker). */ app?: string; - /** Opt into keyless mode (auto-generated dev keys, no login). Only valid on keyless-capable frameworks. */ + /** Force keyless mode (auto-generated dev keys, no login). Only valid on keyless-capable frameworks. */ keyless?: boolean; + /** Force the authenticated flow (log in and link a real app) instead of defaulting to keyless. */ + login?: boolean; + /** Pre-configure the keyless application from a Clerk application template. */ + template?: KeylessTemplate; + /** Replace an existing unclaimed keyless application instead of keeping it. */ + fresh?: boolean; }; export async function init(options: InitOptions = {}) { const cwd = process.cwd(); const agent = isAgent(); + await assertUsableFlags(options, agent); + const frameworkOverride = options.framework ? (lookupFramework(options.framework) ?? undefined) : undefined; @@ -98,19 +112,33 @@ export async function init(options: InitOptions = {}) { const optsKeyless = options.keyless === true; // Skip auth-related I/O entirely when the user opted into keyless — those // values are not consumed once the strategy resolves to "keyless". - const authed = optsKeyless ? false : await isAuthenticated(); + // + // Agent mode has no way to recover if this lies: a human who turns out to be + // unauthenticated just gets prompted to log in, but an agent that trusts a + // stale/broken credential ends up blocked on an interactive browser OAuth + // round-trip it can never complete. So agent mode validates the credential + // (it can fall back to keyless) instead of trusting mere presence. + const authed = optsKeyless + ? false + : agent + ? await isAuthenticatedForAgent() + : await isAuthenticated(); const linkedProfile = !optsKeyless && agent && !options.app ? await resolveProfile(ctx.cwd) : undefined; const hasRealAppTarget = Boolean(options.app || linkedProfile); const strategy = pickStrategy({ optsKeyless, + optsLogin: options.login === true, agent, authed, + isBootstrap: bootstrap != null, hasRealAppTarget, framework: ctx.framework, }); + assertKeylessOnlyFlags(options, strategy); + if (strategy === "authenticate") { bar(); const createIfMissing = agent @@ -136,7 +164,11 @@ export async function init(options: InitOptions = {}) { } bar(); - await runStrategy(strategy, ctx); + await runStrategy(strategy, ctx, { + template: options.template, + fresh: options.fresh === true, + skipConfirm: overrides.skipConfirm, + }); // Native platforms (iOS/Android) have no npx/Node toolchain to run `skills add` with. if (options.skills !== false && isNpmFramework(ctx.framework)) { @@ -153,6 +185,83 @@ export async function init(options: InitOptions = {}) { outro("Done"); } +/** + * Rejects flag combinations that can't both be honoured, before anything is + * bootstrapped on disk. `--keyless`, `--template`, and `--fresh` describe an + * application the CLI creates; `--login` and `--app` describe one that + * already exists. + */ +async function assertUsableFlags(options: InitOptions, agent: boolean): Promise { + if (options.keyless && options.login) { + throwUsageError("--keyless and --login cannot be combined."); + } + if (options.keyless && options.app) { + throwUsageError( + "--keyless cannot be combined with --app. Drop --keyless to link the app, or drop --app to use temporary development keys.", + ); + } + if (options.template && options.login) { + throwUsageError( + "--template applies to keyless applications and cannot be combined with --login.", + ); + } + if (options.fresh && options.login) { + throwUsageError("--fresh applies to keyless applications and cannot be combined with --login."); + } + // Presence-only here would repeat the hang below: an agent can't complete an + // interactive login, so a stored-but-broken credential must read as + // unauthenticated rather than let this guard wave the request through. + if (options.login && agent && !(await isAuthenticatedForAgent())) { + throwUsageError( + "--login requires an interactive terminal to complete the browser login. Ask the user to run `clerk auth login`, then re-run `clerk init`.", + ); + } +} + +/** + * Agent-mode variant of `isAuthenticated()`. The human-mode presence check + * (see `heuristics.isAuthenticated`) deliberately doesn't validate the + * credential, because a human who turns out to be unauthenticated just gets + * an interactive login prompt. An agent has no such fallback — if it trusts a + * stale/broken credential, it ends up blocked on a browser OAuth round-trip + * that can never complete. So this validates before trusting: a real API key + * is accepted outright (no OAuth involved), everything else must actually + * resolve to a user. + */ +async function isAuthenticatedForAgent(): Promise { + if (process.env.CLERK_PLATFORM_API_KEY) return true; + return (await getAuthenticatedEmail()) !== null; +} + +/** + * `--template` and `--fresh` only take effect when init creates a keyless + * application. Silently dropping them when the strategy resolves elsewhere + * (the pre-fix behaviour for `--template`) leaves the user believing they got + * a shaped or replaced app when they didn't — so fail loudly instead, the + * same way `--keyless`+`--app` does above. This runs after strategy + * resolution because that's the earliest point the real strategy — not just + * the flags that might influence it — is known. + */ +function assertKeylessOnlyFlags(options: InitOptions, strategy: InitStrategy): void { + if (strategy === "keyless") return; + + const reason = + strategy === "manual" + ? "this framework does not support keyless mode" + : "this run resolved to the authenticated flow instead (already signed in, --app was set, or a project is already linked)"; + + if (options.template) { + throwUsageError( + `--template only applies to keyless applications, but ${reason}. Add --keyless to force a keyless app, or drop --template.`, + ); + } + if (options.fresh) { + throwUsageError( + `--fresh only applies to keyless applications, but ${reason}. Add --keyless to force a keyless app, or drop --fresh.`, + ); + } +} + type ResolvedContext = { ctx: ProjectContext; bootstrap: BootstrapResult | null; @@ -237,19 +346,14 @@ function printBootstrapNextSteps( } function printBootstrapManualSetupInfo(framework: FrameworkInfo): void { - const lines = [`\n Set up Clerk for ${framework.name}:`]; - if (framework.supportsKeyless) { - lines.push( - " clerk init --keyless (use temporary development keys)", - " clerk auth login (then re-run clerk init to link a real app)", - ); - } else { - lines.push( - ` ${framework.name} requires API keys — set them up manually:`, - " clerk init --app ", - " clerk env pull", - ); - } + // Only reachable for non-keyless frameworks: keyless-capable ones resolve to + // the "keyless" or "authenticate" strategy in agent mode instead. + const lines = [ + `\n Set up Clerk for ${framework.name}:`, + ` ${framework.name} requires API keys — set them up manually:`, + " clerk init --app ", + " clerk env pull", + ]; log.info(lines.map(dim).join("\n")); } @@ -258,20 +362,28 @@ function printBootstrapManualSetupInfo(framework: FrameworkInfo): void { type InitStrategy = "keyless" | "manual" | "authenticate"; // Picks how `clerk init` will reach a working Clerk setup: -// - "keyless" → user opted in via `--keyless`; needs a keyless-capable framework (else: usage error). -// - "manual" → agent mode can't auto-resolve (no real app target, plus either a non-keyless framework -// or no auth) — scaffold locally and print guidance instead of running OAuth. -// - "authenticate" → default; log in (interactively if needed) and link a real Clerk application. +// - "keyless" → temporary development keys, no login. Forced via `--keyless`, or the default +// for unauthenticated runs on a keyless-capable framework (human bootstrap and +// all agent runs). A `.clerk/keyless.json` breadcrumb lets the next +// `clerk auth login` claim the app automatically. +// - "manual" → agent mode on a non-keyless framework without a real app target — scaffold +// locally and print guidance instead of running OAuth. +// - "authenticate" → log in (interactively if needed) and link a real Clerk application. Forced +// via `--login`, and the default whenever keyless doesn't apply. function pickStrategy({ optsKeyless, + optsLogin, agent, authed, + isBootstrap, hasRealAppTarget, framework, }: { optsKeyless: boolean; + optsLogin: boolean; agent: boolean; authed: boolean; + isBootstrap: boolean; hasRealAppTarget: boolean; framework: FrameworkInfo; }): InitStrategy { @@ -283,11 +395,25 @@ function pickStrategy({ } return "keyless"; } - if (agent && !hasRealAppTarget && (!framework.supportsKeyless || !authed)) return "manual"; + if (optsLogin || hasRealAppTarget) return "authenticate"; + if (agent && !framework.supportsKeyless) return "manual"; + if (!authed && framework.supportsKeyless && (agent || isBootstrap)) return "keyless"; return "authenticate"; } -async function runStrategy(strategy: InitStrategy, ctx: ProjectContext): Promise { +type KeylessRunOptions = { + template?: KeylessTemplate; + /** Escape hatch for "give me a fresh one": mint a new app even if an unclaimed one already exists. */ + fresh: boolean; + /** Agent mode and `-y` both skip y/n prompts, so both must default to *not* replacing. */ + skipConfirm: boolean; +}; + +async function runStrategy( + strategy: InitStrategy, + ctx: ProjectContext, + keylessOptions: KeylessRunOptions, +): Promise { switch (strategy) { case "manual": printBootstrapManualSetupInfo(ctx.framework); @@ -296,7 +422,7 @@ async function runStrategy(strategy: InitStrategy, ctx: ProjectContext): Promise await pull({ file: ctx.envFile, cwd: ctx.cwd }); return; case "keyless": - await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile); + await setupKeylessApp(ctx.cwd, ctx.framework.dep, ctx.envFile, keylessOptions); return; } } @@ -338,10 +464,58 @@ async function authenticateAndLink( // --- Keyless app setup --- -async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise { +/** + * A `.clerk/keyless.json` breadcrumb means an earlier run already minted an + * unclaimed keyless application for this project — its claim token, and the + * local means of claiming or reaching it, only exist as long as that + * breadcrumb (and the env keys pointing at it) survive. The same is true of + * an application a Clerk SDK minted for itself in `.clerk/.tmp/keyless.json` + * (running `next dev` with no keys configured), so both files count as "an + * app already exists here". Re-running init must not silently mint a + * replacement and orphan either one, so this asks before ever touching it: + * human mode confirms (default: keep); agent mode and `-y` both keep it too, + * since neither can consent to a destructive default. `--fresh` is the + * explicit "I know, replace it anyway" escape hatch. + */ +async function shouldKeepExistingKeyless( + cwd: string, + skipConfirm: boolean, + fresh: boolean, +): Promise { + if (fresh) return false; + + const existing = await readKeylessBreadcrumb(cwd); + const sdkApp = existing ? undefined : await readSdkKeylessApp(cwd); + if (!existing && !sdkApp?.secretKey) return false; + + if (skipConfirm) return true; + + const replace = await confirm({ + message: existing + ? `This project already has an unclaimed keyless application (created ${existing.createdAt}). Replace it with a new one?` + : "This project already has an unclaimed keyless application (minted by its Clerk SDK in `.clerk/.tmp/keyless.json`). Replace it with a new one?", + default: false, + }); + return !replace; +} + +async function setupKeylessApp( + cwd: string, + frameworkDep: string, + envFile: string, + { template, fresh, skipConfirm }: KeylessRunOptions, +): Promise { + if (await shouldKeepExistingKeyless(cwd, skipConfirm, fresh)) { + printExistingKeylessInfo(envFile); + return; + } + try { - const app = await withSpinner("Creating development application...", () => - createAccountlessApp(frameworkDep), + const app = await withSpinner( + template + ? `Creating development application (${template})...` + : "Creating development application...", + () => createAccountlessApp(frameworkDep, template), ); await writeKeysToEnvFile(cwd, { @@ -455,7 +629,21 @@ export function registerInit(program: Program): void { .option("--starter", "Create a new project from a starter template") .option( "--keyless", - "Use keyless development keys instead of logging in (only for keyless-capable frameworks)", + "Force keyless development keys, even when logged in (only for keyless-capable frameworks)", + ) + .option( + "--login", + "Force the authenticated flow: log in and link a real application instead of keyless keys", + ) + .addOption( + createOption( + "--template ", + "Pre-configure the keyless application from a Clerk application template. Only applies when the strategy resolves to keyless — errors otherwise", + ).choices(KEYLESS_TEMPLATES), + ) + .option( + "--fresh", + "Replace an existing unclaimed keyless application with a new one, instead of keeping it. Only applies when the strategy resolves to keyless — errors otherwise", ) .option("-y, --yes", "Skip confirmation prompts") .option("--no-skills", "Skip the optional agent skills install prompt") @@ -476,7 +664,19 @@ export function registerInit(program: Program): void { }, { command: "clerk init --starter --framework next --keyless", - description: "Bootstrap without logging in (uses temporary dev keys)", + description: "Bootstrap with temporary dev keys, even when logged in", + }, + { + command: "clerk init --login", + description: "Log in and link a real application instead of keyless keys", + }, + { + command: "clerk init --template b2b-saas", + description: "Bootstrap a keyless app pre-configured for B2B SaaS", + }, + { + command: "clerk init --keyless --fresh", + description: "Replace an existing unclaimed keyless app with a new one", }, { command: "clerk init -y", description: "Skip all confirmation prompts" }, { command: "clerk init --no-skills", description: "Skip the agent skills install prompt" }, diff --git a/packages/cli-core/src/commands/init/strategy.test.ts b/packages/cli-core/src/commands/init/strategy.test.ts new file mode 100644 index 000000000..472c9f9ba --- /dev/null +++ b/packages/cli-core/src/commands/init/strategy.test.ts @@ -0,0 +1,753 @@ +import { test, expect, describe, spyOn } from "bun:test"; + +// Pure spyOn approach — Bun's mock.module globally replaces modules for the +// entire test run, which pollutes other test files that import the same +// modules. spyOn restores cleanly. Shared setup lives in the harness. +import { + useInitHarness, + FAKE_CTX, + KEYLESS_CTX, + mockBootstrapTo, + mockExistingProject, + mockMiddlewareScaffold, + type FakeCtx, + loginMod, + linkMod, + pullMod, + config, + context, + scaffoldMod, + heuristics, + bootstrapMod, + keylessMod, + keylessTargetMod, +} from "../../test/lib/init-harness.ts"; +import * as promptsMod from "../../lib/prompts.ts"; +import { init } from "./index.ts"; + +const EXISTING_BREADCRUMB = { claimToken: "tok_existing", createdAt: "2024-01-01T00:00:00.000Z" }; + +describe("init strategy", () => { + const { setup, setupBootstrapSuccess, track } = useInitHarness(); + test("blank dir with keyless framework defaults to keyless when unauthenticated", async () => { + setup(); + mockBootstrapTo(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({}); + + expect(bootstrapMod.promptAndBootstrap).toHaveBeenCalled(); + // Keyless is the default for unauthenticated bootstrap; login is opt-in via --login. + expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); + expect(keylessMod.writeKeylessBreadcrumb).toHaveBeenCalled(); + expect(heuristics.printKeylessInfo).toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + }); + + test("--login on unauthenticated bootstrap forces the authenticated flow", async () => { + setup(); + mockBootstrapTo(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({ login: true }); + + expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); + expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); + expect(linkMod.link).toHaveBeenCalled(); + }); + + test("--template is forwarded to the keyless application create call", async () => { + setup(); + mockBootstrapTo(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({ template: "b2b-saas" }); + + expect(keylessMod.createAccountlessApp).toHaveBeenCalledWith( + KEYLESS_CTX.framework.dep, + "b2b-saas", + ); + }); + + test("--template with --login throws a usage error", async () => { + setup(); + + await expect(init({ template: "b2b-saas", login: true })).rejects.toThrow( + /--template applies to keyless applications/, + ); + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + }); + + test("--keyless with --login throws a usage error before bootstrapping", async () => { + setup(); + + await expect(init({ keyless: true, login: true })).rejects.toThrow( + /--keyless and --login cannot be combined/, + ); + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + }); + + test("--keyless with --app throws a usage error before bootstrapping", async () => { + setup(); + + await expect(init({ keyless: true, app: "app_abc" })).rejects.toThrow( + /--keyless cannot be combined with --app/, + ); + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + }); + + test("--keyless on a keyless-capable framework uses keyless mode without logging in", async () => { + setup(); + mockBootstrapTo(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({ keyless: true }); + + expect(bootstrapMod.promptAndBootstrap).toHaveBeenCalled(); + expect(heuristics.printKeylessInfo).toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); + }); + + test("--keyless takes precedence over an authed user", async () => { + setup({ email: "user@example.com" }); + mockBootstrapTo(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({ keyless: true }); + + expect(heuristics.printKeylessInfo).toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + }); + + test("--keyless on a non-keyless framework throws a usage error", async () => { + setup(); + const nonKeylessCtx: FakeCtx = { + ...FAKE_CTX, + existingClerk: false, + framework: { + dep: "vue", + name: "Vue", + sdk: "@clerk/vue", + envVar: "VITE_CLERK_PUBLISHABLE_KEY", + envFile: ".env.local", + }, + envFile: ".env.local", + }; + mockBootstrapTo(nonKeylessCtx); + + await expect(init({ keyless: true })).rejects.toThrow(/--keyless is not supported for Vue/); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + }); + + test("--keyless on an existing keyless-capable project uses keyless mode", async () => { + setup(); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({ keyless: true }); + + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); + expect(heuristics.printKeylessInfo).toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + }); + + test("bootstrap with keyless framework goes authenticated when already signed in", async () => { + setup({ email: "user@example.com" }); + mockBootstrapTo({ ...KEYLESS_CTX, existingClerk: true }); + + await init({}); + + expect(heuristics.isAuthenticated).toHaveBeenCalled(); + expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); + expect(linkMod.link).toHaveBeenCalled(); + }); + + test("-y flag with keyless framework uses authenticated flow when signed in", async () => { + setup({ email: "user@example.com" }); + mockBootstrapTo({ ...KEYLESS_CTX, existingClerk: true }); + + await init({ yes: true }); + + expect(heuristics.isAuthenticated).toHaveBeenCalled(); + expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); + }); + + test("-y flag with keyless framework uses authenticated flow when CLERK_PLATFORM_API_KEY is set", async () => { + setup({ apiKey: true }); + mockBootstrapTo({ ...KEYLESS_CTX, existingClerk: true }); + + await init({ yes: true }); + + expect(heuristics.isAuthenticated).toHaveBeenCalled(); + expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); + expect(linkMod.link).toHaveBeenCalled(); + }); + + test("-y flag with keyless framework stays keyless when unauthenticated", async () => { + // `-y` only skips y/n confirmations — it neither forces nor bypasses the + // keyless default for unauthenticated bootstrap. + setup(); + mockBootstrapTo(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({ yes: true }); + + expect(heuristics.isAuthenticated).toHaveBeenCalled(); + expect(heuristics.printKeylessInfo).toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + }); + + test("-y --keyless with keyless framework uses keyless mode", async () => { + setup(); + mockBootstrapTo(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({ yes: true, keyless: true }); + + expect(heuristics.printKeylessInfo).toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + }); + + test("agent mode with keyless framework uses keyless with breadcrumb when unauthenticated", async () => { + // Agents can't run interactive OAuth, so unauthenticated agent runs default + // to keyless: the app works immediately and the breadcrumb lets the next + // `clerk auth login` claim it. + setup({ isAgent: true, email: null }); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({}); + + expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); + expect(keylessMod.writeKeylessBreadcrumb).toHaveBeenCalled(); + expect(heuristics.printKeylessInfo).toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + }); + + test("agent mode with --login while unauthenticated throws a usage error", async () => { + setup({ isAgent: true, email: null }); + + await expect(init({ login: true })).rejects.toThrow(/--login requires an interactive terminal/); + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + }); + + test("agent mode with --login while authenticated runs the authenticated flow", async () => { + setup({ isAgent: true, email: "user@example.com" }); + mockExistingProject(KEYLESS_CTX); + spyOn(config, "resolveProfile").mockResolvedValue(undefined); + mockMiddlewareScaffold(); + + await init({ login: true }); + + expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); + expect(linkMod.link).toHaveBeenCalled(); + expect(pullMod.pull).toHaveBeenCalled(); + }); + + test("agent mode with --keyless uses keyless mode without authentication", async () => { + setup({ isAgent: true, email: null }); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({ keyless: true }); + + expect(heuristics.printKeylessInfo).toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); + }); + + test("agent mode with keyless framework + authed creates and links a real app", async () => { + setup({ isAgent: true, email: "user@example.com" }); + mockExistingProject(KEYLESS_CTX); + // Override potential leakage from earlier tests that spy on resolveProfile + // with a non-undefined value but don't track those spies for restoration. + spyOn(config, "resolveProfile").mockResolvedValue(undefined); + mockMiddlewareScaffold(); + + await init({}); + + expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); + expect(linkMod.link).toHaveBeenCalledWith({ + skipIfLinked: true, + app: undefined, + cwd: KEYLESS_CTX.cwd, + createIfMissing: expect.any(String), + }); + expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: KEYLESS_CTX.cwd }); + }); + + test("agent mode with keyless framework uses linked profile as a real app target", async () => { + setup({ isAgent: true, email: "user@example.com" }); + mockExistingProject(KEYLESS_CTX); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_123" }, + } as never); + mockMiddlewareScaffold(); + + await init({}); + + expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: KEYLESS_CTX.cwd }); + }); + + test("agent mode with keyless framework and --app uses real app flow", async () => { + setup({ isAgent: true, email: "user@example.com" }); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({ app: "app_abc" }); + + expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); + expect(linkMod.link).toHaveBeenCalledWith({ + skipIfLinked: true, + app: "app_abc", + cwd: KEYLESS_CTX.cwd, + createIfMissing: expect.any(String), + }); + expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: KEYLESS_CTX.cwd }); + }); + + test("agent mode with non-keyless framework and no app target prints manual setup", async () => { + const { captured } = setup({ isAgent: true, email: "user@example.com" }); + + const noKeylessCtx = { + ...FAKE_CTX, + existingClerk: false, + framework: { + dep: "vue", + name: "Vue", + sdk: "@clerk/vue", + envVar: "VITE_CLERK_PUBLISHABLE_KEY", + envFile: ".env.local" as const, + }, + envFile: ".env.local", + }; + spyOn(context, "gatherContext").mockResolvedValue(noKeylessCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [{ type: "create", path: "src/main.ts", content: "", description: "" }], + postInstructions: [], + }); + + await init({}); + + expect(linkMod.link).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(captured.err).toContain("clerk init --app "); + }); + + test("agent mode with real app target and no auth launches login", async () => { + setup({ isAgent: true }); + spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); + + await init({ app: "app_abc" }); + + expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); + expect(linkMod.link).toHaveBeenCalledWith({ + skipIfLinked: true, + app: "app_abc", + cwd: FAKE_CTX.cwd, + createIfMissing: expect.any(String), + }); + }); + + test("-y flag triggers login when unauthenticated", async () => { + setup(); + setupBootstrapSuccess(); + + await init({ yes: true }); + + expect(bootstrapMod.promptAndBootstrap).toHaveBeenCalled(); + expect(heuristics.isAuthenticated).toHaveBeenCalled(); + // `-y` skips y/n confirmations but not authentication. + expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); + }); + + test("-y flag triggers login for non-keyless frameworks in bootstrap", async () => { + setup(); + + const noKeylessCtx = { + ...FAKE_CTX, + framework: { + dep: "vue", + name: "Vue", + sdk: "@clerk/vue", + envVar: "VITE_CLERK_PUBLISHABLE_KEY", + envFile: ".env.local" as const, + }, + existingClerk: false, + }; + + spyOn(context, "gatherContext").mockResolvedValueOnce(null).mockResolvedValueOnce(noKeylessCtx); + + await init({ yes: true }); + + expect(bootstrapMod.promptAndBootstrap).toHaveBeenCalled(); + expect(heuristics.isAuthenticated).toHaveBeenCalled(); + expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); + expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); + }); + test("existing repo with keyless framework uses authenticated flow when signed in", async () => { + setup({ email: "user@example.com" }); + + const keylessCtx = { + ...FAKE_CTX, + framework: { ...FAKE_CTX.framework, supportsKeyless: true }, + }; + spyOn(context, "gatherContext").mockResolvedValue(keylessCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never); + + await init({ yes: true }); + + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + expect(heuristics.isAuthenticated).toHaveBeenCalled(); + expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); + }); + + test("existing repo with keyless framework uses authenticated flow when not signed in", async () => { + // Keyless auto-selection is scoped to bootstrap (new-project) flows. On an + // existing repo, an unauthenticated re-run should fall through to the + // authenticated flow (which prompts login) rather than silently skip + // `env pull`. + setup(); + + const keylessCtx = { + ...FAKE_CTX, + existingClerk: false, + framework: { ...FAKE_CTX.framework, supportsKeyless: true }, + }; + spyOn(context, "gatherContext").mockResolvedValue(keylessCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }], + postInstructions: [], + }); + spyOn(loginMod, "login").mockResolvedValue({ + userId: "user_1", + email: "test@test.com", + } as never); + + await init({}); + + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + expect(heuristics.isAuthenticated).toHaveBeenCalled(); + expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); + // Unauthenticated + existing repo → login + link run via authenticateAndLink. + expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); + expect(linkMod.link).toHaveBeenCalled(); + expect(pullMod.pull).toHaveBeenCalled(); + }); + + describe("keeping an existing unclaimed keyless app (defect: re-run minted a second app)", () => { + test("agent mode keeps an existing unclaimed keyless app without prompting or minting a new one", async () => { + setup({ isAgent: true, email: null }); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + const breadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb").mockResolvedValue( + EXISTING_BREADCRUMB, + ); + const confirmSpy = spyOn(promptsMod, "confirm"); + const printExistingSpy = spyOn(heuristics, "printExistingKeylessInfo").mockReturnValue( + undefined, + ); + track(breadcrumbSpy); + track(confirmSpy); + track(printExistingSpy); + + await init({}); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); + expect(keylessMod.writeKeylessBreadcrumb).not.toHaveBeenCalled(); + expect(printExistingSpy).toHaveBeenCalledWith(KEYLESS_CTX.envFile); + }); + + test("-y keeps an existing unclaimed keyless app without prompting (does not force a fresh one)", async () => { + setup({ email: null }); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + const breadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb").mockResolvedValue( + EXISTING_BREADCRUMB, + ); + const confirmSpy = spyOn(promptsMod, "confirm"); + const printExistingSpy = spyOn(heuristics, "printExistingKeylessInfo").mockReturnValue( + undefined, + ); + track(breadcrumbSpy); + track(confirmSpy); + track(printExistingSpy); + + await init({ keyless: true, yes: true }); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); + }); + + test("human mode prompts before replacing, defaulting to keep when declined", async () => { + setup({ email: null }); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + const breadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb").mockResolvedValue( + EXISTING_BREADCRUMB, + ); + const confirmSpy = spyOn(promptsMod, "confirm").mockResolvedValue(false); + const printExistingSpy = spyOn(heuristics, "printExistingKeylessInfo").mockReturnValue( + undefined, + ); + track(breadcrumbSpy); + track(confirmSpy); + track(printExistingSpy); + + await init({ keyless: true }); + + expect(confirmSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("already has an unclaimed keyless application"), + default: false, + }), + ); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); + expect(printExistingSpy).toHaveBeenCalledWith(KEYLESS_CTX.envFile); + }); + + test("an app the SDK minted for itself counts as existing too — no silent replacement", async () => { + setup({ isAgent: true, email: null }); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + // No CLI breadcrumb — the app came from running the dev server, so the + // only trace is the SDK's own .clerk/.tmp/keyless.json. + const breadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb").mockResolvedValue(undefined); + const sdkAppSpy = spyOn(keylessTargetMod, "readSdkKeylessApp").mockResolvedValue({ + secretKey: "sk_test_sdkapp", + publishableKey: "pk_test_sdkapp", + }); + const confirmSpy = spyOn(promptsMod, "confirm"); + const printExistingSpy = spyOn(heuristics, "printExistingKeylessInfo").mockReturnValue( + undefined, + ); + track(breadcrumbSpy); + track(sdkAppSpy); + track(confirmSpy); + track(printExistingSpy); + + await init({}); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); + expect(printExistingSpy).toHaveBeenCalledWith(KEYLESS_CTX.envFile); + }); + + test("human mode names the SDK file when prompting to replace an SDK-minted app", async () => { + setup({ email: null }); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + const breadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb").mockResolvedValue(undefined); + const sdkAppSpy = spyOn(keylessTargetMod, "readSdkKeylessApp").mockResolvedValue({ + secretKey: "sk_test_sdkapp", + }); + const confirmSpy = spyOn(promptsMod, "confirm").mockResolvedValue(false); + const printExistingSpy = spyOn(heuristics, "printExistingKeylessInfo").mockReturnValue( + undefined, + ); + track(breadcrumbSpy); + track(sdkAppSpy); + track(confirmSpy); + track(printExistingSpy); + + await init({ keyless: true }); + + expect(confirmSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining(".clerk/.tmp/keyless.json"), + default: false, + }), + ); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); + }); + + test("human mode mints a fresh app when the user confirms replacement", async () => { + setup({ email: null }); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + const breadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb").mockResolvedValue( + EXISTING_BREADCRUMB, + ); + const confirmSpy = spyOn(promptsMod, "confirm").mockResolvedValue(true); + track(breadcrumbSpy); + track(confirmSpy); + + await init({ keyless: true }); + + expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); + expect(keylessMod.writeKeylessBreadcrumb).toHaveBeenCalled(); + }); + + test("--fresh mints a new app without prompting or checking for an existing one, even in agent mode", async () => { + setup({ isAgent: true, email: null }); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + const breadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb"); + const confirmSpy = spyOn(promptsMod, "confirm"); + track(breadcrumbSpy); + track(confirmSpy); + + await init({ fresh: true }); + + expect(breadcrumbSpy).not.toHaveBeenCalled(); + expect(confirmSpy).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); + }); + + test("--fresh with --login throws a usage error before bootstrapping", async () => { + setup(); + + await expect(init({ fresh: true, login: true })).rejects.toThrow( + /--fresh applies to keyless applications/, + ); + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + }); + + test("no existing breadcrumb mints an app normally without prompting", async () => { + setup({ isAgent: true, email: null }); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + const breadcrumbSpy = spyOn(keylessMod, "readKeylessBreadcrumb").mockResolvedValue(undefined); + const confirmSpy = spyOn(promptsMod, "confirm"); + track(breadcrumbSpy); + track(confirmSpy); + + await init({}); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); + }); + }); + + describe("--template / --fresh silently dropped when the strategy isn't keyless (defect)", () => { + test("--template with CLERK_PLATFORM_API_KEY set errors instead of silently dropping the template", async () => { + setup({ apiKey: true }); + mockExistingProject(KEYLESS_CTX); + + await expect(init({ template: "b2b-saas", yes: true })).rejects.toThrow( + /--template only applies to keyless applications/, + ); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); + }); + + test("--fresh with CLERK_PLATFORM_API_KEY set errors instead of silently doing nothing", async () => { + setup({ apiKey: true }); + mockExistingProject(KEYLESS_CTX); + + await expect(init({ fresh: true, yes: true })).rejects.toThrow( + /--fresh only applies to keyless applications/, + ); + }); + + test("--template with --app errors (a real app target always forces the authenticated flow)", async () => { + setup({ email: "user@example.com" }); + spyOn(context, "gatherContext").mockResolvedValue(KEYLESS_CTX); + + await expect(init({ template: "b2b-saas", app: "app_abc", yes: true })).rejects.toThrow( + /--template only applies to keyless applications/, + ); + }); + + test("--template on a non-keyless framework in agent mode names the missing keyless support", async () => { + setup({ isAgent: true, email: "user@example.com" }); + const nonKeylessCtx: FakeCtx = { + ...FAKE_CTX, + existingClerk: false, + framework: { + dep: "vue", + name: "Vue", + sdk: "@clerk/vue", + envVar: "VITE_CLERK_PUBLISHABLE_KEY", + envFile: ".env.local", + }, + envFile: ".env.local", + }; + spyOn(context, "gatherContext").mockResolvedValue(nonKeylessCtx); + + await expect(init({ template: "b2b-saas" })).rejects.toThrow(/does not support keyless mode/); + }); + + test("--template on a keyless-resolved run is still forwarded normally", async () => { + setup(); + mockBootstrapTo(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({ template: "b2b-saas" }); + + expect(keylessMod.createAccountlessApp).toHaveBeenCalledWith( + KEYLESS_CTX.framework.dep, + "b2b-saas", + ); + }); + }); + + describe("agent mode never trusts stored-credential presence over an interactive login hang (defect)", () => { + test("falls back to keyless when the stored credential's presence check would say yes but validation fails", async () => { + // Regression test for the hang: a broken/expired stored session still + // makes hasAccountCredentials() (presence) report true, but agent mode + // has no interactive fallback if that lies — it must validate before + // trusting it, not just check whether *something* is stored. + setup({ isAgent: true, email: null }); + spyOn(heuristics, "isAuthenticated").mockResolvedValue(true); + mockExistingProject(KEYLESS_CTX); + mockMiddlewareScaffold(); + + await init({}); + + expect(heuristics.isAuthenticated).not.toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + }); + + test("--login validates the credential instead of trusting presence, still erroring on a stale session", async () => { + setup({ isAgent: true, email: null }); + spyOn(heuristics, "isAuthenticated").mockResolvedValue(true); + + await expect(init({ login: true })).rejects.toThrow( + /--login requires an interactive terminal/, + ); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + }); + + test("a real CLERK_PLATFORM_API_KEY is trusted outright, without needing to validate a stored session", async () => { + process.env.CLERK_PLATFORM_API_KEY = "test_key"; + try { + setup({ isAgent: true, email: null }); + mockExistingProject(KEYLESS_CTX); + spyOn(config, "resolveProfile").mockResolvedValue(undefined); + mockMiddlewareScaffold(); + + await init({}); + + expect(linkMod.link).toHaveBeenCalled(); + expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); + } finally { + delete process.env.CLERK_PLATFORM_API_KEY; + } + }); + }); +}); diff --git a/packages/cli-core/src/commands/open/README.md b/packages/cli-core/src/commands/open/README.md new file mode 100644 index 000000000..1d769f317 --- /dev/null +++ b/packages/cli-core/src/commands/open/README.md @@ -0,0 +1,62 @@ +# Open Command + +Opens the linked Clerk application's dashboard in your browser. When the current directory isn't linked but holds an unclaimed keyless application instead, opens that application's one-time **claim link** rather than failing. + +## Usage + +```sh +clerk open # Open the linked app's dashboard (development instance) +clerk open users # Open a known subpath +clerk open api-keys +clerk open --print # Print the URL instead of opening a browser +``` + +`open` is Commander's default subcommand for the `open` group — `clerk open [subpath]` and `clerk open dashboard [subpath]` are equivalent. + +## Options + +| Option | Description | +| --------- | ---------------------------------------------------------------------------- | +| `--print` | Print the URL on stdout; don't open a browser or a browser fallback message. | + +## Behavior + +1. Resolves the linked profile for the current directory (`resolveProfile(cwd)` from [`lib/config.ts`](../../lib/config.ts)). +2. **Linked** — builds `{dashboardUrl}/apps/{appId}/instances/{instanceId}/{subpath?}` via `buildDashboardUrl()` and opens it. Always targets the **development** instance; throws `INSTANCE_NOT_FOUND` if the profile has none. +3. **Not linked** — falls through to the keyless path below instead of failing outright. + +An unknown `subpath` (not in [`dashboard-paths.ts`](./dashboard-paths.ts)'s allowlist) is not blocked, just warned about — the CLI opens it anyway since the allowlist can't keep up with every dashboard route. + +### Unclaimed keyless applications + +`clerk link` cannot help a keyless application that has never been claimed — there is no application in any account yet to link to. For that case `open` instead looks for the application's **claim link**, via [`keyless-claim.ts`](./keyless-claim.ts), checking (in order): + +1. `.clerk/.tmp/keyless.json` — an SDK that self-provisioned keys (e.g. `next dev` with none configured) writes its own full `claimUrl` here. +2. `.clerk/keyless.json` — the breadcrumb `clerk init --keyless` writes (see [`lib/keyless.ts`](../../lib/keyless.ts)'s `readKeylessBreadcrumb`), holding just the claim token; the URL is rebuilt as `{dashboardUrl}/apps/claim?token={claimToken}`. + +If a claim link is found, `open` opens/prints/emits **that** URL instead of a dashboard deep-link — an unclaimed app has no `/apps/{appId}/instances/{instanceId}` page to go to. The local secret key (resolved the same way as [`whoami`](../whoami/README.md), via `resolveKeylessTarget()`) is used, best-effort, to look up the instance id/environment type from `GET /v1/instance` purely to decorate the output; a missing or invalid key there never blocks opening the claim link itself. + +A `subpath` cannot be honored for an unclaimed application (there is no dashboard page beyond the claim link yet), so `open users` on an unclaimed app throws instead of silently opening the claim link at the wrong URL: + +```text +"users" isn't reachable yet — this application hasn't been claimed, so it has +no dashboard pages beyond the claim link. Run `clerk open` (no subpath) to +claim it, then retry the subpath once it's linked. +``` + +When **no** claim link can be found at all, the error differs depending on what is on disk, and deliberately does not send the user solely to `clerk link` (a dead end for a genuinely unclaimed app): + +- A secret key exists locally but no claim source does (e.g. `CLERK_SECRET_KEY` set by hand with no `.clerk` files) — names both possibilities: the key may belong to an already-claimed app (`clerk link` / `--app `) or the claim breadcrumb was lost (`clerk init --keyless` regenerates one). +- Nothing at all is found — the original message, pointing at both `clerk link` (if an application already exists in your account) and `clerk init` (to create one). + +| Method | Endpoint | Description | +| ------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/instance` | Best-effort instance id/environment lookup for display, authenticated with the local secret key. Never blocks the claim link on failure. | + +## Output modes + +- **`--print`** — the bare URL on stdout, nothing else. Works identically for the dashboard deep-link and the keyless claim link. +- **Agent mode** (`isAgent()`) — a JSON object on stdout, `opened: false` either way: + - Linked: `{ url, appId, appName, instanceId, instanceLabel, subpath, opened }`. + - Keyless: `{ url, keyless: true, claimSource, instanceId, environmentType, subpath: null, opened: false }`. +- **Human mode** — `intro`/`outro` framing, the target app or claim-link context on stderr, then attempts `openBrowser()`. On failure, prints the URL as a fallback instead of failing the command. diff --git a/packages/cli-core/src/commands/open/index.test.ts b/packages/cli-core/src/commands/open/index.test.ts index a3d6ce634..be7a71711 100644 --- a/packages/cli-core/src/commands/open/index.test.ts +++ b/packages/cli-core/src/commands/open/index.test.ts @@ -1,13 +1,17 @@ import { test, expect, describe, afterEach, beforeEach, mock } from "bun:test"; import { setMode } from "../../mode.ts"; import { setCurrentEnv } from "../../lib/environment.ts"; -import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { configStubs, keylessTargetStubs, useCaptureLog } from "../../test/lib/stubs.ts"; import { isKnownDashboardPath } from "./dashboard-paths.ts"; const mockResolveProfile = mock(); const mockOpenBrowser = mock(); +const mockResolveKeylessTarget = mock(); +const mockFindKeylessClaimUrl = mock(); +const mockDescribeKeylessInstance = mock(); mock.module("../../lib/config.ts", () => ({ + ...configStubs, resolveProfile: (...args: unknown[]) => mockResolveProfile(...args), })); @@ -21,6 +25,16 @@ mock.module("../../lib/spinner.ts", () => ({ pausedOutro: () => {}, })); +mock.module("../../lib/keyless-target.ts", () => ({ + ...keylessTargetStubs, + resolveKeylessTarget: (...args: unknown[]) => mockResolveKeylessTarget(...args), +})); + +mock.module("./keyless-claim.ts", () => ({ + findKeylessClaimUrl: (...args: unknown[]) => mockFindKeylessClaimUrl(...args), + describeKeylessInstance: (...args: unknown[]) => mockDescribeKeylessInstance(...args), +})); + const { openDashboard, buildDashboardUrl } = await import("./index.ts"); const PROFILE = { @@ -85,11 +99,18 @@ describe("openDashboard", () => { setMode("human"); setCurrentEnv("production"); mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "open" }); + // Default to "no keyless application on disk either" so the pre-existing + // not-linked tests exercise the fully-empty case, not the keyless branch. + mockFindKeylessClaimUrl.mockResolvedValue(undefined); + mockResolveKeylessTarget.mockResolvedValue(undefined); }); afterEach(() => { mockResolveProfile.mockReset(); mockOpenBrowser.mockReset(); + mockResolveKeylessTarget.mockReset(); + mockFindKeylessClaimUrl.mockReset(); + mockDescribeKeylessInstance.mockReset(); }); test("human mode: prints arrow + app + dim URL, opens browser", async () => { @@ -186,10 +207,10 @@ describe("openDashboard", () => { ); }); - test("throws NOT_LINKED when no profile", async () => { + test("throws NOT_LINKED when no profile and no keyless application either", async () => { mockResolveProfile.mockResolvedValue(null); - await expect(openDashboard(undefined)).rejects.toThrow(/clerk link/); + await expect(openDashboard(undefined)).rejects.toThrow(/clerk link.*clerk init/is); expect(mockOpenBrowser).not.toHaveBeenCalled(); }); @@ -206,3 +227,117 @@ describe("openDashboard", () => { expect(mockOpenBrowser).not.toHaveBeenCalled(); }); }); + +describe("openDashboard: unclaimed keyless application", () => { + const captured = useCaptureLog(); + + const CLAIM_DESTINATION = { + url: "https://dashboard.clerk.com/apps/claim?token=abc123", + source: ".clerk/keyless.json", + }; + + beforeEach(() => { + setMode("human"); + setCurrentEnv("production"); + mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "open" }); + mockResolveProfile.mockResolvedValue(null); + mockDescribeKeylessInstance.mockResolvedValue({ + instanceId: "ins_keyless123", + environmentType: "development", + }); + }); + + afterEach(() => { + mockResolveProfile.mockReset(); + mockOpenBrowser.mockReset(); + mockResolveKeylessTarget.mockReset(); + mockFindKeylessClaimUrl.mockReset(); + mockDescribeKeylessInstance.mockReset(); + }); + + test("human mode: opens the claim link, not a dashboard deep-link", async () => { + mockFindKeylessClaimUrl.mockResolvedValue(CLAIM_DESTINATION); + mockResolveKeylessTarget.mockResolvedValue({ secretKey: "sk_test_x", source: ".env" }); + + await openDashboard(undefined); + + expect(captured.err).toContain("hasn't been claimed"); + expect(captured.err).toContain("ins_keyless123"); + expect(captured.err).toContain(CLAIM_DESTINATION.url); + expect(mockOpenBrowser).toHaveBeenCalledWith(CLAIM_DESTINATION.url); + }); + + test("--print: prints only the claim URL, no browser", async () => { + mockFindKeylessClaimUrl.mockResolvedValue(CLAIM_DESTINATION); + mockResolveKeylessTarget.mockResolvedValue({ secretKey: "sk_test_x", source: ".env" }); + + await openDashboard(undefined, { print: true }); + + expect(captured.out).toBe(CLAIM_DESTINATION.url); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("agent mode: emits structured JSON with keyless: true, no browser", async () => { + setMode("agent"); + mockFindKeylessClaimUrl.mockResolvedValue(CLAIM_DESTINATION); + mockResolveKeylessTarget.mockResolvedValue({ secretKey: "sk_test_x", source: ".env" }); + + await openDashboard(undefined); + + const payload = JSON.parse(captured.out); + expect(payload).toEqual({ + url: CLAIM_DESTINATION.url, + keyless: true, + claimSource: CLAIM_DESTINATION.source, + instanceId: "ins_keyless123", + environmentType: "development", + subpath: null, + opened: false, + }); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("a bad secret key on disk doesn't block the claim link — instance details just come back null", async () => { + mockFindKeylessClaimUrl.mockResolvedValue(CLAIM_DESTINATION); + mockResolveKeylessTarget.mockRejectedValue(new Error("malformed key")); + + await openDashboard(undefined, { print: true }); + + expect(captured.out).toBe(CLAIM_DESTINATION.url); + }); + + test("subpath is refused instead of opening a dashboard page that doesn't exist yet", async () => { + mockFindKeylessClaimUrl.mockResolvedValue(CLAIM_DESTINATION); + + await expect(openDashboard("users")).rejects.toThrow(/users.*hasn't been claimed/is); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("subpath refusal does not point at `clerk link`", async () => { + mockFindKeylessClaimUrl.mockResolvedValue(CLAIM_DESTINATION); + + let message = ""; + try { + await openDashboard("users"); + } catch (error) { + message = (error as Error).message; + } + expect(message).not.toMatch(/clerk link/); + }); + + test("no claim link found, but a secret key is on disk: doesn't blindly say `clerk link`", async () => { + mockFindKeylessClaimUrl.mockResolvedValue(undefined); + mockResolveKeylessTarget.mockResolvedValue({ secretKey: "sk_test_x", source: ".env.local" }); + + await expect(openDashboard(undefined)).rejects.toThrow(/clerk init --keyless/); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); + + test("no claim link and no secret key: falls back to the plain not-linked message", async () => { + mockFindKeylessClaimUrl.mockResolvedValue(undefined); + mockResolveKeylessTarget.mockResolvedValue(undefined); + + await expect(openDashboard(undefined)).rejects.toThrow(/clerk link.*clerk init/is); + expect(mockOpenBrowser).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/commands/open/index.ts b/packages/cli-core/src/commands/open/index.ts index e8aea8e81..b0f9cb1a8 100644 --- a/packages/cli-core/src/commands/open/index.ts +++ b/packages/cli-core/src/commands/open/index.ts @@ -8,7 +8,9 @@ import { log } from "../../lib/log.ts"; import { bold, cyan, dim } from "../../lib/color.ts"; import { intro, outro } from "../../lib/spinner.ts"; import { isAgent } from "../../mode.ts"; +import { resolveKeylessTarget } from "../../lib/keyless-target.ts"; import { isKnownDashboardPath } from "./dashboard-paths.ts"; +import { describeKeylessInstance, findKeylessClaimUrl } from "./keyless-claim.ts"; interface OpenOptions { print?: boolean; @@ -34,9 +36,7 @@ export async function openDashboard( const resolved = await resolveProfile(cwd); if (!resolved) { - throw new CliError("No Clerk project linked to this directory. Run `clerk link` first.", { - code: ERROR_CODE.NOT_LINKED, - }); + return openKeylessDashboard(cwd, subpath, options); } const { appId, appName } = resolved.profile; @@ -105,6 +105,100 @@ export async function openDashboard( outro(); } +/** + * The keyless counterpart to `openDashboard` above. An unclaimed keyless + * application belongs to no account, so `/apps/{appId}/instances/{instanceId}` + * doesn't exist for it yet — the one page that does is the one-time claim + * link. `clerk link` cannot help here (there is nothing in any account to + * link to), so this path exists precisely so the CLI has *some* answer + * instead of dead-ending on that advice. + */ +async function openKeylessDashboard( + cwd: string, + subpath: string | undefined, + options: OpenOptions, +): Promise { + const destination = await findKeylessClaimUrl(cwd); + + if (!destination) { + // A secret key on disk with no claim link is ambiguous: it may be a + // perfectly claimed app's key set by hand (in which case `clerk link` + // really is the fix), or a keyless app whose breadcrumb got lost. Name + // both possibilities rather than guessing. + const keyless = await resolveKeylessTarget({ cwd }); + if (keyless) { + throw new CliError( + `Found a secret key (from ${keyless.source}) but no claim link on disk, so there's no dashboard page to open yet. ` + + "If this key belongs to an application you've already claimed, run `clerk link` (or pass `--app `) to target it directly. " + + "Otherwise, run `clerk init --keyless` to regenerate a claim link.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } + + throw new CliError( + "No Clerk project linked to this directory, and no keyless application was found either. " + + "Run `clerk link` if you already have an application, or `clerk init` to create one.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } + + if (subpath) { + throw new CliError( + `"${subpath}" isn't reachable yet — this application hasn't been claimed, so it has no dashboard pages beyond the claim link. ` + + "Run `clerk open` (no subpath) to claim it, then retry the subpath once it's linked.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } + + const { url, source } = destination; + + // Best-effort only: the claim link is already fully formed, so a bad or + // missing secret key shouldn't block opening it — it just means the + // output won't include instance details. + const instance = await resolveKeylessTarget({ cwd }) + .then((keyless) => (keyless ? describeKeylessInstance(keyless.secretKey) : null)) + .catch(() => null); + + // Output strategy mirrors the linked-app flow above: + // --print → plain URL on stdout (scriptable) + // agent mode → JSON object with full context (parseable) + // human mode → intro/outro logging flow with browser open + if (options.print) { + log.data(url); + return; + } + + if (isAgent()) { + log.data( + JSON.stringify({ + url, + keyless: true, + claimSource: source, + instanceId: instance?.instanceId ?? null, + environmentType: instance?.environmentType ?? null, + subpath: null, + opened: false, + }), + ); + return; + } + + intro("Opening dashboard"); + + const suffix = instance?.instanceId ? ` (${instance.instanceId})` : ""; + log.info(`↗ This application hasn't been claimed yet — opening its claim link${suffix}`); + log.info(` ${dim(url)}`); + + const result = await openBrowser(url); + if (!result.ok) { + log.warn( + `Could not open your browser automatically. Open this URL to continue:\n ${cyan(url)}\n${dim(`(Reason: ${result.reason})`)}`, + ); + } + + outro(); +} + export function registerOpen(program: Program): void { const open = program.command("open").description("Open Clerk resources in your browser"); diff --git a/packages/cli-core/src/commands/open/keyless-claim.test.ts b/packages/cli-core/src/commands/open/keyless-claim.test.ts new file mode 100644 index 000000000..40a1edd00 --- /dev/null +++ b/packages/cli-core/src/commands/open/keyless-claim.test.ts @@ -0,0 +1,183 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { setCurrentEnv } from "../../lib/environment.ts"; +import { stubFetch } from "../../test/lib/stubs.ts"; +import { describeKeylessInstance, findKeylessClaimUrl } from "./keyless-claim.ts"; + +describe("findKeylessClaimUrl", () => { + let projectDir: string; + const originalFetch = globalThis.fetch; + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), "clerk-open-keyless-")); + setCurrentEnv("production"); + }); + + afterEach(async () => { + globalThis.fetch = originalFetch; + await rm(projectDir, { recursive: true, force: true }); + }); + + test("returns undefined when neither file exists", async () => { + expect(await findKeylessClaimUrl(projectDir)).toBeUndefined(); + }); + + test("reads the full claimUrl an SDK wrote for itself", async () => { + await mkdir(join(projectDir, ".clerk", ".tmp"), { recursive: true }); + await writeFile( + join(projectDir, ".clerk", ".tmp", "keyless.json"), + JSON.stringify({ claimUrl: "https://dashboard.clerk.com/apps/claim?token=sdk-token" }), + ); + + expect(await findKeylessClaimUrl(projectDir)).toEqual({ + url: "https://dashboard.clerk.com/apps/claim?token=sdk-token", + source: ".clerk/.tmp/keyless.json", + }); + }); + + test("rebuilds the URL from the CLI's own breadcrumb token", async () => { + await mkdir(join(projectDir, ".clerk"), { recursive: true }); + await writeFile( + join(projectDir, ".clerk", "keyless.json"), + JSON.stringify({ claimToken: "cli-token", createdAt: new Date().toISOString() }), + ); + + expect(await findKeylessClaimUrl(projectDir)).toEqual({ + url: "https://dashboard.clerk.com/apps/claim?token=cli-token", + source: ".clerk/keyless.json", + }); + }); + + test("prefers the SDK's own claimUrl over the CLI breadcrumb", async () => { + await mkdir(join(projectDir, ".clerk", ".tmp"), { recursive: true }); + await writeFile( + join(projectDir, ".clerk", ".tmp", "keyless.json"), + JSON.stringify({ claimUrl: "https://dashboard.clerk.com/apps/claim?token=sdk-token" }), + ); + await writeFile( + join(projectDir, ".clerk", "keyless.json"), + JSON.stringify({ claimToken: "cli-token", createdAt: new Date().toISOString() }), + ); + + const destination = await findKeylessClaimUrl(projectDir); + expect(destination?.source).toBe(".clerk/.tmp/keyless.json"); + }); + + test("falls back to the breadcrumb when the SDK file has no claimUrl", async () => { + await mkdir(join(projectDir, ".clerk", ".tmp"), { recursive: true }); + await writeFile( + join(projectDir, ".clerk", ".tmp", "keyless.json"), + JSON.stringify({ secretKey: "sk_test_x" }), + ); + await mkdir(join(projectDir, ".clerk"), { recursive: true }); + await writeFile( + join(projectDir, ".clerk", "keyless.json"), + JSON.stringify({ claimToken: "cli-token", createdAt: new Date().toISOString() }), + ); + + const destination = await findKeylessClaimUrl(projectDir); + expect(destination?.source).toBe(".clerk/keyless.json"); + }); + + test("ignores a non-https claimUrl and falls back to the breadcrumb", async () => { + await mkdir(join(projectDir, ".clerk", ".tmp"), { recursive: true }); + await writeFile( + join(projectDir, ".clerk", ".tmp", "keyless.json"), + JSON.stringify({ claimUrl: "file:///etc/passwd" }), + ); + await writeFile( + join(projectDir, ".clerk", "keyless.json"), + JSON.stringify({ claimToken: "cli-token", createdAt: new Date().toISOString() }), + ); + + const destination = await findKeylessClaimUrl(projectDir); + expect(destination?.source).toBe(".clerk/keyless.json"); + }); + + test("ignores a claimUrl that doesn't parse as a URL at all", async () => { + await mkdir(join(projectDir, ".clerk", ".tmp"), { recursive: true }); + await writeFile( + join(projectDir, ".clerk", ".tmp", "keyless.json"), + JSON.stringify({ claimUrl: 'not-a-url" --evil' }), + ); + + expect(await findKeylessClaimUrl(projectDir)).toBeUndefined(); + }); + + test("URL-encodes the breadcrumb claim token", async () => { + await mkdir(join(projectDir, ".clerk"), { recursive: true }); + await writeFile( + join(projectDir, ".clerk", "keyless.json"), + JSON.stringify({ claimToken: "cli token&extra=1", createdAt: new Date().toISOString() }), + ); + + const destination = await findKeylessClaimUrl(projectDir); + expect(destination?.url).toBe( + "https://dashboard.clerk.com/apps/claim?token=cli%20token%26extra%3D1", + ); + }); + + test("ignores a malformed SDK keyless file and falls back to the breadcrumb", async () => { + await mkdir(join(projectDir, ".clerk", ".tmp"), { recursive: true }); + await writeFile(join(projectDir, ".clerk", ".tmp", "keyless.json"), "{ not json"); + await writeFile( + join(projectDir, ".clerk", "keyless.json"), + JSON.stringify({ claimToken: "cli-token", createdAt: new Date().toISOString() }), + ); + + expect(await findKeylessClaimUrl(projectDir)).toEqual({ + url: "https://dashboard.clerk.com/apps/claim?token=cli-token", + source: ".clerk/keyless.json", + }); + }); +}); + +describe("describeKeylessInstance", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + setCurrentEnv("production"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("returns instance id and environment type on success", async () => { + stubFetch(async () => + Response.json({ id: "ins_abc", object: "instance", environment_type: "development" }), + ); + + expect(await describeKeylessInstance("sk_test_x")).toEqual({ + instanceId: "ins_abc", + environmentType: "development", + }); + }); + + test("returns nulls instead of throwing when BAPI rejects the key", async () => { + stubFetch( + async () => + new Response(JSON.stringify({ errors: [{ message: "unauthorized" }] }), { + status: 401, + }), + ); + + expect(await describeKeylessInstance("sk_test_bad")).toEqual({ + instanceId: null, + environmentType: null, + }); + }); + + test("returns nulls instead of throwing on a network failure", async () => { + stubFetch(async () => { + throw new Error("network down"); + }); + + expect(await describeKeylessInstance("sk_test_x")).toEqual({ + instanceId: null, + environmentType: null, + }); + }); +}); diff --git a/packages/cli-core/src/commands/open/keyless-claim.ts b/packages/cli-core/src/commands/open/keyless-claim.ts new file mode 100644 index 000000000..a9b6a19d2 --- /dev/null +++ b/packages/cli-core/src/commands/open/keyless-claim.ts @@ -0,0 +1,116 @@ +/** + * Where `clerk open` sends an unclaimed keyless application. + * + * A keyless app belongs to no account yet, so the normal dashboard deep-link + * (`/apps/{appId}/instances/{instanceId}/...`) 404s — there is no appId to + * put in it. The only page that reliably exists for it is the one-time claim + * link, which is why this module looks for that instead of reusing + * `buildDashboardUrl`. + */ + +import { join } from "node:path"; +import { readKeylessBreadcrumb } from "../../lib/keyless.ts"; +import { getDashboardUrl } from "../../lib/environment.ts"; +import { bapiRequest } from "../../lib/bapi.ts"; +import { log } from "../../lib/log.ts"; + +export interface KeylessClaimDestination { + url: string; + /** Where the claim link came from, for display (mirrors `KeylessTarget.source`). */ + source: string; +} + +/** Best-effort instance metadata for display only — never blocks the claim link. */ +export interface KeylessInstanceInfo { + instanceId: string | null; + environmentType: string | null; +} + +const SDK_KEYLESS_FILE = [".clerk", ".tmp", "keyless.json"]; + +/** + * 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: + * + * - `clerk init --keyless` writes only the claim TOKEN to `.clerk/keyless.json` + * (see `writeKeylessBreadcrumb`); the URL is rebuilt against whichever + * dashboard host the CLI is currently pointed at. + * - An SDK that self-provisions (e.g. `next dev` with no keys configured) + * writes the full URL straight into `.clerk/.tmp/keyless.json`, so that one + * is used verbatim rather than reconstructed. + * + * Returns `undefined` when neither file has a usable claim link. + */ +export async function findKeylessClaimUrl( + cwd: string, +): Promise { + const sdkClaimUrl = await readSdkClaimUrl(cwd); + if (sdkClaimUrl) { + return { url: sdkClaimUrl, source: SDK_KEYLESS_FILE.join("/") }; + } + + const breadcrumb = await readKeylessBreadcrumb(cwd); + if (breadcrumb) { + const host = getDashboardUrl().replace(/\/$/, ""); + return { + url: `${host}/apps/claim?token=${encodeURIComponent(breadcrumb.claimToken)}`, + source: ".clerk/keyless.json", + }; + } + + return undefined; +} + +/** + * Enriches the claim link with the instance's own id/environment, purely for + * display. BAPI has no route that returns claim info for an existing + * instance (only the one-time creation response does), so this can only ever + * decorate a destination already found on disk — never produce one itself. + * Failures here must not block opening the claim link, hence the catch. + */ +export async function describeKeylessInstance(secretKey: string): Promise { + try { + const response = await bapiRequest({ method: "GET", path: "/v1/instance", secretKey }); + const body = response.body as { id?: string; environment_type?: string }; + return { instanceId: body.id ?? null, environmentType: body.environment_type ?? null }; + } catch (error) { + log.debug(`open: could not fetch instance info for keyless app (${(error as Error).message})`); + return { instanceId: null, environmentType: null }; + } +} diff --git a/packages/cli-core/src/commands/orgs/README.md b/packages/cli-core/src/commands/orgs/README.md index f585f9c8d..41f8f7ca9 100644 --- a/packages/cli-core/src/commands/orgs/README.md +++ b/packages/cli-core/src/commands/orgs/README.md @@ -4,6 +4,17 @@ Toggle Clerk Organizations on the linked instance. The handlers are wired to top-level `clerk enable orgs` and `clerk disable orgs` commands; the source lives here so future org-related commands (settings, CRUD) can co-locate. +Works without an account. When the directory isn't linked and no `--app` is +passed, both commands target the unclaimed keyless application whose secret key +the project holds locally, writing through `PATCH /v1/instance/organization_settings` +instead of the account-level config document. The payload field names are +identical on both paths, so nothing is translated. See +[keyless mode](../config/README.md#keyless-mode). + +One difference in keyless mode: `clerk disable orgs` can't run its +organization-billing pre-flight check, because billing is not readable without +an account. + ## Usage ``` diff --git a/packages/cli-core/src/commands/orgs/index.ts b/packages/cli-core/src/commands/orgs/index.ts index aebf8076c..ee495ac4d 100644 --- a/packages/cli-core/src/commands/orgs/index.ts +++ b/packages/cli-core/src/commands/orgs/index.ts @@ -1,10 +1,10 @@ -import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfig } from "../../lib/plapi.ts"; import { throwUsageError, withApiContext } from "../../lib/errors.ts"; import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { isHuman } from "../../mode.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { applyConfigPatch } from "../config/apply-patch.ts"; +import { resolveInstanceTarget } from "../../lib/keyless-target.ts"; interface OrgsOptions { app?: string; @@ -31,7 +31,7 @@ function parsePositiveInt(value: string, flag: string): number { } export async function orgsEnable(options: OrgsOptions): Promise { - const ctx = await resolveAppContext(options); + const target = await resolveInstanceTarget(options); const orgSettings: Record = { enabled: true }; if (options.forceSelection) orgSettings.force_organization_selection = true; @@ -53,7 +53,7 @@ export async function orgsEnable(options: OrgsOptions): Promise { await withGutter("Enabling organizations", async ({ setNextSteps }) => { const applied = await applyConfigPatch({ - ctx, + target, payload: { organization_settings: orgSettings }, verb: "Enabling organizations", successMessage: "Organizations enabled", @@ -69,17 +69,26 @@ export async function orgsEnable(options: OrgsOptions): Promise { } export async function orgsDisable(options: OrgsOptions): Promise { - const ctx = await resolveAppContext(options); + const target = await resolveInstanceTarget(options); await withGutter("Disabling organizations", async () => { - const current = await withSpinner("Fetching current config...", () => - withApiContext( - fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing", "organization_settings"]), - "Failed to fetch config", - ), - ); + // Billing lives only in the account-level config document, so a keyless run + // can't read it. Skip the pre-flight fetch entirely rather than half-run it: + // applyConfigPatch reads the group it needs on its own. + const current = + target.kind === "account" + ? await withSpinner("Fetching current config...", () => + withApiContext( + fetchInstanceConfig(target.ctx.appId, target.ctx.instanceId, [ + "billing", + "organization_settings", + ]), + "Failed to fetch config", + ), + ) + : undefined; - const billing = current.billing as Record | undefined; + const billing = current?.billing as Record | undefined; const orgBillingOn = billing?.organization_enabled === true; // Agent mode: refuse rather than warn-then-mutate (warn-then-mutate in CI @@ -92,7 +101,7 @@ export async function orgsDisable(options: OrgsOptions): Promise { } await applyConfigPatch({ - ctx, + target, payload: { organization_settings: { enabled: false } }, verb: "Disabling organizations", successMessage: "Organizations disabled", diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index c6ee420b8..812c5cb0f 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -2,6 +2,11 @@ Manage direct Clerk user resources with first-class commands. Use `clerk api` for unsupported or fully custom user requests. +Works with no login and no linked project on an **unclaimed keyless +application** — the one an SDK creates for itself the first time you run +`next dev` (or similar) with no keys configured. See +[Shared Targeting And Auth](#shared-targeting-and-auth) below. + ## Shared Targeting And Auth Most `clerk users` commands accept the same targeting flags: @@ -16,12 +21,14 @@ Most `clerk users` commands accept the same targeting flags: Authentication is resolved in this order: -- `--app ` plus Platform API auth to resolve the instance secret key -- `--secret-key ` -- `CLERK_SECRET_KEY` +- `--secret-key ` (explicit) +- `--app ` plus Platform API auth to resolve the instance secret key (explicit) +- this project's own keyless secret key — `CLERK_SECRET_KEY`, `.env.local` (or the framework's detected env var name), or the SDK's own `.clerk/.tmp/keyless.json` - a linked project profile via `clerk link` -The users commands talk to the instance's Backend API. Identifier and required-field rules are enforced by BAPI, so any BAPI secret key (via `CLERK_SECRET_KEY`, `--secret-key`, or `--app`-resolved) is enough — no `applications:manage` Platform API scope is required. +The third step is what makes `clerk users` work with no login and no Platform API auth on an **unclaimed keyless application** — the one an SDK creates for itself on first `next dev` (or similar) with no keys configured. It only applies when the directory isn't linked and `--app` wasn't passed, since either of those names an explicit destination the on-disk key might not belong to. + +The users commands talk to the instance's Backend API. Identifier and required-field rules are enforced by BAPI, so any BAPI secret key (via `CLERK_SECRET_KEY`, `.env.local`, `--secret-key`, or `--app`-resolved) is enough — no `applications:manage` Platform API scope is required. ## Interactive mode @@ -119,6 +126,12 @@ In agent mode the user-id is required (no interactive picker) and output is a JS `--secret-key` chooses the Backend API key used for user lookup. `users open` still requires an app target to resolve the dashboard URL, either from `--app`, a linked project, or the human-mode app picker. Use `--instance` when you want something other than the default development instance. +#### On an unclaimed keyless application + +This is the one command in the family that an unclaimed keyless application cannot satisfy, and the reason is structural rather than a missing code path: a dashboard link is `/apps/{appId}/instances/{instanceId}/users/{userId}`, and an application has no `appId` until somebody claims it. + +Rather than report the generic "no Clerk project linked", `users open` detects the keyless project and says so — because the two remedies that message implies, `clerk link` and `--app`, both want an application ID that does not exist yet. The error names `clerk auth login` to claim the application, and `clerk api /users/` to read the user right now without claiming anything. Passing `--app` explicitly skips this check: naming an application says the request isn't about the keyless one in this directory. + ## API Endpoints | Method | Endpoint | Command(s) | diff --git a/packages/cli-core/src/commands/users/open.test.ts b/packages/cli-core/src/commands/users/open.test.ts index 58eb2dc57..f1af97082 100644 --- a/packages/cli-core/src/commands/users/open.test.ts +++ b/packages/cli-core/src/commands/users/open.test.ts @@ -1,12 +1,15 @@ import { test, expect, describe, afterEach, beforeEach, mock } from "bun:test"; import { setMode } from "../../mode.ts"; import { setCurrentEnv } from "../../lib/environment.ts"; -import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { configStubs, keylessTargetStubs, useCaptureLog } from "../../test/lib/stubs.ts"; const mockResolveAppContext = mock(); const mockResolveProfile = mock(); const mockResolveInstanceId = mock(); +// Spread the full stub set: a partial module mock only stays valid while +// nothing else in the import graph reaches for the exports it left out. mock.module("../../lib/config.ts", () => ({ + ...configStubs, resolveAppContext: (...args: unknown[]) => mockResolveAppContext(...args), resolveProfile: (...args: unknown[]) => mockResolveProfile(...args), resolveInstanceId: (...args: unknown[]) => mockResolveInstanceId(...args), @@ -27,6 +30,12 @@ mock.module("../../lib/open.ts", () => ({ openBrowser: (...args: unknown[]) => mockOpenBrowser(...args), })); +const mockResolveKeylessTarget = mock(); +mock.module("../../lib/keyless-target.ts", () => ({ + ...keylessTargetStubs, + resolveKeylessTarget: (...args: unknown[]) => mockResolveKeylessTarget(...args), +})); + mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, @@ -63,6 +72,7 @@ describe("users open", () => { }); mockResolveUsersInstanceContext.mockResolvedValue(CTX); mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "open" }); + mockResolveKeylessTarget.mockResolvedValue(undefined); }); afterEach(() => { @@ -72,6 +82,43 @@ describe("users open", () => { mockResolveUsersInstanceContext.mockReset(); mockPickUser.mockReset(); mockOpenBrowser.mockReset(); + mockResolveKeylessTarget.mockReset(); + }); + + describe("on an unclaimed keyless application", () => { + beforeEach(() => { + // Nothing linked and no account context — the state `clerk init` leaves + // behind before anyone claims the app. + mockResolveAppContext.mockRejectedValue( + new Error("No Clerk project linked to this directory."), + ); + mockResolveKeylessTarget.mockResolvedValue({ + secretKey: "sk_test_keyless", + source: ".env.local", + }); + }); + + test("says the application is unclaimed rather than that nothing is linked", async () => { + await expect(open({ userId: "user_abc" })).rejects.toThrow( + /unclaimed keyless application \(secret key from \.env\.local\).*no Dashboard page/s, + ); + }); + + test("suggests claiming, and a command that works right now", async () => { + const error = await open({ userId: "user_abc" }).catch((thrown: unknown) => thrown); + // `clerk users get` does not exist; `clerk api` is the reader that does. + expect((error as Error).message).toContain("clerk auth login"); + expect((error as Error).message).toContain("clerk api /users/user_abc"); + }); + + test("does not hijack an explicit --app target", async () => { + // Naming an application says the user isn't asking about the keyless one + // sitting in this directory, so that resolution must run untouched. + await open({ userId: "user_abc", app: "app_other", print: true }); + + expect(mockResolveKeylessTarget).not.toHaveBeenCalled(); + expect(captured.out).toContain(`/apps/${CTX.appId}`); + }); }); test("explicit user-id + linked profile: opens dashboard URL for that user", async () => { diff --git a/packages/cli-core/src/commands/users/open.ts b/packages/cli-core/src/commands/users/open.ts index 0393dcf43..cf8354e4b 100644 --- a/packages/cli-core/src/commands/users/open.ts +++ b/packages/cli-core/src/commands/users/open.ts @@ -1,6 +1,8 @@ import { bold, cyan, dim } from "../../lib/color.ts"; import { resolveAppContext, resolveInstanceId, resolveProfile } from "../../lib/config.ts"; import { CliError, ERROR_CODE, throwUsageError } from "../../lib/errors.ts"; +import { resolveKeylessTarget } from "../../lib/keyless-target.ts"; +import { keylessCopy } from "../../lib/copy.ts"; import { log } from "../../lib/log.ts"; import { openBrowser } from "../../lib/open.ts"; import { intro, outro } from "../../lib/spinner.ts"; @@ -64,6 +66,28 @@ async function resolveKnownUserDashboardTarget(options: UsersOpenOptions): Promi }; } +/** + * Fails with the real reason when the directory holds an unclaimed keyless + * application, rather than letting the caller land on `not_linked`. + * + * Both are true — nothing is linked, and there is nothing to link to — but only + * one of them tells you what to do next. `clerk link` and `--app` both expect an + * application ID that an unclaimed application does not have yet, so the generic + * message sends people to try two things that cannot work. + */ +async function assertNotUnclaimedKeyless(options: UsersOpenOptions, userId: string): Promise { + // An explicit destination means the user is not asking about keyless at all, + // and the original error is the accurate one. + if (options.app || options.instance) return; + + const keyless = await resolveKeylessTarget({ cwd: process.cwd() }); + if (keyless) { + throw new CliError(keylessCopy.userDashboardNeedsClaim(keyless.source, userId), { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } +} + export async function open(options: UsersOpenOptions = {}): Promise { let userId = options.userId; if (userId !== undefined && !/^user_[A-Za-z0-9]+$/.test(userId)) { @@ -83,6 +107,13 @@ export async function open(options: UsersOpenOptions = {}): Promise { try { target = await resolveKnownUserDashboardTarget(options); } catch (error) { + // An unclaimed application has no Dashboard page to open — the deep-link + // is `/apps/{appId}/…` and there is no appId until somebody claims it. + // Checked before the `--secret-key` branch below because the advice that + // branch would eventually give ("use --app", "run `clerk link`") is + // unreachable here: no app exists to name. + await assertNotUnclaimedKeyless(options, userId); + if (!options.secretKey) { throw error; } diff --git a/packages/cli-core/src/commands/whoami/README.md b/packages/cli-core/src/commands/whoami/README.md index b19098699..7e7a29932 100644 --- a/packages/cli-core/src/commands/whoami/README.md +++ b/packages/cli-core/src/commands/whoami/README.md @@ -22,7 +22,35 @@ clerk whoami --json - Calls `resolveProfile(cwd)` (best-effort — failures are swallowed) to determine whether the working directory is linked to a Clerk application. - When linked, prints a `Linked to ...` line on **stderr** above the next-steps, where `...` is the app label rendered by `profileLabel()` from `lib/config.ts` — for example, `Linked to MyApp (app_xxx)`. - When not linked, only the existing `WHOAMI` next-steps are printed. -- If no token exists, throws an `AuthError` ("Not logged in"). +- If no token exists, falls back to the keyless path below; when that finds nothing either, throws an `AuthError` ("Not logged in"). + +### Keyless applications + +An unclaimed keyless application has no account to name, so the instance itself is the identity. When there's no stored token but the directory holds an instance secret key (env var, `.env`/`.env.local`, or `.clerk/.tmp/keyless.json` — see [`lib/keyless-target.ts`](../../lib/keyless-target.ts)), `whoami` reads `GET /v1/instance` with that key and reports the instance instead of erroring. A secret key the API rejects (revoked, malformed) surfaces as an error naming the key and where it came from, not a bare "Request failed (401)". + +```json +{ + "email": null, + "keyless": { + "instanceId": "ins_...", + "environmentType": "development", + "publishableKey": "pk_test_...", + "publishableKeyMismatch": false, + "keySource": ".clerk/.tmp/keyless.json" + }, + "linked": null +} +``` + +`publishableKey` and the secret key are found independently (see `findLocalSecretKey`/`findLocalPublishableKey` in `lib/keyless-target.ts`) and can each belong to a _different_ keyless application if a project's env files hold leftovers from more than one. `publishableKeyMismatch` is `true` when that's happened — checked by decoding the publishable key's Frontend API host (`decodePublishableKey` in `lib/fapi.ts`) and confirming it appears in the secret key's own `GET /v1/domains`. Whoami only warns in this case (on **stderr**) and still reports what it found; `clerk env pull` is the stricter, refusing to write the mismatched pair (see [`commands/env/README.md`](../env/README.md)). + +In human mode the instance ID goes to **stdout** and the explanation (including where the key came from) to **stderr**. + +| Method | Endpoint | Description | +| ------ | -------------- | ---------------------------------------------------------------------------------------------- | +| `GET` | `/v1/instance` | Reads the keyless instance's identity. Authenticated with the secret key. | +| `GET` | `/v1/domains` | Only when a local publishable key was found — checks it against the secret key's own instance. | + - If the token is expired or invalid, throws an `AuthError` ("Session expired"). ### `--json` (and agent mode) diff --git a/packages/cli-core/src/commands/whoami/index.test.ts b/packages/cli-core/src/commands/whoami/index.test.ts index 5390ae4c8..61fe89c88 100644 --- a/packages/cli-core/src/commands/whoami/index.test.ts +++ b/packages/cli-core/src/commands/whoami/index.test.ts @@ -2,19 +2,39 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun: import { configStubs, credentialStoreStubs, + keylessTargetStubs, tokenExchangeStubs, useCaptureLog, } from "../../test/lib/stubs.ts"; import { CliError } from "../../lib/errors.ts"; const mockGetValidToken = mock(); +const mockHasStoredCredentials = mock(); const mockFetchUserInfo = mock(); const mockResolveProfile = mock(); const mockIsAgent = mock(); +const mockResolveKeylessTarget = mock(); +const mockFindLocalSecretKey = mock(); +const mockFindLocalPublishableKey = mock(); +const mockHasKeyPairMismatch = mock(); +const mockBapiRequest = mock(); mock.module("../../lib/credential-store.ts", () => ({ ...credentialStoreStubs, getValidToken: (...args: unknown[]) => mockGetValidToken(...args), + hasStoredCredentials: (...args: unknown[]) => mockHasStoredCredentials(...args), +})); + +mock.module("../../lib/keyless-target.ts", () => ({ + ...keylessTargetStubs, + resolveKeylessTarget: (...args: unknown[]) => mockResolveKeylessTarget(...args), + findLocalSecretKey: (...args: unknown[]) => mockFindLocalSecretKey(...args), + findLocalPublishableKey: (...args: unknown[]) => mockFindLocalPublishableKey(...args), + hasKeyPairMismatch: (...args: unknown[]) => mockHasKeyPairMismatch(...args), +})); + +mock.module("../../lib/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), })); mock.module("../../lib/token-exchange.ts", () => ({ @@ -54,13 +74,26 @@ describe("whoami", () => { beforeEach(() => { mockIsAgent.mockReturnValue(false); mockResolveProfile.mockResolvedValue(undefined); + mockHasStoredCredentials.mockResolvedValue(false); + mockResolveKeylessTarget.mockResolvedValue(undefined); + mockFindLocalSecretKey.mockResolvedValue(undefined); + mockFindLocalPublishableKey.mockResolvedValue(undefined); + mockHasKeyPairMismatch.mockResolvedValue(false); + delete process.env.CLERK_SECRET_KEY; }); afterEach(() => { mockGetValidToken.mockReset(); + mockHasStoredCredentials.mockReset(); mockFetchUserInfo.mockReset(); mockResolveProfile.mockReset(); mockIsAgent.mockReset(); + mockResolveKeylessTarget.mockReset(); + mockFindLocalSecretKey.mockReset(); + mockFindLocalPublishableKey.mockReset(); + mockHasKeyPairMismatch.mockReset(); + mockBapiRequest.mockReset(); + delete process.env.CLERK_SECRET_KEY; consoleSpy?.mockRestore(); }); @@ -148,6 +181,55 @@ describe("whoami", () => { expect(captured.err).not.toContain("Linked to"); }); + test("warns when unlinked and a local secret key is found", async () => { + mockGetValidToken.mockResolvedValue("valid-token"); + mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" }); + mockResolveProfile.mockResolvedValue(undefined); + mockFindLocalSecretKey.mockResolvedValue({ secretKey: "sk_test_xxx", source: ".env.local" }); + + await runWhoami(); + + expect(captured.err).toContain("isn't linked"); + expect(captured.err).toContain(".env.local"); + expect(captured.err).not.toContain("Linked to"); + }); + + test("warns when linked but an exported CLERK_SECRET_KEY overrides the profile", async () => { + mockGetValidToken.mockResolvedValue("valid-token"); + mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" }); + mockResolveProfile.mockResolvedValue(linkedProfile); + process.env.CLERK_SECRET_KEY = "sk_test_xxx"; + + await runWhoami(); + + expect(captured.err).toContain("Linked to"); + expect(captured.err).toContain("overrides the linked application"); + expect(captured.err).toContain("CLERK_SECRET_KEY env var"); + expect(mockFindLocalSecretKey).not.toHaveBeenCalled(); + }); + + test("no override warning when linked and no CLERK_SECRET_KEY is exported", async () => { + mockGetValidToken.mockResolvedValue("valid-token"); + mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" }); + mockResolveProfile.mockResolvedValue(linkedProfile); + + await runWhoami(); + + expect(captured.err).toContain("Linked to"); + expect(captured.err).not.toContain("overrides the linked application"); + }); + + test("--json reports the override source when linked but overridden", async () => { + mockGetValidToken.mockResolvedValue("valid-token"); + mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" }); + mockResolveProfile.mockResolvedValue(linkedProfile); + process.env.CLERK_SECRET_KEY = "sk_test_xxx"; + + await runWhoami({ json: true }); + + expect(JSON.parse(captured.out).localSecretKeySource).toBe("CLERK_SECRET_KEY env var"); + }); + test("--json emits structured payload with linked details and suppresses next-steps", async () => { mockGetValidToken.mockResolvedValue("valid-token"); mockFetchUserInfo.mockResolvedValue({ userId: "user_123", email: "alice@example.com" }); @@ -158,6 +240,7 @@ describe("whoami", () => { const payload = JSON.parse(captured.out); expect(payload).toEqual({ email: "alice@example.com", + localSecretKeySource: null, linked: { appId: "app_xxx", appName: "MyApp", @@ -179,6 +262,7 @@ describe("whoami", () => { expect(JSON.parse(captured.out)).toEqual({ email: "alice@example.com", + localSecretKeySource: null, linked: null, }); }); @@ -216,4 +300,128 @@ describe("whoami", () => { expect(payload.linked.appId).toBe("app_xxx"); expect(captured.err).not.toContain("Linked to"); }); + + describe("keyless", () => { + const KEYLESS_TARGET = { secretKey: "sk_test_keyless", source: ".env.local" }; + + /** Points the keyless fallback at an instance the Backend API will answer for. */ + function withKeylessProject(): void { + mockResolveKeylessTarget.mockResolvedValue(KEYLESS_TARGET); + mockFindLocalPublishableKey.mockResolvedValue("pk_test_keyless"); + mockBapiRequest.mockResolvedValue({ + body: { id: "ins_keyless_1", environment_type: "development" }, + }); + } + + test("reports the keyless instance when there is no account at all", async () => { + mockGetValidToken.mockResolvedValue(null); + withKeylessProject(); + + await runWhoami({ json: true }); + + expect(JSON.parse(captured.out)).toEqual({ + email: null, + linked: null, + keyless: { + instanceId: "ins_keyless_1", + environmentType: "development", + publishableKey: "pk_test_keyless", + publishableKeyMismatch: false, + keySource: ".env.local", + }, + }); + }); + + test("warns and flags the JSON payload when the local publishable key belongs to a different app", async () => { + mockGetValidToken.mockResolvedValue(null); + withKeylessProject(); + mockHasKeyPairMismatch.mockResolvedValue(true); + + await runWhoami({ json: true }); + + expect(JSON.parse(captured.out)).toMatchObject({ + keyless: { publishableKeyMismatch: true }, + }); + expect(captured.err).toContain("doesn't belong to this secret key's application"); + }); + + test("a failed pairing check doesn't block reporting the identity", async () => { + mockGetValidToken.mockResolvedValue(null); + withKeylessProject(); + mockHasKeyPairMismatch.mockRejectedValue(new Error("network blip")); + + await runWhoami({ json: true }); + + expect(JSON.parse(captured.out)).toMatchObject({ + keyless: { instanceId: "ins_keyless_1", publishableKeyMismatch: false }, + }); + }); + + test("no pairing check runs when no local publishable key is found", async () => { + mockGetValidToken.mockResolvedValue(null); + withKeylessProject(); + mockFindLocalPublishableKey.mockResolvedValue(undefined); + + await runWhoami({ json: true }); + + expect(mockHasKeyPairMismatch).not.toHaveBeenCalled(); + expect(JSON.parse(captured.out)).toMatchObject({ + keyless: { publishableKey: null, publishableKeyMismatch: false }, + }); + }); + + test("wraps an invalid keyless secret key with context naming the key and its source", async () => { + mockGetValidToken.mockResolvedValue(null); + mockResolveKeylessTarget.mockResolvedValue(KEYLESS_TARGET); + const { BapiError } = await import("../../lib/errors.ts"); + mockBapiRequest.mockRejectedValue( + new BapiError(401, '{"errors":[{"message":"invalid key"}]}', new Headers()), + ); + + const error = await runWhoami({ json: true }).catch((e: unknown) => e); + + expect((error as { context?: string }).context).toContain(".env.local"); + }); + + // A login whose refresh token the server has since rejected used to abort + // whoami outright, because the "am I signed in?" question threw instead of + // answering no. + test("falls back to keyless when the stored session can no longer be refreshed", async () => { + mockGetValidToken.mockRejectedValue(new CliError("Token refresh failed (401)")); + mockHasStoredCredentials.mockResolvedValue(true); + withKeylessProject(); + + await runWhoami({ json: true }); + + expect(JSON.parse(captured.out)).toMatchObject({ + email: null, + keyless: { instanceId: "ins_keyless_1" }, + }); + expect(mockFetchUserInfo).not.toHaveBeenCalled(); + }); + + test("an unrefreshable session with no keyless keys still reports an expired session", async () => { + mockGetValidToken.mockRejectedValue(new CliError("Token refresh failed (401)")); + mockHasStoredCredentials.mockResolvedValue(true); + + await expect(runWhoami()).rejects.toThrow(/Session expired/); + }); + + test("no credentials and no keyless keys reports not logged in", async () => { + mockGetValidToken.mockResolvedValue(null); + + await expect(runWhoami()).rejects.toThrow(/Not logged in/); + }); + + test("human output names the instance and where its key came from", async () => { + mockGetValidToken.mockResolvedValue(null); + withKeylessProject(); + + await runWhoami(); + + expect(captured.out.trim()).toBe("ins_keyless_1"); + expect(captured.err).toContain("unclaimed keyless application"); + expect(captured.err).toContain(".env.local"); + }); + }); }); diff --git a/packages/cli-core/src/commands/whoami/index.ts b/packages/cli-core/src/commands/whoami/index.ts index b9eeac76d..25339a37b 100644 --- a/packages/cli-core/src/commands/whoami/index.ts +++ b/packages/cli-core/src/commands/whoami/index.ts @@ -1,21 +1,88 @@ import type { Program } from "../../cli-program.ts"; -import { getValidToken } from "../../lib/credential-store.ts"; +import { getValidToken, hasStoredCredentials } from "../../lib/credential-store.ts"; import { fetchUserInfo } from "../../lib/token-exchange.ts"; import { withSpinner } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; -import { AuthError } from "../../lib/errors.ts"; +import { AuthError, errorMessage, withApiContext } from "../../lib/errors.ts"; import { profileLabel, resolveProfile } from "../../lib/config.ts"; import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; import { isAgent } from "../../mode.ts"; +import { bapiRequest } from "../../lib/bapi.ts"; +import { + findLocalPublishableKey, + findLocalSecretKey, + hasKeyPairMismatch, + resolveKeylessTarget, + type KeylessTarget, +} from "../../lib/keyless-target.ts"; export interface WhoamiOptions { json?: boolean; } +/** + * Who this directory is acting as: a Clerk account, or — with no account at all + * — the unclaimed keyless application whose key the project holds. Resolving + * this first keeps rendering to a single place. + */ +type Identity = + | { + kind: "account"; + email: string; + profile: Awaited>; + /** + * Where a local secret key was found that `clerk api` and `clerk users` + * would actually use instead of this account, or null. Populated when + * the directory is unlinked, and also when it's linked but an exported + * `CLERK_SECRET_KEY` overrides the profile — the one local-key source + * `resolveBapiSecretKey` still honors even then. + */ + localSecretKeySource: string | null; + } + | { + kind: "keyless"; + instanceId: string | null; + environmentType: string | null; + publishableKey: string | null; + /** True when `publishableKey` was found locally but doesn't address this secret key's own instance. */ + publishableKeyMismatch: boolean; + keySource: string; + }; + export async function whoami(options: WhoamiOptions = {}) { - const token = await getValidToken(); + const identity = await resolveIdentity(); + + if (options.json || isAgent()) { + log.data(JSON.stringify(toJson(identity), null, 2)); + return; + } + + render(identity); +} + +async function resolveIdentity(): Promise { + // Stored credentials that can no longer be refreshed are, for this command's + // purposes, the same as no credentials: there is no account to report. Ask the + // question in a way that can't throw, so an expired session falls through to + // the keyless path instead of aborting it. + const token = await getValidToken().catch((error: unknown) => { + log.debug(`credentials: stored session unusable (${errorMessage(error)})`); + return null; + }); + + // No usable account, but the directory may still hold a working keyless + // application. Report what it is instead of a flat "not logged in". if (!token) { - throw new AuthError({ reason: "not_logged_in" }); + const keyless = await resolveKeylessTarget({ cwd: process.cwd() }); + if (!keyless) { + // Nothing to fall back to, so the distinction matters again: a session + // that expired is fixed by logging in again, not by logging in for the + // first time. + throw new AuthError({ + reason: (await hasStoredCredentials()) ? "session_expired" : "not_logged_in", + }); + } + return describeKeyless(keyless); } let userInfo; @@ -25,44 +92,135 @@ export async function whoami(options: WhoamiOptions = {}) { throw new AuthError({ reason: "session_expired" }); } - let resolved: Awaited>; + let profile: Awaited>; try { - resolved = await resolveProfile(process.cwd()); + profile = await resolveProfile(process.cwd()); } catch { // Best-effort only: don't fail whoami when local profile resolution fails. - resolved = undefined; + profile = undefined; } - if (options.json || isAgent()) { - log.data( - JSON.stringify( - { - email: userInfo.email, - linked: resolved - ? { - appId: resolved.profile.appId, - appName: resolved.profile.appName ?? null, - instances: { - development: resolved.profile.instances.development, - production: resolved.profile.instances.production ?? null, - }, - resolvedVia: resolved.resolvedVia, - path: resolved.path, - } - : null, - }, - null, - 2, - ), + // Unlinked but holding a local secret key: the Backend API commands will use + // that key, not this account, so whoami must say so or its answer is wrong + // for half the command surface. A linked directory isn't automatically safe + // from this either — resolveBapiSecretKey lets an exported CLERK_SECRET_KEY + // win over even a linked profile, so check for that specific override + // instead of skipping discovery outright. + const localKey = profile + ? process.env.CLERK_SECRET_KEY + ? { source: "CLERK_SECRET_KEY env var" } + : undefined + : await findLocalSecretKey(process.cwd()); + + return { + kind: "account", + email: userInfo.email, + profile, + localSecretKeySource: localKey?.source ?? null, + }; +} + +async function describeKeyless(keyless: KeylessTarget): Promise { + const instance = await withSpinner("Fetching instance info...", async () => { + const response = await withApiContext( + bapiRequest({ method: "GET", path: "/v1/instance", secretKey: keyless.secretKey }), + `Failed to read the keyless secret key from \`${keyless.source}\``, + ); + return response.body as { id?: string; environment_type?: string }; + }); + + const publishableKey = (await findLocalPublishableKey(process.cwd())) ?? null; + const publishableKeyMismatch = publishableKey + ? await checkKeyPairMismatch(keyless, publishableKey) + : false; + + if (publishableKeyMismatch) { + log.warn( + `The publishable key found locally doesn't belong to this secret key's application — the server (secret key from \`${keyless.source}\`) and the browser (\`${publishableKey}\`) would be talking to different apps.\n` + + "Check your env files for a leftover key from another project, or run `clerk env pull` once they match.", ); + } + + return { + kind: "keyless", + instanceId: instance.id ?? null, + environmentType: instance.environment_type ?? null, + publishableKey, + publishableKeyMismatch, + keySource: keyless.source, + }; +} + +/** + * Whoami's job here is to report, not to block — a pairing check that itself + * fails (network hiccup on `/v1/domains`, say) shouldn't stop it from showing + * the identity `/v1/instance` already confirmed. `env pull` is the write path + * and is the stricter of the two: see `commands/env/pull.ts`. + */ +async function checkKeyPairMismatch( + keyless: KeylessTarget, + publishableKey: string, +): Promise { + try { + return await hasKeyPairMismatch(keyless, publishableKey); + } catch (error) { + log.debug(`whoami: key pairing check failed (${errorMessage(error)})`); + return false; + } +} + +function toJson(identity: Identity): Record { + if (identity.kind === "keyless") { + const { kind: _kind, ...keyless } = identity; + return { email: null, keyless, linked: null }; + } + + const resolved = identity.profile; + return { + email: identity.email, + localSecretKeySource: identity.localSecretKeySource, + linked: resolved + ? { + appId: resolved.profile.appId, + appName: resolved.profile.appName ?? null, + instances: { + development: resolved.profile.instances.development, + production: resolved.profile.instances.production ?? null, + }, + resolvedVia: resolved.resolvedVia, + path: resolved.path, + } + : null, + }; +} + +function render(identity: Identity): void { + if (identity.kind === "keyless") { + log.data(identity.instanceId ?? "unknown instance"); + log.info( + `Not logged in — running on an unclaimed keyless 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. + printNextSteps(NEXT_STEPS.WHOAMI_KEYLESS); return; } - log.data(userInfo.email); - if (resolved) { - log.info(`Linked to \`${profileLabel(resolved.profile)}\``); + log.data(identity.email); + if (identity.profile) { + log.info(`Linked to \`${profileLabel(identity.profile.profile)}\``); + } + if (identity.localSecretKeySource) { + log.warn( + identity.profile + ? `An exported secret key (from \`${identity.localSecretKeySource}\`) overrides the linked application — \`clerk api\` and \`clerk users\` will use that key's instance instead.\n` + + "Unset `CLERK_SECRET_KEY` to use the linked application instead." + : `This directory isn't linked, but holds a secret key (from \`${identity.localSecretKeySource}\`) — \`clerk api\` and \`clerk users\` will use that key's instance, not your account.\n` + + "Run `clerk link` to point them at an application of yours, or remove the key.", + ); } - printNextSteps(resolved ? NEXT_STEPS.WHOAMI_LINKED : NEXT_STEPS.WHOAMI); + printNextSteps(identity.profile ? NEXT_STEPS.WHOAMI_LINKED : NEXT_STEPS.WHOAMI); } export function registerWhoami(program: Program): void { diff --git a/packages/cli-core/src/lib/bapi-command.test.ts b/packages/cli-core/src/lib/bapi-command.test.ts index e7058668b..a984b128a 100644 --- a/packages/cli-core/src/lib/bapi-command.test.ts +++ b/packages/cli-core/src/lib/bapi-command.test.ts @@ -4,6 +4,7 @@ import { useCaptureLog } from "../test/lib/stubs.ts"; const configModule = await import("./config.ts"); const plapiModule = await import("./plapi.ts"); +const keylessTargetModule = await import("./keyless-target.ts"); const { normalizeBapiPath, handleBapiError, resolveBapiSecretKey, describeBapiTarget } = await import("./bapi-command.ts"); @@ -12,6 +13,7 @@ describe("bapi-command", () => { let resolveAppContextSpy: ReturnType; let fetchApplicationSpy: ReturnType; let validateKeyPrefixSpy: ReturnType; + let resolveKeylessTargetSpy: ReturnType; const captured = useCaptureLog(); beforeEach(() => { @@ -19,6 +21,11 @@ describe("bapi-command", () => { resolveAppContextSpy = spyOn(configModule, "resolveAppContext"); fetchApplicationSpy = spyOn(plapiModule, "fetchApplication"); validateKeyPrefixSpy = spyOn(plapiModule, "validateKeyPrefix"); + // Defaults to "no keyless project here" so existing account-path tests are + // unaffected; individual tests override this to exercise the keyless path. + resolveKeylessTargetSpy = spyOn(keylessTargetModule, "resolveKeylessTarget").mockResolvedValue( + undefined, + ); }); afterEach(() => { @@ -27,6 +34,7 @@ describe("bapi-command", () => { resolveAppContextSpy.mockRestore(); fetchApplicationSpy.mockRestore(); validateKeyPrefixSpy.mockRestore(); + resolveKeylessTargetSpy.mockRestore(); }); test("normalizes unversioned paths", () => { @@ -190,12 +198,82 @@ describe("bapi-command", () => { expect(fetchApplicationSpy).toHaveBeenCalledWith("app_123"); }); - test("falls back to CLERK_SECRET_KEY when no explicit app is provided", async () => { + test("an exported CLERK_SECRET_KEY wins over a linked profile", async () => { process.env.CLERK_SECRET_KEY = "sk_env_123"; - await expect(resolveBapiSecretKey({ instance: "dev" })).resolves.toBe("sk_env_123"); + await expect(resolveBapiSecretKey({})).resolves.toBe("sk_env_123"); + + // The env var is explicit user intent: neither the keyless resolver (which + // stands down for linked directories) nor the profile lookup runs at all. + expect(resolveKeylessTargetSpy).not.toHaveBeenCalled(); + expect(resolveAppContextSpy).not.toHaveBeenCalled(); + expect(fetchApplicationSpy).not.toHaveBeenCalled(); + }); + + test("--instance stays a no-op next to an exported CLERK_SECRET_KEY", async () => { + process.env.CLERK_SECRET_KEY = "sk_env_123"; + + // The key addresses exactly one instance, so --instance has always been + // ignored here — never a usage error. + await expect(resolveBapiSecretKey({ instance: "prod" })).resolves.toBe("sk_env_123"); + + expect(resolveKeylessTargetSpy).not.toHaveBeenCalled(); + }); + + test("rejects an exported CLERK_SECRET_KEY that is not a secret key", async () => { + process.env.CLERK_SECRET_KEY = "pk_test_not_secret"; + validateKeyPrefixSpy.mockImplementation(() => { + throw new Error("bad prefix"); + }); + + await expect(resolveBapiSecretKey({})).rejects.toThrow("bad prefix"); + expect(validateKeyPrefixSpy).toHaveBeenCalledWith("pk_test_not_secret", "sk_"); + }); + + test("falls back to the shared keyless resolution when no explicit app or secret key is provided", async () => { + resolveKeylessTargetSpy.mockResolvedValue({ + secretKey: "sk_keyless_123", + source: ".env.local", + }); + + await expect(resolveBapiSecretKey({ instance: "dev" })).resolves.toBe("sk_keyless_123"); + + expect(resolveKeylessTargetSpy).toHaveBeenCalledWith({ instance: "dev", cwd: undefined }); + expect(resolveAppContextSpy).not.toHaveBeenCalled(); + expect(fetchApplicationSpy).not.toHaveBeenCalled(); + }); + + test("prefers an explicit app over the keyless project's own key", async () => { + resolveKeylessTargetSpy.mockResolvedValue({ + secretKey: "sk_keyless_123", + source: ".env.local", + }); + fetchApplicationSpy.mockResolvedValue({ + application_id: "app_123", + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + secret_key: "sk_test_123", + }, + ], + }); + + await expect(resolveBapiSecretKey({ app: "app_123", instance: "dev" })).resolves.toBe( + "sk_test_123", + ); + + expect(resolveKeylessTargetSpy).not.toHaveBeenCalled(); + }); + + test("prefers keyless resolution over the account-linked fallback", async () => { + resolveKeylessTargetSpy.mockResolvedValue({ + secretKey: "sk_keyless_123", + source: ".env.local", + }); + + await expect(resolveBapiSecretKey({})).resolves.toBe("sk_keyless_123"); - expect(validateKeyPrefixSpy).toHaveBeenCalledWith("sk_env_123", "sk_"); expect(resolveAppContextSpy).not.toHaveBeenCalled(); expect(fetchApplicationSpy).not.toHaveBeenCalled(); }); @@ -247,6 +325,24 @@ describe("bapi-command", () => { await expect(describeBapiTarget({ secretKey: "sk_test_123" })).resolves.toBeUndefined(); }); + test("describes an unclaimed keyless target without querying the account", async () => { + resolveKeylessTargetSpy.mockResolvedValue({ secretKey: "sk_test_123", source: ".env.local" }); + + await expect(describeBapiTarget({})).resolves.toBe( + "this keyless application (secret key from .env.local)", + ); + + expect(resolveAppContextSpy).not.toHaveBeenCalled(); + }); + + test("an explicit --secret-key wins over a keyless target on disk, matching resolveBapiSecretKey", async () => { + resolveKeylessTargetSpy.mockResolvedValue({ secretKey: "sk_test_disk", source: ".env.local" }); + + await expect(describeBapiTarget({ secretKey: "sk_test_explicit" })).resolves.toBeUndefined(); + + expect(resolveKeylessTargetSpy).not.toHaveBeenCalled(); + }); + test("throws instance-not-found when the resolved instance is missing from the application", async () => { resolveAppContextSpy.mockResolvedValue({ appId: "app_123", diff --git a/packages/cli-core/src/lib/bapi-command.ts b/packages/cli-core/src/lib/bapi-command.ts index 22f22630d..26f6f24a4 100644 --- a/packages/cli-core/src/lib/bapi-command.ts +++ b/packages/cli-core/src/lib/bapi-command.ts @@ -1,5 +1,6 @@ import { resolveAppContext, resolveFetchedApplicationInstance } from "./config.ts"; import { BapiError, CliError, ERROR_CODE, throwUsageError, withApiContext } from "./errors.ts"; +import { resolveKeylessTarget } from "./keyless-target.ts"; import { log } from "./log.ts"; import { fetchApplication, validateKeyPrefix } from "./plapi.ts"; @@ -14,13 +15,33 @@ interface ResolveBapiSecretKeyOptions { app?: string; instance?: string; secretKey?: string; + cwd?: string; } export async function describeBapiTarget( options: ResolveBapiSecretKeyOptions, ): Promise { + // An explicit --secret-key wins in resolveBapiSecretKey, so it has no + // app/instance context to describe. + if (options.secretKey) return undefined; + + // Mirrors resolveBapiSecretKey's precedence: an unclaimed keyless project has + // no app/instance to describe, only the key's own source. + const keyless = await resolveKeylessTarget({ + app: options.app, + instance: options.instance, + cwd: options.cwd, + }); + if (keyless) { + return `this keyless application (secret key from ${keyless.source})`; + } + try { - const ctx = await resolveAppContext({ app: options.app, instance: options.instance }); + const ctx = await resolveAppContext({ + app: options.app, + instance: options.instance, + cwd: options.cwd, + }); return `${ctx.appLabel} (${ctx.instanceLabel})`; } catch (error) { if ( @@ -58,14 +79,36 @@ export async function resolveBapiSecretKey(options: ResolveBapiSecretKeyOptions) return resolved.instance.secret_key; } - if (process.env.CLERK_SECRET_KEY) { - validateKeyPrefix(process.env.CLERK_SECRET_KEY, "sk_"); - return process.env.CLERK_SECRET_KEY; + // An explicitly exported CLERK_SECRET_KEY wins over everything below, + // including a linked profile — same precedence this command family has + // always had. Routing it through resolveKeylessTarget instead would lose + // both halves of that contract: the keyless resolver stands down entirely + // when the directory is linked, and refuses --instance, which has always + // been a no-op next to an env key that addresses exactly one instance. + const envSecretKey = process.env.CLERK_SECRET_KEY; + if (envSecretKey) { + validateKeyPrefix(envSecretKey, "sk_"); + return envSecretKey; + } + + // An unclaimed keyless application keeps its only secret key on disk + // (.env.local, or the SDK's own keyless.json) — the same resolution `whoami`, + // `config`, and `env pull` already share. The shipped binary is compiled with + // --no-compile-autoload-dotenv, so without this every `users`/`api` command + // would report "no secret key" on a perfectly live keyless project the moment + // it wasn't run via `bun run` (which autoloads .env.local for us in dev). + const keyless = await resolveKeylessTarget({ instance: options.instance, cwd: options.cwd }); + if (keyless) { + return keyless.secretKey; } let ctx: Awaited>; try { - ctx = await resolveAppContext({ app: options.app, instance: options.instance }); + ctx = await resolveAppContext({ + app: options.app, + instance: options.instance, + cwd: options.cwd, + }); } catch (error) { if (error instanceof CliError && error.code === ERROR_CODE.NOT_LINKED) { throwUsageError( diff --git a/packages/cli-core/src/lib/copy.test.ts b/packages/cli-core/src/lib/copy.test.ts new file mode 100644 index 000000000..2ab211408 --- /dev/null +++ b/packages/cli-core/src/lib/copy.test.ts @@ -0,0 +1,21 @@ +import { test, expect, describe } from "bun:test"; +import { keylessCopy } from "./copy.ts"; + +describe("apiReachableKeysLine", () => { + test.each([ + ["organization_settings", "`clerk api /organization_settings`"], + ["protect", "`clerk api /protect`"], + ])("names a single key's own clerk api example (%s)", (key, example) => { + const line = keylessCopy.apiReachableKeysLine([key]); + expect(line).toContain(`${key} is already reachable`); + expect(line).toContain(example); + }); + + test("names every key's own clerk api example when multiple keys are reachable", () => { + const line = keylessCopy.apiReachableKeysLine(["protect", "organization_settings"]); + expect(line).toContain("protect, organization_settings are already reachable"); + expect(line).toContain("`clerk api /protect`"); + expect(line).toContain("`clerk api /organization_settings`"); + expect(line).toContain(" or "); + }); +}); diff --git a/packages/cli-core/src/lib/copy.ts b/packages/cli-core/src/lib/copy.ts new file mode 100644 index 000000000..2e287b74f --- /dev/null +++ b/packages/cli-core/src/lib/copy.ts @@ -0,0 +1,64 @@ +/** + * User-facing copy for the keyless surface, gathered in one module. + * + * One function per sentence (or self-contained message), with everything the + * sentence interpolates as typed parameters. Commands compose these lines but + * never author the prose inline — that is the contract that makes + * localisation possible later: a locale swaps this module's bodies without + * touching a single call site, and pluralisation/agreement rules live here + * where a translator can reach them instead of being scattered through + * command logic. + * + * House style every entry carries: backticks highlight commands and + * identifiers (`lib/log.ts` renders them cyan), and a refusal names the + * reason and a working alternative rather than just saying no. + */ +export const keylessCopy = { + // --- config payload validation (config/keyless.ts) --- + + unsupportedConfigKeys: (unknown: string[], supported: readonly string[]): string => + `Unsupported config ${unknown.length === 1 ? "key" : "keys"} for an unclaimed keyless application: ${unknown.join(", ")}.\n` + + `Supported keys: ${supported.join(", ")}.`, + + unsupportedPayloadKeysLine: (unknown: string[]): string => + `Unsupported config ${unknown.length === 1 ? "key" : "keys"} for an unclaimed keyless application: ${unknown.join(", ")}.`, + + supportedPayloadKeysLine: (supported: readonly string[]): string => + `Supported top-level keys: ${supported.join(", ")}.`, + + apiReachableKeysLine: (keys: string[]): string => + `${keys.join(", ")} ${keys.length === 1 ? "is" : "are"} already reachable on an unclaimed application — use ${keys.map((key) => `\`clerk api /${key}\``).join(" or ")} directly instead of this config document.`, + + claimForFullConfigLine: (): string => + "Run `clerk auth login` to claim the application and use the full config document.", + + configKeyMustBeObject: (key: string): string => `Config key \`${key}\` must be a JSON object.`, + + unsupportedInstanceFieldsLine: (unknown: string[]): string => + `Unsupported ${unknown.length === 1 ? "field" : "fields"} on \`instance\` for an unclaimed keyless application: ${unknown.join(", ")}.`, + + supportedInstanceFieldsLine: (fields: readonly string[]): string => + `Supported fields: ${fields.join(", ")}.`, + + noRouteForInstanceFieldLine: (reason: string): string => + `Clerk's Backend API has no route for ${reason}, so this can't be changed from an unclaimed application at all — claim it first with \`clerk auth login\`.`, + + // --- "needs a claimed application" refusals --- + + billingNeedsClaimedApplication: (): string => + "Billing can only be configured on a claimed application — Clerk's Backend API has no billing settings, so an unclaimed keyless application can't reach them.\n" + + "Run `clerk auth login` to claim this application, then re-run the command.", + + schemaNeedsClaimedApplication: (): string => + "Config schema is only available for a claimed application — the schema describes the account-level config document, which an unclaimed keyless application has no access to.\n" + + "Run `clerk auth login` to claim this application, then re-run `clerk config schema`.", + + putNeedsClaimedApplication: (): string => + "Replacing the entire configuration is only available for a claimed application — an unclaimed keyless application has no full config document to replace.\n" + + "Use `clerk config patch` to update individual settings, or run `clerk auth login` to claim the application first.", + + userDashboardNeedsClaim: (keySource: string, userId: string): string => + `This directory holds an unclaimed keyless application (secret key from ${keySource}), which has no Dashboard page — a dashboard link needs an application ID, and one is only assigned when the application is claimed.\n` + + `Run \`clerk auth login\` to claim it, then \`clerk users open ${userId}\` will work.\n` + + `To inspect the user right now, \`clerk api /users/${userId}\` reads it straight from the instance.`, +}; diff --git a/packages/cli-core/src/lib/credential-store.test.ts b/packages/cli-core/src/lib/credential-store.test.ts index 8ad503dab..cc58b7f79 100644 --- a/packages/cli-core/src/lib/credential-store.test.ts +++ b/packages/cli-core/src/lib/credential-store.test.ts @@ -154,6 +154,39 @@ describe("credential-store", () => { expect(await getStoredSession()).toBeNull(); }); + test("getValidToken treats a 429 refresh failure as transient, not an expired session", async () => { + const session = { + accessToken: "expired-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 60_000, + tokenType: "Bearer", + }; + await storeToken(session); + + mockRefreshAccessToken.mockRejectedValue(new ApiError(429, "rate_limited")); + + await expect(getValidToken()).rejects.toBeInstanceOf(ApiError); + expect(await getStoredSession()).toEqual(session); + }); + + test("getValidToken reads a non-429 4xx refusal as an expired session but keeps the credentials", async () => { + const session = { + accessToken: "expired-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 60_000, + tokenType: "Bearer", + }; + await storeToken(session); + + mockRefreshAccessToken.mockRejectedValue(new ApiError(401, '{"error":"invalid_client"}')); + + // Unlike the invalid_grant branch above, the credentials stay in place: + // the refusal names this client/request, not the refresh token itself, so + // deleting the session would destroy state a config fix could still save. + await expect(getValidToken()).rejects.toBeInstanceOf(AuthError); + expect(await getStoredSession()).toEqual(session); + }); + test("createOAuthSession requires a refresh token in the auth response", () => { expect(() => createOAuthSession({ diff --git a/packages/cli-core/src/lib/credential-store.ts b/packages/cli-core/src/lib/credential-store.ts index 5094b78d9..61c85efd8 100644 --- a/packages/cli-core/src/lib/credential-store.ts +++ b/packages/cli-core/src/lib/credential-store.ts @@ -302,6 +302,25 @@ function isInvalidGrant(error: unknown): boolean { ); } +/** + * A 4xx from the token endpoint means this stored session will never refresh — + * `invalid_client` when the OAuth client no longer recognises us, `invalid_grant` + * when the refresh token itself is spent. Either way the user's next step is to + * log in again, so it should read as an expired session rather than as a raw + * `Token refresh failed (401): {"error":"invalid_client",…}` leaking out of the + * credential layer. + * + * 5xx and network failures are deliberately excluded: those are transient, and + * telling someone their session expired because Clerk had a bad minute would + * send them to re-authenticate for nothing. 429 is excluded for the same + * reason — it's a rate limit, not a rejection of the refresh token. + */ +function isUnrecoverableRefreshFailure(error: unknown): boolean { + return ( + error instanceof ApiError && error.status >= 400 && error.status < 500 && error.status !== 429 + ); +} + async function readStoredValue(): Promise { if (tokenOverride !== undefined) return tokenOverride; @@ -373,6 +392,15 @@ async function refreshStoredSession(session: OAuthSession): Promise { await deleteToken(); throw sessionExpiredError(); } + // Other 4xx refusals are just as terminal, but the stored session isn't + // provably spent the way a rotated refresh token is — so report it and + // leave the credentials alone rather than logging the user out for them. + if (isUnrecoverableRefreshFailure(error)) { + log.debug( + `credentials: refresh refused, treating session as expired (${errorMessage(error)})`, + ); + throw sessionExpiredError(); + } throw error; } @@ -436,6 +464,17 @@ export async function hasStoredCredentials(): Promise { return (await readStoredValue()) !== null; } +/** + * True when the CLI has credentials for a Clerk *account* — either a stored + * OAuth session or a platform API key. This is a presence check only: it does + * not hit the network, so an expired token or an API outage won't demote a + * logged-in user into an unauthenticated code path. + */ +export async function hasAccountCredentials(): Promise { + if (process.env.CLERK_PLATFORM_API_KEY) return true; + return hasStoredCredentials(); +} + export async function getValidToken(): Promise { const session = await getStoredSession(); if (!session) { diff --git a/packages/cli-core/src/lib/keyless-target.test.ts b/packages/cli-core/src/lib/keyless-target.test.ts new file mode 100644 index 000000000..cc422f338 --- /dev/null +++ b/packages/cli-core/src/lib/keyless-target.test.ts @@ -0,0 +1,226 @@ +import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { useCaptureLog } from "../test/lib/stubs.ts"; + +const configModule = await import("./config.ts"); +const credentialStoreModule = await import("./credential-store.ts"); +const bapiModule = await import("./bapi.ts"); + +const { resolveKeylessTarget, resolveInstanceTarget, hasKeyPairMismatch, findLocalPublishableKey } = + await import("./keyless-target.ts"); + +/** Encodes `.clerk.accounts.dev$` the way a real publishable key would. */ +function encodeFapiHost(host: string): string { + return `pk_test_${Buffer.from(`${host}.clerk.accounts.dev$`).toString("base64").replace(/=+$/, "")}`; +} + +describe("keyless-target", () => { + let resolveProfileSpy: ReturnType; + let hasAccountCredentialsSpy: ReturnType; + let getStoredSessionSpy: ReturnType; + let bapiRequestSpy: ReturnType; + const originalEnv = { ...process.env }; + const captured = useCaptureLog(); + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-keyless-target-")); + resolveProfileSpy = spyOn(configModule, "resolveProfile").mockResolvedValue(undefined); + hasAccountCredentialsSpy = spyOn( + credentialStoreModule, + "hasAccountCredentials", + ).mockResolvedValue(false); + getStoredSessionSpy = spyOn(credentialStoreModule, "getStoredSession").mockResolvedValue(null); + bapiRequestSpy = spyOn(bapiModule, "bapiRequest"); + }); + + afterEach(async () => { + process.env = { ...originalEnv }; + resolveProfileSpy.mockRestore(); + hasAccountCredentialsSpy.mockRestore(); + getStoredSessionSpy.mockRestore(); + bapiRequestSpy.mockRestore(); + await rm(tempDir, { recursive: true, force: true }); + }); + + describe("hasKeyPairMismatch", () => { + const KEYLESS = { secretKey: "sk_test_x", source: ".env.local" }; + const MATCHING_PK = encodeFapiHost("match"); + const MISMATCHED_PK = encodeFapiHost("other"); + + test("resolves false when the publishable key's host is among the secret key's own domains", async () => { + bapiRequestSpy.mockResolvedValue({ + body: { data: [{ frontend_api_url: "https://match.clerk.accounts.dev" }] }, + }); + + await expect(hasKeyPairMismatch(KEYLESS, MATCHING_PK)).resolves.toBe(false); + }); + + test("resolves true when the publishable key names a different application's host", async () => { + bapiRequestSpy.mockResolvedValue({ + body: { data: [{ frontend_api_url: "https://match.clerk.accounts.dev" }] }, + }); + + await expect(hasKeyPairMismatch(KEYLESS, MISMATCHED_PK)).resolves.toBe(true); + }); + + test("matches against any of several domains, not just the first", async () => { + bapiRequestSpy.mockResolvedValue({ + body: { + data: [ + { frontend_api_url: "https://unrelated.clerk.accounts.dev" }, + { frontend_api_url: "https://match.clerk.accounts.dev" }, + ], + }, + }); + + await expect(hasKeyPairMismatch(KEYLESS, MATCHING_PK)).resolves.toBe(false); + }); + + test("resolves false for a malformed publishable key rather than reporting a mismatch", async () => { + await expect(hasKeyPairMismatch(KEYLESS, "not_a_publishable_key")).resolves.toBe(false); + // A malformed key can't be decoded, so there was nothing to check against BAPI for. + expect(bapiRequestSpy).not.toHaveBeenCalled(); + }); + + test("resolves false when BAPI returns an unexpected shape rather than blocking on it", async () => { + bapiRequestSpy.mockResolvedValue({ body: { data: "not-an-array" } }); + + await expect(hasKeyPairMismatch(KEYLESS, MATCHING_PK)).resolves.toBe(false); + }); + + test("propagates a BAPI failure so callers can decide how strict to be", async () => { + bapiRequestSpy.mockRejectedValue(new Error("network down")); + + await expect(hasKeyPairMismatch(KEYLESS, MATCHING_PK)).rejects.toThrow("network down"); + }); + }); + + // The warning that the keyless view covers less belongs to the config + // surface, so it hangs off `resolveInstanceTarget` — the resolver itself + // stays silent for `whoami`, `open`, `env pull` and `doctor`. + describe("findLocalPublishableKey — name priority in env files", () => { + beforeEach(async () => { + delete process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + delete process.env.CLERK_PUBLISHABLE_KEY; + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { next: "14.0.0" } }), + ); + }); + + test("the framework-specific name wins even when the generic fallback appears later in the file", async () => { + await writeFile( + join(tempDir, ".env.local"), + "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_framework\nCLERK_PUBLISHABLE_KEY=pk_test_generic\n", + ); + + expect(await findLocalPublishableKey(tempDir)).toBe("pk_test_framework"); + }); + + test("the framework-specific name wins regardless of line order", async () => { + await writeFile( + join(tempDir, ".env.local"), + "CLERK_PUBLISHABLE_KEY=pk_test_generic\nNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_framework\n", + ); + + expect(await findLocalPublishableKey(tempDir)).toBe("pk_test_framework"); + }); + + test("within one name a later file still overrides an earlier one", async () => { + await writeFile(join(tempDir, ".env"), "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_env\n"); + await writeFile( + join(tempDir, ".env.local"), + "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_env_local\n", + ); + + expect(await findLocalPublishableKey(tempDir)).toBe("pk_test_env_local"); + }); + + test("the generic fallback is still found when the framework name is absent", async () => { + await writeFile(join(tempDir, ".env.local"), "CLERK_PUBLISHABLE_KEY=pk_test_generic\n"); + + expect(await findLocalPublishableKey(tempDir)).toBe("pk_test_generic"); + }); + }); + + describe("resolveInstanceTarget — not-linked warning", () => { + const session = (expiresAt: number) => ({ + accessToken: "at", + refreshToken: "rt", + expiresAt, + tokenType: "Bearer" as const, + }); + + beforeEach(() => { + process.env.CLERK_SECRET_KEY = "sk_test_x"; + }); + + afterEach(() => { + delete process.env.CLERK_SECRET_KEY; + delete process.env.CLERK_PLATFORM_API_KEY; + }); + + test("resolving a target on its own never warns", async () => { + hasAccountCredentialsSpy.mockResolvedValue(true); + getStoredSessionSpy.mockResolvedValue(session(Date.now() - 60_000)); + + await resolveKeylessTarget({ cwd: tempDir }); + + expect(captured.err).toBe(""); + }); + + test("no warning at all when there are no account credentials", async () => { + await resolveInstanceTarget({ cwd: tempDir }); + + expect(captured.err).toBe(""); + }); + + test("points at `clerk link` when the stored session isn't locally expired", async () => { + hasAccountCredentialsSpy.mockResolvedValue(true); + getStoredSessionSpy.mockResolvedValue(session(Date.now() + 60_000)); + + await resolveInstanceTarget({ cwd: tempDir }); + + expect(captured.err).toContain("isn't linked to an application"); + expect(captured.err).toContain("Run `clerk link`"); + expect(captured.err).not.toContain("session has expired"); + }); + + test("points at `clerk auth login` first when the stored session has locally expired", async () => { + hasAccountCredentialsSpy.mockResolvedValue(true); + getStoredSessionSpy.mockResolvedValue(session(Date.now() - 60_000)); + + await resolveInstanceTarget({ cwd: tempDir }); + + expect(captured.err).toContain("stored session has expired"); + expect(captured.err).toContain("Run `clerk auth login` to re-authenticate"); + }); + + test("uses the `clerk link` wording for a platform API key, which never locally expires", async () => { + hasAccountCredentialsSpy.mockResolvedValue(true); + getStoredSessionSpy.mockResolvedValue(null); + process.env.CLERK_PLATFORM_API_KEY = "ak_test_platform"; + + await resolveInstanceTarget({ cwd: tempDir }); + + expect(captured.err).toContain("isn't linked to an application"); + expect(captured.err).not.toContain("session has expired"); + }); + + // `clerk link` accepts a platform API key on its own, so a leftover expired + // OAuth session alongside one must not send the user to log in again. + test("a platform API key wins over a stale stored session", async () => { + hasAccountCredentialsSpy.mockResolvedValue(true); + getStoredSessionSpy.mockResolvedValue(session(Date.now() - 60_000)); + process.env.CLERK_PLATFORM_API_KEY = "ak_test_platform"; + + await resolveInstanceTarget({ cwd: tempDir }); + + expect(captured.err).toContain("Run `clerk link`"); + expect(captured.err).not.toContain("session has expired"); + }); + }); +}); diff --git a/packages/cli-core/src/lib/keyless-target.ts b/packages/cli-core/src/lib/keyless-target.ts new file mode 100644 index 000000000..c1f7f5b08 --- /dev/null +++ b/packages/cli-core/src/lib/keyless-target.ts @@ -0,0 +1,311 @@ +/** + * Finding and addressing an unclaimed keyless application from the files a + * project already has on disk. + * + * Commands that can operate on an instance directly (config, feature toggles, + * whoami, env) resolve a target through here so the account and keyless paths + * are decided once, the same way, everywhere. + */ + +import { join } from "node:path"; +import { bapiRequest } from "./bapi.ts"; +import { resolveAppContext, resolveProfile } from "./config.ts"; +import { getStoredSession, hasAccountCredentials, type OAuthSession } from "./credential-store.ts"; +import { parseEnvFile } from "./dotenv.ts"; +import { CliError, ERROR_CODE, throwUsageError } from "./errors.ts"; +import { decodePublishableKey } from "./fapi.ts"; +import { detectPublishableKeyName, detectSecretKeyName } from "./framework.ts"; +import { log } from "./log.ts"; + +export interface KeylessTarget { + secretKey: string; + /** Where the key came from, for display (`CLERK_SECRET_KEY env var`, `.env.local`). */ + source: string; +} + +export interface AccountContext { + appId: string; + appLabel: string; + instanceId: string; + instanceLabel: string; +} + +/** + * Where a command should send its reads and writes. Resolve this once and + * branch on `kind`, so the account and keyless paths can't drift per command. + */ +export type InstanceTarget = + | { kind: "account"; ctx: AccountContext; label: string } + | { kind: "keyless"; keyless: KeylessTarget; label: string }; + +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"]; + +/** + * Reads the SDK's own keyless file, ignoring a partially-written one. + * Exported for `init`, whose keep-the-existing-app guard must recognise an + * application the SDK minted for itself just as readily as one `clerk init` + * minted — the two files describe the same kind of unclaimed app. + */ +export async function readSdkKeylessApp( + cwd: string, +): Promise<{ secretKey?: string; publishableKey?: string } | undefined> { + const file = Bun.file(join(cwd, ...SDK_KEYLESS_FILE)); + if (!(await file.exists())) return undefined; + + try { + const parsed = (await file.json()) as { secretKey?: unknown; publishableKey?: unknown }; + return { + secretKey: typeof parsed.secretKey === "string" ? parsed.secretKey : undefined, + publishableKey: typeof parsed.publishableKey === "string" ? parsed.publishableKey : undefined, + }; + } catch { + // A half-written file during an SDK refresh isn't worth failing over. + return undefined; + } +} + +interface LocatedKey { + value: string; + source: string; +} + +/** + * Looks for a key under any of `names`, in the order the app itself would + * resolve one: the environment first, then env files with a later file + * overriding an earlier one. + */ +async function findKeyInProject(cwd: string, names: string[]): Promise { + for (const name of new Set(names)) { + const value = process.env[name]; + if (value) return { value, source: `${name} env var` }; + } + + // Priority is by name, not by position: the framework-specific name beats + // the generic fallback even when the generic one appears later in the same + // file. Within one name, a later file still overrides an earlier one. + const foundByName = new Map(); + for (const envFile of ENV_FILES) { + const file = Bun.file(join(cwd, envFile)); + if (!(await file.exists())) continue; + + for (const line of parseEnvFile(await file.text())) { + if (line.type !== "entry" || !line.value) continue; + if (names.includes(line.key)) { + foundByName.set(line.key, { value: line.value, source: envFile }); + } + } + } + + for (const name of names) { + const located = foundByName.get(name); + if (located) return located; + } + return undefined; +} + +/** + * The instance secret key a keyless project keeps locally. Falls back to the + * keys an SDK created for itself, which it only does when nothing else supplies + * them — so that file goes last. + */ +export async function findLocalSecretKey(cwd: string): Promise { + const names = [await detectSecretKeyName(cwd), "CLERK_SECRET_KEY"]; + const located = await findKeyInProject(cwd, names); + + const found = located + ? { secretKey: located.value, source: located.source } + : await sdkKeylessTarget(cwd); + + if (found) log.debug(`keyless: secret key from ${found.source}`); + return found; +} + +async function sdkKeylessTarget(cwd: string): Promise { + const sdkApp = await readSdkKeylessApp(cwd); + if (!sdkApp?.secretKey) return undefined; + return { secretKey: sdkApp.secretKey, source: SDK_KEYLESS_FILE.join("/") }; +} + +/** The publishable key a keyless project holds locally, when one can be found. */ +export async function findLocalPublishableKey(cwd: string): Promise { + const names = [await detectPublishableKeyName(cwd), "CLERK_PUBLISHABLE_KEY"]; + const located = await findKeyInProject(cwd, names); + + return located?.value ?? (await readSdkKeylessApp(cwd))?.publishableKey; +} + +/** + * Whether a publishable key found locally does NOT belong to the same + * application as a secret key found locally. `findLocalSecretKey` and + * `findLocalPublishableKey` search independently — nothing stops one from + * returning app A's key and the other app B's, e.g. two keyless apps whose + * keys both happen to sit in the same `.env.local`. Presenting or writing + * that pair as one identity produces an app that fails at runtime in a way + * that's very hard to trace: the server trusts one app, the browser talks to + * another. + * + * `GET /v1/instance` doesn't echo a publishable key back for an unclaimed + * keyless app, so the only BAPI route that names the Frontend API host a + * secret key addresses is `/v1/domains` — every instance has at least one. + * Comparing that host against the one the publishable key decodes to + * (`decodePublishableKey` in `lib/fapi.ts`) is sound: unlike comparing + * `_test_`/`_live_` prefixes, a same-environment key from a different + * application can never pass it, and a legitimately matched pair always will. + * + * A malformed publishable key isn't this check's problem to report — callers + * that need `sk_`/`pk_` validation already do it elsewhere — so it resolves + * to "no mismatch" rather than throwing. + */ +export async function hasKeyPairMismatch( + keyless: KeylessTarget, + publishableKey: string, +): Promise { + let fapiHost: string; + try { + fapiHost = decodePublishableKey(publishableKey).fapiHost; + } catch { + return false; + } + + const response = await bapiRequest({ + method: "GET", + path: "/v1/domains", + secretKey: keyless.secretKey, + }); + const domains = (response.body as { data?: unknown }).data; + if (!Array.isArray(domains)) return false; + + const matchesADomain = domains.some((domain) => { + const frontendApiUrl = (domain as { frontend_api_url?: unknown }).frontend_api_url; + if (typeof frontendApiUrl !== "string") return false; + try { + return new URL(frontendApiUrl).host === fapiHost; + } catch { + return false; + } + }); + + return !matchesADomain; +} + +/** + * Resolves the keyless target for a command, or `undefined` when the + * account-authenticated path applies. + * + * Account credentials are deliberately NOT part of this decision: these + * commands must work from the instance secret key alone, with or without a + * platform API key or a login session. What rules keyless out is an explicit + * destination — `--app` or a linked profile — because that names an application + * the secret key on disk may not even belong to. + */ +export async function resolveKeylessTarget(options: { + app?: string; + instance?: string; + cwd?: string; +}): Promise { + if (options.app) return undefined; + + const cwd = options.cwd ?? process.cwd(); + if (await resolveProfile(cwd)) return undefined; + + const target = await findLocalSecretKey(cwd); + if (!target) return undefined; + + if (!target.secretKey.startsWith("sk_")) { + throw new CliError( + `Expected a secret key starting with \`sk_\` in ${target.source}, found something else.`, + { code: ERROR_CODE.INVALID_KEY_FORMAT }, + ); + } + + // The secret key addresses exactly one instance — its own — so there is no + // instance to choose between. + if (options.instance) { + throwUsageError( + `--instance is not supported for an unclaimed keyless application: the secret key in ${target.source} already targets its own instance.\n` + + "Run `clerk auth login` to claim the application, then target instances by name.", + ); + } + + return target; +} + +/** + * Says so when a signed-in user is getting the smaller, keyless view of an + * instance they could be reaching in full. + * + * This lives with `resolveInstanceTarget` rather than with the resolution + * itself because it is only true of the configuration surface: `clerk link` + * would widen what `config pull` and the feature toggles can see, but it does + * nothing for `whoami`, `env pull`, `open` or `doctor`, which want the same + * keyless answer either way. A resolver that warns is also a resolver no + * diagnostic tool can call without polluting its own report. + * + * `hasAccountCredentials` only tests presence, so a stored session past its own + * recorded expiry would otherwise be pointed at `clerk link`, which can't + * succeed until the user logs in again. Checking the token's real validity + * would mean a refresh round trip on every keyless command just to word a + * warning, so this reads the expiry already cached alongside the session — no + * network call, and right in the common case of a session stale by its own + * clock rather than one the server revoked. + */ +async function warnKeylessCoversLess(source: string): Promise { + if (!(await hasAccountCredentials())) return; + + // A platform API key never expires this way and is on its own enough to link, + // so it always gets the plain wording. + if (process.env.CLERK_PLATFORM_API_KEY) { + log.warn( + `This directory isn't linked to an application — using the secret key from ${source}, which covers fewer settings.\n` + + "Run `clerk link` (or pass --app ) to use the full configuration.", + ); + return; + } + + const session = await getStoredSession(); + log.warn( + session && isLocallyExpired(session) + ? `This directory isn't linked to an application, and the stored session has expired — using the secret key from ${source}, which covers fewer settings.\n` + + "Run `clerk auth login` to re-authenticate, then `clerk link` (or pass --app ) to use the full configuration." + : `This directory isn't linked to an application — using the secret key from ${source}, which covers fewer settings.\n` + + "Run `clerk link` (or pass --app ) to use the full configuration.", + ); +} + +/** + * A cheap, local-only proxy for "the account path is currently broken": + * whether a stored session's own recorded expiry has already passed. This + * can't see a session the *server* has revoked (that needs the refresh round + * trip `getValidToken` performs), only one that's stale by its own clock — + * but that's the common case, and it's the distinction the not-linked warning + * needs without paying for a network call on every keyless command. + */ +function isLocallyExpired(session: OAuthSession): boolean { + return Number.isFinite(session.expiresAt) && session.expiresAt <= Date.now(); +} + +export async function resolveInstanceTarget(options: { + app?: string; + instance?: string; + cwd?: string; +}): Promise { + const keyless = await resolveKeylessTarget(options); + if (keyless) { + await warnKeylessCoversLess(keyless.source); + return { + kind: "keyless", + keyless, + label: `this keyless application (secret key from ${keyless.source})`, + }; + } + + const ctx = await resolveAppContext(options); + return { kind: "account", ctx, label: `${ctx.appLabel} (${ctx.instanceLabel})` }; +} diff --git a/packages/cli-core/src/lib/keyless.test.ts b/packages/cli-core/src/lib/keyless.test.ts index 7def4fe36..4c781b13a 100644 --- a/packages/cli-core/src/lib/keyless.test.ts +++ b/packages/cli-core/src/lib/keyless.test.ts @@ -8,6 +8,7 @@ const { parseClaimToken, writeKeylessBreadcrumb, readKeylessBreadcrumb, + peekKeylessBreadcrumb, clearKeylessBreadcrumb, writeKeysToEnvFile, createAccountlessApp, @@ -74,6 +75,20 @@ describe("breadcrumb", () => { expect(await Bun.file(breadcrumbFile).exists()).toBe(false); }); + test("peek returns the breadcrumb like read does", async () => { + await writeKeylessBreadcrumb(tempDir, "token_abc"); + const result = await peekKeylessBreadcrumb(tempDir); + expect(result?.claimToken).toBe("token_abc"); + }); + + test("peek leaves a wrong-shape file in place instead of clearing it", async () => { + const breadcrumbFile = join(tempDir, ".clerk", "keyless.json"); + await Bun.write(breadcrumbFile, JSON.stringify({ claimToken: 12345, createdAt: "2024-01-01" })); + const result = await peekKeylessBreadcrumb(tempDir); + expect(result).toBeUndefined(); + expect(await Bun.file(breadcrumbFile).exists()).toBe(true); + }); + test("clear removes the breadcrumb file", async () => { await writeKeylessBreadcrumb(tempDir, "token_abc"); await clearKeylessBreadcrumb(tempDir); diff --git a/packages/cli-core/src/lib/keyless.ts b/packages/cli-core/src/lib/keyless.ts index b7fff3909..46db0a1a7 100644 --- a/packages/cli-core/src/lib/keyless.ts +++ b/packages/cli-core/src/lib/keyless.ts @@ -31,8 +31,20 @@ function isKeylessBreadcrumb(value: unknown): value is KeylessBreadcrumb { ); } +/** + * Application shapes the accountless endpoint can pre-configure at creation + * time — auth strategies, organizations, and billing are set server-side before + * the first key is ever used. + */ +export const KEYLESS_TEMPLATES = ["b2b-saas", "b2c-saas", "native", "waitlist"] as const; + +export type KeylessTemplate = (typeof KEYLESS_TEMPLATES)[number]; + /** Creates an accountless Clerk application via the public BAPI endpoint. */ -export async function createAccountlessApp(framework?: string): Promise { +export async function createAccountlessApp( + framework?: string, + template?: KeylessTemplate, +): Promise { const url = new URL("/v1/accountless_applications", getBapiBaseUrl()); const headers: Record = { @@ -41,7 +53,7 @@ export async function createAccountlessApp(framework?: string): Promise controller.abort(), CREATE_TIMEOUT_MS); @@ -138,6 +150,21 @@ export async function readKeylessBreadcrumb(cwd: string): Promise { + try { + const data: unknown = await Bun.file(breadcrumbPath(cwd)).json(); + return isKeylessBreadcrumb(data) ? data : undefined; + } catch { + return undefined; + } +} + export async function clearKeylessBreadcrumb(cwd: string): Promise { try { await unlink(breadcrumbPath(cwd)); diff --git a/packages/cli-core/src/lib/next-steps.ts b/packages/cli-core/src/lib/next-steps.ts index 807e507ba..e447ddd2e 100644 --- a/packages/cli-core/src/lib/next-steps.ts +++ b/packages/cli-core/src/lib/next-steps.ts @@ -61,6 +61,12 @@ export const NEXT_STEPS = { CONFIG_DRY_RUN_PUT: ["Run `clerk config put` without `--dry-run` to apply these changes"], 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. + WHOAMI_KEYLESS: [ + "Run `clerk open` to open this application's claim link", + "Run `clerk auth login` to connect your Clerk account", + ], WHOAMI_LINKED: [ "Run `clerk apps list` to see your other applications", "Run `clerk config pull` to inspect the live configuration of this instance", diff --git a/packages/cli-core/src/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index 5c08e7599..6d80b75cf 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -56,6 +56,8 @@ mock.module( } : null, hasStoredCredentials: async () => mockState.storedToken !== null, + hasAccountCredentials: async () => + Boolean(process.env.CLERK_PLATFORM_API_KEY) || mockState.storedToken !== null, storeToken: async (value: { accessToken: string }) => { mockState.storedToken = value.accessToken; }, diff --git a/packages/cli-core/src/test/lib/init-harness.ts b/packages/cli-core/src/test/lib/init-harness.ts new file mode 100644 index 000000000..5db6e2d9f --- /dev/null +++ b/packages/cli-core/src/test/lib/init-harness.ts @@ -0,0 +1,187 @@ +/** + * Shared harness for the `clerk init` test files. + * + * `init` orchestrates a dozen collaborators, so every test needs the same wall + * of spies. Keeping that wall here lets the test files split by concern + * (strategy selection vs. bootstrap plumbing) without duplicating setup. + * + * Pure `spyOn` — no `mock.module`, which is process-lifetime in Bun and would + * leak into other files. + */ + +import { afterEach, spyOn } from "bun:test"; +import { useCaptureLog } from "./stubs.ts"; + +export * as loginMod from "../../commands/auth/login.ts"; +export * as linkMod from "../../commands/link/index.ts"; +export * as pullMod from "../../commands/env/pull.ts"; +export * as mode from "../../mode.ts"; +export * as config from "../../lib/config.ts"; +export * as frameworkMod from "../../lib/framework.ts"; +export * as context from "../../commands/init/context.ts"; +export * as scaffoldMod from "../../commands/init/scaffold.ts"; +export * as previewMod from "../../commands/init/preview.ts"; +export * as formatMod from "../../commands/init/format.ts"; +export * as scanMod from "../../commands/init/scan.ts"; +export * as heuristics from "../../commands/init/heuristics.ts"; +export * as skillsMod from "../../commands/init/skills.ts"; +export * as bootstrapMod from "../../commands/init/bootstrap.ts"; +export * as nextStepsMod from "../../lib/next-steps.ts"; +export * as keylessMod from "../../lib/keyless.ts"; +export * as keylessTargetMod from "../../lib/keyless-target.ts"; + +import * as loginModule from "../../commands/auth/login.ts"; +import * as linkModule from "../../commands/link/index.ts"; +import * as pullModule from "../../commands/env/pull.ts"; +import * as modeModule from "../../mode.ts"; +import * as configModule from "../../lib/config.ts"; +import * as frameworkModule from "../../lib/framework.ts"; +import * as contextModule from "../../commands/init/context.ts"; +import * as scaffoldModule from "../../commands/init/scaffold.ts"; +import * as previewModule from "../../commands/init/preview.ts"; +import * as formatModule from "../../commands/init/format.ts"; +import * as scanModule from "../../commands/init/scan.ts"; +import * as heuristicsModule from "../../commands/init/heuristics.ts"; +import * as skillsModule from "../../commands/init/skills.ts"; +import * as bootstrapModule from "../../commands/init/bootstrap.ts"; +import * as keylessModule from "../../lib/keyless.ts"; + +export const FAKE_CTX = { + cwd: "/tmp/test", + framework: { + dep: "react", + name: "React", + sdk: "@clerk/react", + envVar: "VITE_CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + }, + typescript: true, + srcDir: false, + packageManager: "npm" as const, + existingClerk: true, + deps: { react: "^19.0.0" }, + envFile: ".env", +}; + +export const FAKE_BOOTSTRAP = { + projectDir: "/tmp/test/my-app", + projectName: "my-app", + packageManager: "npm" as const, +}; + +type FakeFramework = { + dep: string; + name: string; + sdk: string; + envVar: string; + envFile: ".env" | ".env.local"; + supportsKeyless?: boolean; +}; + +export type FakeCtx = Omit & { framework: FakeFramework }; + +export const KEYLESS_CTX: FakeCtx = { + ...FAKE_CTX, + existingClerk: false, + framework: { ...FAKE_CTX.framework, supportsKeyless: true }, +}; + +export function mockBootstrapTo(ctx: FakeCtx): void { + spyOn(contextModule, "gatherContext").mockResolvedValueOnce(null).mockResolvedValueOnce(ctx); +} + +export function mockExistingProject(ctx: FakeCtx): void { + spyOn(contextModule, "gatherContext").mockResolvedValue(ctx); +} + +export function mockMiddlewareScaffold(): void { + spyOn(scaffoldModule, "scaffold").mockResolvedValue({ + actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }], + postInstructions: [], + }); +} + +export interface InitHarness { + setup: (overrides?: { email?: string | null; apiKey?: boolean; isAgent?: boolean }) => { + gatherContextSpy: ReturnType; + captured: ReturnType; + }; + setupBootstrapSuccess: () => void; + /** Registers an extra spy so the harness restores it with the rest. */ + track: (spy: ReturnType) => void; + captured: ReturnType; +} + +/** + * Registers the spy lifecycle for an `init` describe block. Call at describe + * scope, exactly like `useCaptureLog()`. + */ +export function useInitHarness(): InitHarness { + let spies: ReturnType[] = []; + const captured = useCaptureLog(); + + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + }); + + function setup(overrides: { email?: string | null; apiKey?: boolean; isAgent?: boolean } = {}) { + const email = overrides.email ?? null; + const apiKey = overrides.apiKey ?? false; + const agent = overrides.isAgent ?? false; + const authed = email != null || apiKey; + const gatherContextSpy = spyOn(contextModule, "gatherContext").mockResolvedValue(null); + + spies = [ + spyOn(modeModule, "isAgent").mockReturnValue(agent), + spyOn(modeModule, "isHuman").mockReturnValue(!agent), + spyOn(configModule, "resolveProfile").mockResolvedValue(undefined), + spyOn(frameworkModule, "lookupFramework").mockReturnValue(null), + gatherContextSpy, + spyOn(contextModule, "hasPackageJson").mockResolvedValue(false), + spyOn(scaffoldModule, "scaffold").mockResolvedValue({ actions: [], postInstructions: [] }), + spyOn(scaffoldModule, "enrichProjectContext").mockResolvedValue(undefined), + spyOn(previewModule, "previewPlan").mockReturnValue(undefined), + spyOn(previewModule, "previewAndConfirm").mockResolvedValue(true), + spyOn(formatModule, "runFormatters").mockResolvedValue(undefined), + spyOn(scanModule, "detectAuthLibraries").mockReturnValue(undefined), + spyOn(scanModule, "scanForIssues").mockResolvedValue([]), + spyOn(heuristicsModule, "getAuthenticatedEmail").mockResolvedValue(email), + spyOn(heuristicsModule, "isAuthenticated").mockResolvedValue(authed), + spyOn(heuristicsModule, "printKeylessInfo").mockReturnValue(undefined), + spyOn(heuristicsModule, "installSdk").mockResolvedValue(undefined), + spyOn(heuristicsModule, "installDeps").mockResolvedValue(undefined), + spyOn(heuristicsModule, "writePlan").mockResolvedValue([]), + spyOn(heuristicsModule, "checkGitDirty").mockResolvedValue(false), + spyOn(heuristicsModule, "printOutro").mockReturnValue(undefined), + spyOn(skillsModule, "installSkills").mockResolvedValue(undefined), + spyOn(loginModule, "login").mockResolvedValue(undefined as never), + spyOn(linkModule, "link").mockResolvedValue(undefined), + spyOn(pullModule, "pull").mockResolvedValue(undefined), + spyOn(bootstrapModule, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP), + spyOn(bootstrapModule, "confirmOverwrite").mockResolvedValue(undefined), + spyOn(keylessModule, "createAccountlessApp").mockResolvedValue({ + publishable_key: "pk_test_stub", + secret_key: "sk_test_stub", + claim_url: "/apps/claim?token=stub_token", + }), + spyOn(keylessModule, "writeKeysToEnvFile").mockResolvedValue(undefined), + spyOn(keylessModule, "writeKeylessBreadcrumb").mockResolvedValue(undefined), + ]; + + return { gatherContextSpy, captured }; + } + + function setupBootstrapSuccess(): void { + const gatherSpy = + spies.find((s) => s.getMockName?.() === "gatherContext") ?? + spyOn(contextModule, "gatherContext"); + gatherSpy.mockResolvedValueOnce(null).mockResolvedValueOnce(FAKE_CTX); + } + + function track(spy: ReturnType): void { + spies.push(spy); + } + + return { setup, setupBootstrapSuccess, track, captured }; +} diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index 1fbf961dd..70598ff99 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -126,26 +126,54 @@ export function captureUi() { const noop = async () => {}; +// Mocking a module replaces it wholesale, so this must cover every export of +// lib/config.ts — a missing name is an import error in any consumer, not just +// the one under test. export const configStubs = { _setConfigDir: () => {}, + getConfigFile: () => "", readConfig: noop, writeConfig: noop, getAuth: noop, setAuth: noop, clearAuth: noop, + getEnvironment: noop, + setEnvironment: noop, getProfile: noop, setProfile: noop, removeProfile: noop, moveProfile: noop, listProfiles: noop, + getRelayEntry: noop, + setRelayEntry: noop, resolveProfile: noop, resolveProfileOrAutolink: noop, resolveInstanceId: () => ({ id: "", label: "" }), + resolveFetchedApplicationInstance: () => ({ + found: false, + instanceId: "", + instanceLabel: "", + instance: undefined, + }), resolveAppContext: async () => ({ appId: "", appLabel: "", instanceId: "", instanceLabel: "" }), profileLabel: (profile: { appName?: string; appId: string }) => profile.appName ? `${profile.appName} (${profile.appId})` : profile.appId, }; +// Same wholesale-replacement rule as configStubs: this must cover every +// export of lib/keyless-target.ts, or importing it anywhere in the process +// after the mock registers becomes an import error. Spread it into each +// `mock.module("../../lib/keyless-target.ts", ...)` and override the exports +// the file under test actually exercises. +export const keylessTargetStubs = { + resolveKeylessTarget: noop, + resolveInstanceTarget: noop, + findLocalSecretKey: noop, + findLocalPublishableKey: noop, + hasKeyPairMismatch: async () => false, + readSdkKeylessApp: noop, +}; + export const autolinkStubs = { findClerkKeys: async () => [], matchKeyToApp: () => undefined, @@ -158,6 +186,7 @@ export const credentialStoreStubs = { getValidToken: async () => null, getStoredSession: async () => null, hasStoredCredentials: async () => false, + hasAccountCredentials: async () => Boolean(process.env.CLERK_PLATFORM_API_KEY), storeToken: async () => {}, deleteToken: async () => {}, createOAuthSession: (tokenResponse: {