diff --git a/.changeset/keyless-mode-default.md b/.changeset/keyless-mode-default.md new file mode 100644 index 00000000..eea8488a --- /dev/null +++ b/.changeset/keyless-mode-default.md @@ -0,0 +1,25 @@ +--- +"clerk": minor +--- + +Make keyless mode the default for unauthenticated `clerk init` runs on keyless-capable frameworks, and add a `--login` flag to force the authenticated flow. + +- Bootstrapping a new project while signed out now uses auto-generated temporary development keys instead of prompting for a browser login; running `clerk auth login` later claims the app automatically. Re-running init in an existing project while signed out still triggers the login flow. +- Agent-mode runs while signed out now set up keyless keys with a claim breadcrumb instead of printing manual setup guidance. +- `--keyless` now forces keyless mode even when signed in, including in existing projects. Combining it with `--login` or `--app` exits with a usage error. +- `--login` in agent mode while signed out exits with a usage error, since the browser login needs an interactive terminal. + +Add keyless support to `clerk config pull` and `clerk config patch`, so an unclaimed keyless application can be configured without logging in. + +- When the project isn't linked and no `--app` is passed, both commands use the instance secret key the project already holds and talk to the Backend API instead of the Platform API. This needs no account at all — no login and no `CLERK_PLATFORM_API_KEY`. If credentials happen to be present, the command still works and warns that linking would give the full configuration. +- Keyless payloads name Backend API resources directly: `instance`, `communication`, `restrictions`, `organization_settings`, `protect`, `oauth_application_settings`, and `instance_settings` (`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. +- `clerk config pull` returns the same envelope; `restrictions` and `instance_settings` are omitted because the Backend API has no read route for them. +- In keyless mode `--dry-run` previews the diff locally without sending anything, `--instance` exits with a usage error (the secret key already targets one instance), and `clerk config put` and `clerk config schema` explain that they need a claimed application. + +Let more commands operate on an unclaimed keyless application, so an agent can bootstrap and configure Clerk end to end without an account. + +- `clerk enable orgs` and `clerk disable orgs` now work without logging in, writing through the instance's own organization settings. `clerk enable/disable billing` still requires a claimed application and now says so directly, because Clerk's Backend API exposes no billing settings. +- `clerk whoami` reports the keyless instance — id, environment, publishable key, and where the key was found — instead of failing with "not logged in". +- `clerk env pull` writes a keyless application's locally-held keys into the project's env file instead of failing with "not linked". +- Every keyless-capable command now also finds the keys a Clerk SDK created for itself in `.clerk/.tmp/keyless.json`, so an application minted by running the dev server is reachable from the CLI. +- `clerk init --template ` pre-configures the keyless application at creation — a `b2b-saas` app comes back with organizations already enabled. Keyless-only; combining it with `--login` exits with a usage error. diff --git a/packages/cli-core/src/commands/billing/README.md b/packages/cli-core/src/commands/billing/README.md index bd29a806..2b8c00d5 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 15e0b1ef..93bd19d2 100644 --- a/packages/cli-core/src/commands/billing/index.ts +++ b/packages/cli-core/src/commands/billing/index.ts @@ -1,5 +1,4 @@ -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 { confirm } from "../../lib/prompts.ts"; @@ -8,6 +7,7 @@ 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 +56,26 @@ 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. + */ +async function resolveBillingTarget(options: BillingOptions): Promise { + const target = await resolveInstanceTarget(options); + if (target.kind === "keyless") { + throw new CliError( + "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.", + { code: ERROR_CODE.AUTH_REQUIRED }, + ); + } + return target; +} + export async function billingEnable(options: BillingOptions): Promise { const targets = parseForTargets(options.for); - const ctx = await resolveAppContext(options); + const target = await resolveBillingTarget(options); const billing: Record = {}; const payload: Record = { billing }; @@ -73,7 +90,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 +139,7 @@ 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 resolveBillingTarget(options); // 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 ed5c4b9f..f5f10d16 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,88 @@ 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 — the account-mode keys (`session`, `sign_up`, `auth_email`, …) are not silently translated. + +`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` is omitted because the Backend API has no read route for it; asking for it 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. + +### Differences from account mode + +| Behavior | Account mode | Keyless mode | +| --------------- | ---------------------------------------- | ----------------------------------------------------------------- | +| Coverage | Full config document | Three 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 | + +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 group is re-read afterwards. | +| `PATCH` | `/v1/instance/restrictions` | Updates sign-up restrictions (allowlist, blocklist). Write-only — no read route exists. | +| `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. | diff --git a/packages/cli-core/src/commands/config/apply-patch.ts b/packages/cli-core/src/commands/config/apply-patch.ts index b3368fee..2ee19581 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 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,13 +28,13 @@ 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"); @@ -35,8 +42,8 @@ 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.debug(`config: ${JSON.stringify(result)}`); log.success(dryRun ? "[dry-run] Validation passed — no changes applied" : 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 00000000..c6907f6d --- /dev/null +++ b/packages/cli-core/src/commands/config/io.ts @@ -0,0 +1,83 @@ +/** + * 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 } from "./keyless.ts"; + +export type ConfigMethod = "PUT" | "PATCH"; + +/** + * 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 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. + return patchKeylessConfig(target.keyless, payload as Record>); + } + + const apiFn = options.method === "PUT" ? putInstanceConfig : patchInstanceConfig; + return withApiContext( + apiFn(target.ctx.appId, target.ctx.instanceId, payload, { + destructive: options.destructive, + dryRun: options.dryRun, + }), + options.dryRun ? "Dry-run failed" : options.failureContext, + ); +} 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 00000000..da37a9b9 --- /dev/null +++ b/packages/cli-core/src/commands/config/keyless.test.ts @@ -0,0 +1,537 @@ +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(); + + 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"); + }); + + stubFetch(async (input, init) => { + const path = input.toString().replace(BAPI_URL, ""); + const method = (init?.method ?? "GET").toUpperCase(); + + // PATCH /v1/instance answers 204 with no body; every other group echoes. + if (path === "/v1/instance" && method === "PATCH") { + 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, + }); + } + const body = READABLE_BODIES[path]; + if (body) return new Response(JSON.stringify(body), { 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, and says why", 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", + }); + expect(captured.err).toContain("isn't linked to an application"); + }); + + test("stays quiet about the reduced coverage when there is no account", async () => { + await writeEnv(".env", `CLERK_SECRET_KEY=${SECRET_KEY}\n`); + const { resolveKeylessTarget } = await import("../../lib/keyless-target.ts"); + + await resolveKeylessTarget({ 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("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/); + }); + }); + + 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", async () => { + const requests: string[] = []; + stubFetch(async (input, init) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + requests.push(`${method} ${url.replace(BAPI_URL, "")}`); + if (url.endsWith("/v1/instance") && method === "PATCH") { + return new Response(null, { status: 204 }); + } + if (url.endsWith("/v1/instance")) { + return new Response(JSON.stringify(INSTANCE), { status: 200 }); + } + return new Response(JSON.stringify(ORG_SETTINGS), { 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", + // 204 carries no body, so the group is re-read for the caller. + "GET /v1/instance", + "PATCH /v1/instance/organization_settings", + ]); + expect(result).toEqual({ instance: INSTANCE, organization_settings: ORG_SETTINGS }); + }); + + 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)" }); + }); + + test("returns the response body for groups that answer with one", async () => { + const { patchKeylessConfig } = await import("./keyless.ts"); + + const result = await patchKeylessConfig( + { secretKey: SECRET_KEY, source: ".env" }, + { restrictions: { allowlist: true } }, + ); + + expect(result).toEqual({ + restrictions: { object: "instance_restrictions", allowlist: true }, + }); + }); + }); + + 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}`); + return new Response(JSON.stringify(READABLE_BODIES[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("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 00000000..e1398508 --- /dev/null +++ b/packages/cli-core/src/commands/config/keyless.ts @@ -0,0 +1,182 @@ +/** + * 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 { 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; +} + +/** + * 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( + `Unsupported config ${unknown.length === 1 ? "key" : "keys"} for an unclaimed keyless application: ${unknown.join(", ")}.\n` + + `Supported keys: ${KEYLESS_GROUP_NAMES.join(", ")}.`, + ); + } + 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) { + throwUsageError( + `Unsupported config ${unknown.length === 1 ? "key" : "keys"} for an unclaimed keyless application: ${unknown.join(", ")}.\n` + + `Supported top-level keys: ${KEYLESS_GROUP_NAMES.join(", ")}.\n` + + "Run `clerk auth login` to claim the application and use the full config document.", + ); + } + + for (const [key, value] of Object.entries(payload)) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throwUsageError( + `Config key \`${key}\` must be a JSON object.`, + undefined, + ERROR_CODE.INVALID_JSON, + ); + } + } +} + +/** + * 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; +} + +/** + * Applies each group in the payload to its own BAPI resource and returns what + * the API reported back. `PATCH /v1/instance` answers 204 with no body, so that + * group is re-read to give the caller something to see. + */ +export async function patchKeylessConfig( + target: KeylessTarget, + payload: Record>, +): Promise> { + const results: Record = {}; + + // 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 applied = Object.keys(results); + const context = + applied.length > 0 + ? `Failed to update ${name} (already applied: ${applied.join(", ")})` + : `Failed to update ${name}`; + + const response = await withApiContext( + bapiRequest({ + method: "PATCH", + path: group.path, + secretKey: target.secretKey, + body: JSON.stringify(payload[name]), + }), + context, + ); + + if (response.body) { + results[name] = response.body; + continue; + } + + // A 204 carries nothing to show; re-read so the caller still sees the group. + results[name] = group.readable + ? ( + await withApiContext( + bapiRequest({ method: "GET", path: group.path, secretKey: target.secretKey }), + `Failed to fetch ${name}`, + ) + ).body + : null; + } + + return results; +} + +/** 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 64a68dbd..3cd71329 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 36c61be1..6dd56f94 100644 --- a/packages/cli-core/src/commands/config/push.ts +++ b/packages/cli-core/src/commands/config/push.ts @@ -1,12 +1,10 @@ -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"; @@ -14,6 +12,15 @@ 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 { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { resolveInstanceTarget } from "../../lib/keyless-target.ts"; +import { + assertPayloadWritable, + LOCAL_DRY_RUN_MESSAGE, + readInstanceConfig, + supportsServerDryRun, + writeInstanceConfig, + type ConfigMethod, +} from "./io.ts"; interface ConfigPushOptions { app?: string; @@ -26,15 +33,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 +43,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 +61,18 @@ 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); + const target = await resolveInstanceTarget(options); - let configPayload: Record; - try { - configPayload = JSON.parse(rawInput); - } catch { - throwUsageError( - "Invalid JSON input. Please provide valid JSON.", - undefined, - ERROR_CODE.INVALID_JSON, + if (target.kind === "keyless" && op.method === "PUT") { + throw new CliError( + "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.", + { code: ERROR_CODE.AUTH_REQUIRED }, ); } - if (typeof configPayload !== "object" || configPayload === null || Array.isArray(configPayload)) { - 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 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", - ), + writeInstanceConfig(target, configPayload, { + method: op.method, + destructive: options.destructive, + dryRun: options.dryRun, + failureContext: "Failed to push config", + }), ); log.data(JSON.stringify(result, null, 2)); log.success( @@ -160,6 +154,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; diff --git a/packages/cli-core/src/commands/config/schema.ts b/packages/cli-core/src/commands/config/schema.ts index 4762f497..130551c4 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 { resolveKeylessTarget } from "../../lib/keyless-target.ts"; interface ConfigSchemaOptions { app?: string; @@ -13,6 +14,14 @@ interface ConfigSchemaOptions { export async function configSchema(options: ConfigSchemaOptions): Promise { await withGutter("Fetching configuration schema", async () => { + if (await resolveKeylessTarget(options)) { + throw new CliError( + "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`.", + { code: ERROR_CODE.AUTH_REQUIRED }, + ); + } + const ctx = await resolveAppContext(options); log.info(`Pulling config schema from ${ctx.appLabel} (${ctx.instanceLabel})...`); diff --git a/packages/cli-core/src/commands/env/README.md b/packages/cli-core/src/commands/env/README.md index 379b5a2a..77e28f02 100644 --- a/packages/cli-core/src/commands/env/README.md +++ b/packages/cli-core/src/commands/env/README.md @@ -2,6 +2,8 @@ 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, and makes no API call. 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). + ## Usage ```sh diff --git a/packages/cli-core/src/commands/env/pull.ts b/packages/cli-core/src/commands/env/pull.ts index fb65d3dc..502bb8a6 100644 --- a/packages/cli-core/src/commands/env/pull.ts +++ b/packages/cli-core/src/commands/env/pull.ts @@ -8,6 +8,11 @@ import { detectEnvFile, } from "../../lib/framework.ts"; import { CliError, ERROR_CODE, withApiContext } from "../../lib/errors.ts"; +import { + findLocalPublishableKey, + resolveKeylessTarget, + type KeylessTarget, +} from "../../lib/keyless-target.ts"; import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; @@ -50,6 +55,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), @@ -71,22 +86,56 @@ export async function pull(options: EnvPullOptions): Promise { const publishableKeyName = await detectPublishableKeyName(cwd); const secretKeyName = await detectSecretKeyName(cwd); - 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) { - vars[secretKeyName] = matched.secret_key; - } - const merged = mergeEnvVars(lines, vars); - const output = serializeEnvFile(merged); - - await Bun.write(targetFile, output); + ...(matched.secret_key && { [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), + ]); + + 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 56192625..f98d85d8 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,8 @@ 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 -y clerk init --yes clerk init --no-skills @@ -19,16 +21,18 @@ 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` | -| `--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` | +| `--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`. Keyless-only — 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 | +| `--no-skills` | Skip the optional agent skills install prompt at the end of init | ## Agent Mode @@ -40,20 +44,22 @@ 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) ## 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): + - **`--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 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 + - **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 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 @@ -87,7 +93,7 @@ Detects the project's framework from `package.json` dependencies (checked top-to | `express` | Express | `@clerk/express` | `CLERK_PUBLISHABLE_KEY` | No | | `fastify` | Fastify | `@clerk/fastify` | `CLERK_PUBLISHABLE_KEY` | No | -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. @@ -227,6 +233,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 an application is created, so it is keyless-only: combining it with `--login` exits with a usage error. 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. diff --git a/packages/cli-core/src/commands/init/heuristics.ts b/packages/cli-core/src/commands/init/heuristics.ts index fab992ba..615568b3 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 { diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index a99fd2f5..047ad4e9 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 40abe1e5..31879c91 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -15,6 +15,8 @@ import { writeKeysToEnvFile, parseClaimToken, writeKeylessBreadcrumb, + KEYLESS_TEMPLATES, + type KeylessTemplate, } from "../../lib/keyless.js"; import { printNextSteps } from "../../lib/next-steps.js"; import { gatherContext, hasPackageJson } from "./context.js"; @@ -55,14 +57,20 @@ 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; }; 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; @@ -100,8 +108,10 @@ export async function init(options: InitOptions = {}) { const strategy = pickStrategy({ optsKeyless, + optsLogin: options.login === true, agent, authed, + isBootstrap: bootstrap != null, hasRealAppTarget, framework: ctx.framework, }); @@ -131,7 +141,7 @@ export async function init(options: InitOptions = {}) { } bar(); - await runStrategy(strategy, ctx); + await runStrategy(strategy, ctx, options.template); if (options.skills !== false) { bar(); @@ -147,6 +157,32 @@ export async function init(options: InitOptions = {}) { outro("Done"); } +/** + * Rejects flag combinations that can't both be honoured, before anything is + * bootstrapped on disk. `--keyless` and `--template` 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.login && agent && !(await isAuthenticated())) { + throwUsageError( + "--login requires an interactive terminal to complete the browser login. Ask the user to run `clerk auth login`, then re-run `clerk init`.", + ); + } +} + type ResolvedContext = { ctx: ProjectContext; bootstrap: BootstrapResult | null; @@ -229,19 +265,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")); } @@ -250,20 +281,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 { @@ -275,11 +314,17 @@ 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 { +async function runStrategy( + strategy: InitStrategy, + ctx: ProjectContext, + template?: KeylessTemplate, +): Promise { switch (strategy) { case "manual": printBootstrapManualSetupInfo(ctx.framework); @@ -288,7 +333,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, template); return; } } @@ -330,10 +375,18 @@ async function authenticateAndLink( // --- Keyless app setup --- -async function setupKeylessApp(cwd: string, frameworkDep: string, envFile: string): Promise { +async function setupKeylessApp( + cwd: string, + frameworkDep: string, + envFile: string, + template?: KeylessTemplate, +): Promise { 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, { @@ -445,7 +498,17 @@ 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", + ).choices(KEYLESS_TEMPLATES), ) .option("-y, --yes", "Skip confirmation prompts") .option("--no-skills", "Skip the optional agent skills install prompt") @@ -466,7 +529,15 @@ 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 -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 00000000..a1a93079 --- /dev/null +++ b/packages/cli-core/src/commands/init/strategy.test.ts @@ -0,0 +1,455 @@ +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, +} from "../../test/lib/init-harness.ts"; +import { init } from "./index.ts"; + +describe("init strategy", () => { + const { setup, setupBootstrapSuccess } = 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(); + }); +}); diff --git a/packages/cli-core/src/commands/orgs/README.md b/packages/cli-core/src/commands/orgs/README.md index f585f9c8..41f8f7ca 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 aebf8076..ee495ac4 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/whoami/README.md b/packages/cli-core/src/commands/whoami/README.md index b1909869..995ed007 100644 --- a/packages/cli-core/src/commands/whoami/README.md +++ b/packages/cli-core/src/commands/whoami/README.md @@ -22,7 +22,31 @@ 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. + +```json +{ + "email": null, + "keyless": { + "instanceId": "ins_...", + "environmentType": "development", + "publishableKey": "pk_test_...", + "keySource": ".clerk/.tmp/keyless.json" + }, + "linked": null +} +``` + +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. | + - 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.ts b/packages/cli-core/src/commands/whoami/index.ts index b9eeac76..2b3de640 100644 --- a/packages/cli-core/src/commands/whoami/index.ts +++ b/packages/cli-core/src/commands/whoami/index.ts @@ -7,15 +7,52 @@ import { AuthError } 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, + 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> } + | { + kind: "keyless"; + instanceId: string | null; + environmentType: string | null; + publishableKey: string | null; + keySource: string; + }; + export async function whoami(options: WhoamiOptions = {}) { + const identity = await resolveIdentity(); + + if (options.json || isAgent()) { + log.data(JSON.stringify(toJson(identity), null, 2)); + return; + } + + render(identity); +} + +async function resolveIdentity(): Promise { const token = await getValidToken(); + + // No 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) throw new AuthError({ reason: "not_logged_in" }); + return describeKeyless(keyless); } let userInfo; @@ -25,44 +62,75 @@ 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, - ), + return { kind: "account", email: userInfo.email, profile }; +} + +async function describeKeyless(keyless: KeylessTarget): Promise { + const instance = await withSpinner("Fetching instance info...", async () => { + const response = await bapiRequest({ + method: "GET", + path: "/v1/instance", + secretKey: keyless.secretKey, + }); + return response.body as { id?: string; environment_type?: string }; + }); + + return { + kind: "keyless", + instanceId: instance.id ?? null, + environmentType: instance.environment_type ?? null, + publishableKey: (await findLocalPublishableKey(process.cwd())) ?? null, + keySource: keyless.source, + }; +} + +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, + 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}\`)`, ); + printNextSteps(NEXT_STEPS.WHOAMI); 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)}\``); } - 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/credential-store.ts b/packages/cli-core/src/lib/credential-store.ts index 5094b78d..d2df0b20 100644 --- a/packages/cli-core/src/lib/credential-store.ts +++ b/packages/cli-core/src/lib/credential-store.ts @@ -436,6 +436,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.ts b/packages/cli-core/src/lib/keyless-target.ts new file mode 100644 index 00000000..fb6be9f4 --- /dev/null +++ b/packages/cli-core/src/lib/keyless-target.ts @@ -0,0 +1,196 @@ +/** + * 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 { resolveAppContext, resolveProfile } from "./config.ts"; +import { hasAccountCredentials } from "./credential-store.ts"; +import { parseEnvFile } from "./dotenv.ts"; +import { CliError, ERROR_CODE, throwUsageError } from "./errors.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. */ +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` }; + } + + let found: LocatedKey | undefined; + 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)) found = { value: line.value, source: envFile }; + } + } + + return found; +} + +/** + * 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; +} + +/** + * 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.", + ); + } + + // Signed in but unlinked is the one case where this fallback is surprising: + // the account could reach the full configuration if the project were linked. + // Say so rather than quietly answering with the smaller view. + if (await hasAccountCredentials()) { + log.warn( + `This directory isn't linked to an application — using the secret key from ${target.source}, which covers fewer settings.\n` + + "Run `clerk link` (or pass --app ) to use the full configuration.", + ); + } + + return target; +} + +export async function resolveInstanceTarget(options: { + app?: string; + instance?: string; + cwd?: string; +}): Promise { + const keyless = await resolveKeylessTarget(options); + if (keyless) { + 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.ts b/packages/cli-core/src/lib/keyless.ts index b7fff390..d87ea0b3 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); diff --git a/packages/cli-core/src/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index 5c08e759..6d80b75c 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 00000000..f47e03c6 --- /dev/null +++ b/packages/cli-core/src/test/lib/init-harness.ts @@ -0,0 +1,186 @@ +/** + * 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"; + +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 1fbf961d..13e6f8db 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -126,21 +126,35 @@ 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, @@ -158,6 +172,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: {