From e4fff300256978bc0e030a64393e5c78ae2c4811 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Wed, 2 Sep 2026 15:03:24 -0400 Subject: [PATCH] feat(init): let accountless setup run on every framework Remove the supportsKeyless gate and the agent-mode "manual" strategy. The CLI mints the accountless app itself and only needs the framework's env var names, so Vue, React, JavaScript, Expo, Express, Fastify, iOS, and Android resolve to accountless like Next.js. iOS and Android get only the publishable key in .env, mirroring env pull. Co-Authored-By: Claude Fable 5.1 --- .changeset/accountless-every-framework.md | 5 + packages/cli-core/src/commands/init/README.md | 50 ++-- .../src/commands/init/bootstrap-registry.ts | 14 +- .../src/commands/init/frameworks/android.ts | 6 +- .../src/commands/init/frameworks/expo.ts | 2 +- .../src/commands/init/frameworks/ios.ts | 6 +- .../commands/init/frameworks/javascript.ts | 4 +- .../commands/init/frameworks/node-server.ts | 2 +- .../src/commands/init/frameworks/react.ts | 4 +- .../cli-core/src/commands/init/index.test.ts | 2 - packages/cli-core/src/commands/init/index.ts | 83 ++----- .../src/commands/init/strategy.test.ts | 213 ++++++------------ packages/cli-core/src/lib/framework.ts | 9 - packages/cli-core/src/lib/keyless.test.ts | 17 +- packages/cli-core/src/lib/keyless.ts | 18 +- .../src/test/integration/agent-mode.test.ts | 45 +++- .../cli-core/src/test/lib/init-harness.ts | 19 +- 17 files changed, 201 insertions(+), 298 deletions(-) create mode 100644 .changeset/accountless-every-framework.md diff --git a/.changeset/accountless-every-framework.md b/.changeset/accountless-every-framework.md new file mode 100644 index 000000000..71d4a0585 --- /dev/null +++ b/.changeset/accountless-every-framework.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Let `clerk init` set up an accountless application on every framework it supports. Vue, React, JavaScript, Expo, Express, Fastify, iOS, and Android now resolve to accountless the same way Next.js does: unauthenticated agent runs and new-project bootstraps mint temporary development keys without a login, `--accountless` is accepted everywhere, and the agent-mode "set up keys manually" fallback is gone. iOS and Android projects get only the publishable key in `.env`, since their default `.gitignore` doesn't cover it. diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index de2eb7cf3..c5cb67f64 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. When the user is unauthenticated and the framework supports accountless, init defaults to accountless 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. `--accountless` forces accountless (even when logged in); `--login` forces the authenticated flow. +Initializes Clerk in a project by detecting the framework, installing the SDK, and scaffolding framework-specific boilerplate. When the user is unauthenticated, init defaults to accountless 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. `--accountless` forces accountless (even when logged in); `--login` forces the authenticated flow. ## Usage @@ -29,7 +29,7 @@ clerk init --no-skills | `--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) | -| `--accountless` | Force auto-generated temporary development keys, even when logged in. Only valid on an accountless-capable framework; cannot be combined with `--login` or `--app` | +| `--accountless` | Force auto-generated temporary development keys, even when logged in. Cannot be combined with `--login` or `--app` | | `--login` | Force the authenticated flow: log in (interactively if needed) and link a real application instead of accountless keys. Errors in agent mode when unauthenticated (agents can't run OAuth) | | `--template ` | Pre-configure the accountless application at creation: `b2b-saas`, `b2c-saas`, `native`, `waitlist`. Only applies when the run resolves to accountless — errors otherwise (see [Application templates](#application-templates)); cannot be combined with `--login` | | `--fresh` | Replace an existing unclaimed accountless application with a new one, instead of keeping it (see [Accountless breadcrumb](#accountless-breadcrumb)). Only applies when the run resolves to accountless — errors otherwise; cannot be combined with `--login` | @@ -46,25 +46,23 @@ When running in agent mode (`--mode agent` or non-TTY), the command runs the ful - For **existing projects**: framework and package manager are auto-detected, no flags required - For **new projects** (`--starter` or blank directory): `--framework` is required (no way to auto-detect in an empty dir). Package manager is auto-selected by availability (bun → pnpm → yarn → npm) unless `--pm` is provided - Project name defaults to the framework's default (e.g. `my-clerk-next-app`) unless `--name` is provided -- For accountless-capable frameworks with no `--app` and no linked profile: +- 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 uses accountless: the app runs on auto-generated dev keys, and init writes a legacy-named `.clerk/keyless.json` breadcrumb so the next `clerk auth login` claims the app automatically. -- For frameworks that require API keys, init will not pick or create an app in agent mode; pass `--app ` or link the project first to pull real keys - `--login` while unauthenticated exits with a usage error (agents can't complete the interactive browser login) -- Agent mode never trusts the mere _presence_ of a stored credential the way human mode does — a stored session that turns out to be expired/broken (e.g. keyring holds a stale OAuth session) is validated before init decides it's "authenticated". A broken credential is treated as unauthenticated, which routes an accountless-capable framework to accountless instead of blocking on a browser OAuth round-trip an agent can never complete. If `--login` (or a real app target) forces the authenticated flow anyway and the credential turns out broken, init exits with a usage error instead of attempting an interactive login +- Agent mode never trusts the mere _presence_ of a stored credential the way human mode does — a stored session that turns out to be expired/broken (e.g. keyring holds a stale OAuth session) is validated before init decides it's "authenticated". A broken credential is treated as unauthenticated, which routes the run to accountless instead of blocking on a browser OAuth round-trip an agent can never complete. If `--login` (or a real app target) forces the authenticated flow anyway and the credential turns out broken, init exits with a usage error instead of attempting an interactive login - Agent mode never mints a fresh accountless application over an existing unclaimed one on re-run — see [Accountless breadcrumb](#accountless-breadcrumb) ## Flow 1. Gathers project context (framework, router variant, TypeScript, `src/` directory, package manager) 2. Determines the strategy (in precedence order). In agent mode, "authenticated" here means a _validated_ credential (a real `CLERK_PLATFORM_API_KEY`, or a stored session that still exchanges for a valid token) — not just the presence of something in the keyring, since agent mode has no interactive fallback if a stale credential turns out to be unusable: - - **`--accountless`**: forces accountless mode, even when logged in. Only valid on an accountless-capable framework, and cannot be combined with `--login` or `--app` (usage errors otherwise). The app runs on auto-generated dev keys; init writes a legacy-named `.clerk/keyless.json` breadcrumb so the next `clerk auth login` claims the app automatically + - **`--accountless`**: forces accountless mode, even when logged in. Cannot be combined with `--login` or `--app` (usage errors otherwise). The app runs on auto-generated dev keys; init writes a legacy-named `.clerk/keyless.json` breadcrumb so the next `clerk auth login` claims the app automatically - **`--login`**: forces the authenticated flow. In agent mode while unauthenticated (or while stored credentials are broken) this exits with a usage error, since agents can't complete the interactive browser login - **Real app target** (`--app` or linked profile): authenticates, links if needed, and pulls real API keys into `.env` - - **Agent + non-accountless framework + no real app target**: scaffolds locally and prints manual setup instructions instead of selecting or creating an app - - **Agent + accountless-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 + accountless-capable framework + unauthenticated + no real app target**: uses accountless mode — the app runs on auto-generated dev keys and the breadcrumb lets the next `clerk auth login` claim it. A broken/stale stored credential (present in the keyring but no longer valid) is treated the same as unauthenticated, so this is also the fallback when the presence-only check would have wrongly said "authenticated" - - **Human mode + bootstrap + accountless-capable framework + not authenticated**: uses accountless mode + - **Agent + 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**: uses accountless mode — the app runs on auto-generated dev keys and the breadcrumb lets the next `clerk auth login` claim it. A broken/stale stored credential (present in the keyring but no longer valid) is treated the same as unauthenticated, so this is also the fallback when the presence-only check would have wrongly said "authenticated" + - **Human mode + bootstrap + not authenticated**: uses accountless mode - **Human mode + existing project + not authenticated**: runs the authenticated flow, which triggers an interactive login so real keys can be pulled. `-y` does not bypass this — it only suppresses y/n confirmation prompts, not authentication - `--template` and `--fresh` are rejected with a usage error whenever the resolved strategy above isn't accountless — see [Application templates](#application-templates) and [Accountless breadcrumb](#accountless-breadcrumb) 3. **Authenticated mode only**: authenticates via `clerk auth login` (skipped if already authenticated) and links the project via `clerk link` (skipped if already linked) @@ -86,19 +84,19 @@ When running in agent mode (`--mode agent` or non-TTY), the command runs the ful Detects the project's framework from `package.json` dependencies (checked top-to-bottom, first match wins): -| Dependency | Framework | Clerk SDK | Publishable Key Env Var | Accountless | -| ----------------------- | -------------- | ----------------------------- | ----------------------------------- | ----------- | -| `next` | Next.js | `@clerk/nextjs` | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Yes | -| `astro` | Astro | `@clerk/astro` | `PUBLIC_CLERK_PUBLISHABLE_KEY` | Yes | -| `nuxt` | Nuxt | `@clerk/nuxt` | `NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Yes | -| `@tanstack/react-start` | TanStack Start | `@clerk/tanstack-react-start` | `VITE_CLERK_PUBLISHABLE_KEY` | Yes | -| `react-router` | React Router | `@clerk/react-router` | `VITE_CLERK_PUBLISHABLE_KEY` | Yes | -| `vue` | Vue | `@clerk/vue` | `VITE_CLERK_PUBLISHABLE_KEY` | No | -| `expo` | Expo | `@clerk/expo` | `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` | No | -| `react` | React | `@clerk/react` | `VITE_CLERK_PUBLISHABLE_KEY` | No | -| `vite` | JavaScript | `@clerk/clerk-js` | `VITE_CLERK_PUBLISHABLE_KEY` | No | -| `express` | Express | `@clerk/express` | `CLERK_PUBLISHABLE_KEY` | No | -| `fastify` | Fastify | `@clerk/fastify` | `CLERK_PUBLISHABLE_KEY` | No | +| Dependency | Framework | Clerk SDK | Publishable Key Env Var | +| ----------------------- | -------------- | ----------------------------- | ----------------------------------- | +| `next` | Next.js | `@clerk/nextjs` | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | +| `astro` | Astro | `@clerk/astro` | `PUBLIC_CLERK_PUBLISHABLE_KEY` | +| `nuxt` | Nuxt | `@clerk/nuxt` | `NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | +| `@tanstack/react-start` | TanStack Start | `@clerk/tanstack-react-start` | `VITE_CLERK_PUBLISHABLE_KEY` | +| `react-router` | React Router | `@clerk/react-router` | `VITE_CLERK_PUBLISHABLE_KEY` | +| `vue` | Vue | `@clerk/vue` | `VITE_CLERK_PUBLISHABLE_KEY` | +| `expo` | Expo | `@clerk/expo` | `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` | +| `react` | React | `@clerk/react` | `VITE_CLERK_PUBLISHABLE_KEY` | +| `vite` | JavaScript | `@clerk/clerk-js` | `VITE_CLERK_PUBLISHABLE_KEY` | +| `express` | Express | `@clerk/express` | `CLERK_PUBLISHABLE_KEY` | +| `fastify` | Fastify | `@clerk/fastify` | `CLERK_PUBLISHABLE_KEY` | Native mobile platforms may not have a `package.json`, so they are detected from project marker files when no npm framework matches: @@ -109,7 +107,9 @@ Native mobile platforms may not have a `package.json`, so they are detected from A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. For native platforms the Clerk SDK cannot be installed by a JS package manager, so init skips the SDK install step and the scaffold plan prints Swift Package Manager / Gradle install steps instead. The publishable key is configured in source code (`Clerk.configure(...)` / `Clerk.initialize(...)`), so init still pulls keys into the env file and instructs the user to copy the key over. -The **Accountless** column indicates whether the framework's Clerk SDK supports accountless mode (auto-generated temporary dev keys). Accountless 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. `--accountless` forces accountless 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 an accountless-capable framework creates a real app named after the project and links it. +Accountless mode (auto-generated temporary dev keys) works on every framework above, native platforms included: the CLI mints the application itself via `POST /v1/accountless_applications` and only needs the framework's env var names. It is the default for unauthenticated runs — 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. `--accountless` forces accountless anywhere (existing projects included, even when logged in). In agent mode, an authenticated run creates a real app named after the project and links it. + +iOS and Android get only the publishable key in `.env` — their default `.gitignore` doesn't cover it, the same reason `clerk env pull` never writes the secret key there. The secret key becomes available once the app is claimed. Package manager is detected from lock files: `bun.lockb`/`bun.lock` → bun, `yarn.lock` → yarn, `pnpm-lock.yaml` → pnpm, else npm. @@ -291,7 +291,7 @@ See [auth/README.md](../auth/README.md), [link/README.md](../link/README.md), an | `native` | Native/mobile application | | `waitlist` | Waitlist sign-up mode | -The template only applies when a _new_ application is actually created, so `--template` is rejected with a usage error whenever the resolved strategy isn't accountless — whether that's because of an explicit conflicting flag (`--login`, or `--app` once the strategy resolves) or because the run is simply already authenticated (e.g. `CLERK_PLATFORM_API_KEY` is set) or the framework doesn't support accountless at all. The error names the reason, so `--template` is never silently dropped: add `--accountless` to force an accountless app, or drop `--template`. Settings can still be changed afterwards with `clerk config patch`, which also works without an account (see [config accountless mode](../config/README.md#accountless-mode)). +The template only applies when a _new_ application is actually created, so `--template` is rejected with a usage error whenever the resolved strategy isn't accountless — whether that's because of an explicit conflicting flag (`--login`, or `--app` once the strategy resolves) or because the run is simply already authenticated (e.g. `CLERK_PLATFORM_API_KEY` is set). The error names the reason, so `--template` is never silently dropped: add `--accountless` to force an accountless app, or drop `--template`. Settings can still be changed afterwards with `clerk config patch`, which also works without an account (see [config accountless mode](../config/README.md#accountless-mode)). ## Accountless breadcrumb diff --git a/packages/cli-core/src/commands/init/bootstrap-registry.ts b/packages/cli-core/src/commands/init/bootstrap-registry.ts index 8f66a6933..4bf35f367 100644 --- a/packages/cli-core/src/commands/init/bootstrap-registry.ts +++ b/packages/cli-core/src/commands/init/bootstrap-registry.ts @@ -27,8 +27,8 @@ function runner(pm: PackageManager): string[] { // create-astro) infer the PM from npm_config_user_agent, which is set automatically when run // via bunx/pnpm dlx/yarn dlx/npx. This works in practice since we run them via the selected // PM's runner, and our own installDependencies() step uses the correct PM regardless. -/** Frameworks that support keyless mode — used for bootstrap (new project from empty dir / --starter). */ -export const BOOTSTRAP_KEYLESS_REGISTRY: BootstrapEntry[] = [ +/** Frameworks `clerk init` can bootstrap (new project from an empty dir / --starter). */ +export const BOOTSTRAP_REGISTRY: BootstrapEntry[] = [ { label: "Next.js", dep: "next", @@ -105,10 +105,6 @@ export const BOOTSTRAP_KEYLESS_REGISTRY: BootstrapEntry[] = [ pm, ], }, -]; - -/** Frameworks that require API keys — keyless mode is not yet supported. */ -export const BOOTSTRAP_AUTHENTICATED_REGISTRY: BootstrapEntry[] = [ { label: "React", dep: "react", @@ -158,12 +154,6 @@ export const BOOTSTRAP_AUTHENTICATED_REGISTRY: BootstrapEntry[] = [ }, ]; -/** All bootstrap-capable frameworks (keyless + authenticated). */ -export const BOOTSTRAP_REGISTRY: BootstrapEntry[] = [ - ...BOOTSTRAP_KEYLESS_REGISTRY, - ...BOOTSTRAP_AUTHENTICATED_REGISTRY, -]; - // Hardening flags come from PM_INSTALL_HARDENING_FLAGS (see package-manager.ts // for the threat model). `clerk init --starter` runs this in projectDir, // which is freshly scaffolded from an official template but lives inside the diff --git a/packages/cli-core/src/commands/init/frameworks/android.ts b/packages/cli-core/src/commands/init/frameworks/android.ts index 13432d678..fafac40be 100644 --- a/packages/cli-core/src/commands/init/frameworks/android.ts +++ b/packages/cli-core/src/commands/init/frameworks/android.ts @@ -7,8 +7,8 @@ import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js * publishable key is configured in Kotlin source (`Clerk.initialize(...)`), * not an env file. Gradle files are user-managed build scripts with too many * layout variants (Groovy/Kotlin DSL, version catalogs) to modify safely, so - * this scaffolder prints the exact quickstart steps; `clerk init` still links - * the app and pulls real keys so the user can copy the publishable key. + * this scaffolder prints the exact quickstart steps; `clerk init` still puts a + * publishable key in the env file (real or accountless) for the user to copy. * * Docs: https://clerk.com/docs/android/getting-started/quickstart */ @@ -25,7 +25,7 @@ export const android: FrameworkScaffold = { 'Add the Clerk Android SDK to app/build.gradle.kts: `implementation("com.clerk:clerk-android-ui:")` and `implementation("androidx.lifecycle:lifecycle-viewmodel-compose:")` (requires minSdk 24+ and Java 17+; latest version: https://github.com/clerk/clerk-android/releases)', "Enable the Native API and register your Android app on the Native Applications page: https://dashboard.clerk.com/~/native-applications", 'Add `` to AndroidManifest.xml and register an Application subclass via `android:name`', - `Initialize Clerk in your Application subclass: \`Clerk.initialize(this, publishableKey = "")\` — copy CLERK_PUBLISHABLE_KEY from ${ctx.envFile} after \`clerk env pull\``, + `Initialize Clerk in your Application subclass: \`Clerk.initialize(this, publishableKey = "")\` — copy CLERK_PUBLISHABLE_KEY from ${ctx.envFile}`, "Full setup guide: https://clerk.com/docs/android/getting-started/quickstart", ], }; diff --git a/packages/cli-core/src/commands/init/frameworks/expo.ts b/packages/cli-core/src/commands/init/frameworks/expo.ts index 0cefa316a..8d9bd4b6c 100644 --- a/packages/cli-core/src/commands/init/frameworks/expo.ts +++ b/packages/cli-core/src/commands/init/frameworks/expo.ts @@ -267,7 +267,7 @@ export const expo: FrameworkScaffold = { } postInstructions.push( - `Ensure ${ctx.framework.envVar} is set in your ${ctx.envFile} (pulled via \`clerk env pull\`)`, + `Ensure ${ctx.framework.envVar} is set in your ${ctx.envFile}`, `Add sign-in and sign-up screens, and enable the Native API at https://dashboard.clerk.com/~/native-applications — see: ${EXPO_QUICKSTART_URL}`, ); diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 0562a84e5..318122f71 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -7,8 +7,8 @@ import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js * configured in Swift source (`Clerk.configure(publishableKey:)`), not an env * file — and adding an SPM dependency requires editing the Xcode project * bundle, which is not safe to automate. So instead of writing files, this - * scaffolder prints the exact quickstart steps; `clerk init` still links the - * app and pulls real keys so the user can copy the publishable key. + * scaffolder prints the exact quickstart steps; `clerk init` still puts a + * publishable key in the env file (real or accountless) for the user to copy. * * Docs: https://clerk.com/docs/ios/getting-started/quickstart */ @@ -25,7 +25,7 @@ export const ios: FrameworkScaffold = { "Add the Clerk iOS SDK via Swift Package Manager: https://github.com/clerk/clerk-ios (add both ClerkKit and ClerkKitUI to your target)", "Enable the Native API and register your iOS app (App ID Prefix + Bundle ID) on the Native Applications page: https://dashboard.clerk.com/~/native-applications", "In Xcode, add the Associated Domains capability with `webcredentials:`", - `Configure Clerk in your @main App struct: \`Clerk.configure(publishableKey: "")\` — copy CLERK_PUBLISHABLE_KEY from ${ctx.envFile} after \`clerk env pull\``, + `Configure Clerk in your @main App struct: \`Clerk.configure(publishableKey: "")\` — copy CLERK_PUBLISHABLE_KEY from ${ctx.envFile}`, "Inject Clerk into the SwiftUI environment so views can read it via `@Environment(Clerk.self)`: `ContentView().environment(Clerk.shared)`", "Full setup guide: https://clerk.com/docs/ios/getting-started/quickstart", ], diff --git a/packages/cli-core/src/commands/init/frameworks/javascript.ts b/packages/cli-core/src/commands/init/frameworks/javascript.ts index acd40c225..d89c3a938 100644 --- a/packages/cli-core/src/commands/init/frameworks/javascript.ts +++ b/packages/cli-core/src/commands/init/frameworks/javascript.ts @@ -80,9 +80,7 @@ export const javascriptVite: FrameworkScaffold = { ); } - postInstructions.push( - `Ensure ${ctx.framework.envVar} is set in your ${ctx.envFile} (pulled via \`clerk env pull\`)`, - ); + postInstructions.push(`Ensure ${ctx.framework.envVar} is set in your ${ctx.envFile}`); return { actions, postInstructions }; }, diff --git a/packages/cli-core/src/commands/init/frameworks/node-server.ts b/packages/cli-core/src/commands/init/frameworks/node-server.ts index 7cd159aa6..a75e49043 100644 --- a/packages/cli-core/src/commands/init/frameworks/node-server.ts +++ b/packages/cli-core/src/commands/init/frameworks/node-server.ts @@ -184,7 +184,7 @@ export async function scaffoldServerFramework( actions: action ? [action] : [], postInstructions: [ ...(wired ? [] : [`${config.manualWiring} See: ${config.docsUrl}`]), - `Ensure ${ctx.framework.envVar} and CLERK_SECRET_KEY are set in your ${ctx.envFile} (pulled via \`clerk env pull\`), and load them before Clerk imports — e.g. \`node --env-file=${ctx.envFile} ${entryPath ?? "index.js"}\``, + `Ensure ${ctx.framework.envVar} and CLERK_SECRET_KEY are set in your ${ctx.envFile}, and load them before Clerk imports — e.g. \`node --env-file=${ctx.envFile} ${entryPath ?? "index.js"}\``, `Protect routes with \`getAuth()\` and \`clerkClient\`: ${config.docsUrl}`, ], }; diff --git a/packages/cli-core/src/commands/init/frameworks/react.ts b/packages/cli-core/src/commands/init/frameworks/react.ts index e697399fb..8ce562541 100644 --- a/packages/cli-core/src/commands/init/frameworks/react.ts +++ b/packages/cli-core/src/commands/init/frameworks/react.ts @@ -80,9 +80,7 @@ export const reactVite: FrameworkScaffold = { ); } - postInstructions.push( - `Ensure ${ctx.framework.envVar} is set in your ${ctx.envFile} (pulled via \`clerk env pull\`)`, - ); + postInstructions.push(`Ensure ${ctx.framework.envVar} is set in your ${ctx.envFile}`); return { actions, postInstructions }; }, diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index e45a102a5..c4f67eed1 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -302,7 +302,6 @@ describe("init", () => { sdk: "@clerk/nextjs", envVar: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", envFile: ".env.local" as const, - supportsKeyless: true, }; setup(); spyOn(context, "hasPackageJson").mockResolvedValue(false); @@ -338,7 +337,6 @@ describe("init", () => { sdk: "@clerk/nextjs", envVar: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", envFile: ".env.local" as const, - supportsKeyless: true, }; setup(); spyOn(context, "hasPackageJson").mockResolvedValue(false); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 10f8e7ef1..eaef6f508 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -150,10 +150,9 @@ export async function init(options: InitOptions = {}) { authed, isBootstrap: bootstrap != null, hasRealAppTarget, - framework: ctx.framework, }); - assertKeylessOnlyFlags(options, strategy, Boolean(ctx.framework.supportsKeyless)); + assertKeylessOnlyFlags(options, strategy); if (strategy === "authenticate") { setTelemetryStage("link"); @@ -174,9 +173,6 @@ export async function init(options: InitOptions = {}) { if (alreadySetUp) { setTelemetryStage("already_set_up"); log.success("\nClerk is already set up in this project."); - if (agent && strategy === "manual") { - printBootstrapManualSetupInfo(ctx.framework); - } await outro("Done"); return; } @@ -269,28 +265,15 @@ async function isAuthenticatedForAgent(): Promise { * resolution because that's the earliest point the real strategy — not just * the flags that might influence it — is known. */ -function assertKeylessOnlyFlags( - options: InitOptions, - strategy: InitStrategy, - supportsAccountless: boolean, -): void { +function assertKeylessOnlyFlags(options: InitOptions, strategy: InitStrategy): void { if (strategy === "keyless") return; - // "Add --accountless" is only valid remediation when accountless setup is - // actually reachable from here — not when the framework doesn't support it - // or when --app/--login are what forced the authenticated flow (both - // conflict with --accountless in assertUsableFlags above). Framework - // support is checked directly, not via strategy: an unsupported framework - // resolves to "manual" only in agent mode — in human mode it resolves to - // "authenticate", which would otherwise suggest an --accountless flag the - // framework rejects. + // "Add --accountless" is only valid remediation when --app/--login aren't + // what forced the authenticated flow (both conflict with --accountless in + // assertUsableFlags above). let reason: string; - // Null when dropping the offending flag is the only remediation. - let remedy: string | null; - if (!supportsAccountless) { - reason = "this framework does not support accountless setup"; - remedy = null; - } else if (options.app) { + let remedy: string; + if (options.app) { reason = "--app was set, which cannot be combined with --accountless"; remedy = "drop --app to allow accountless setup"; } else if (options.login) { @@ -302,15 +285,14 @@ function assertKeylessOnlyFlags( remedy = "add --accountless to force an accountless app"; } - const tail = (flag: string): string => (remedy ? `${remedy}, or drop ${flag}.` : `drop ${flag}.`); if (options.template) { throwUsageError( - `--template only applies to accountless applications, but ${reason}; ${tail("--template")}`, + `--template only applies to accountless applications, but ${reason}; ${remedy}, or drop --template.`, ); } if (options.fresh) { throwUsageError( - `--fresh only applies to accountless applications, but ${reason}; ${tail("--fresh")}`, + `--fresh only applies to accountless applications, but ${reason}; ${remedy}, or drop --fresh.`, ); } } @@ -403,29 +385,16 @@ function printBootstrapNextSteps( printNextSteps(steps); } -function printBootstrapManualSetupInfo(framework: FrameworkInfo): void { - // Only reachable for frameworks without accountless support: 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")); -} - // --- Strategy --- -type InitStrategy = "keyless" | "manual" | "authenticate"; +type InitStrategy = "keyless" | "authenticate"; -// Picks how `clerk init` will reach a working Clerk setup: +// Picks how `clerk init` will reach a working Clerk setup. The CLI mints the accountless app +// itself and only needs the framework's env var names, so every framework qualifies: // - "keyless" → temporary development keys, no login. Forced via `--accountless`, or the default -// for unauthenticated runs on a keyless-capable framework (human bootstrap and -// all agent runs). A legacy `.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. +// for unauthenticated runs (human bootstrap and all agent runs). A legacy +// `.clerk/keyless.json` breadcrumb lets the next `clerk auth login` claim the app +// automatically. // - "authenticate" → log in (interactively if needed) and link a real Clerk application. Forced // via `--login`, and the default whenever accountless setup doesn't apply. function pickStrategy({ @@ -435,7 +404,6 @@ function pickStrategy({ authed, isBootstrap, hasRealAppTarget, - framework, }: { optsAccountless: boolean; optsLogin: boolean; @@ -443,19 +411,10 @@ function pickStrategy({ authed: boolean; isBootstrap: boolean; hasRealAppTarget: boolean; - framework: FrameworkInfo; }): InitStrategy { - if (optsAccountless) { - if (!framework.supportsKeyless) { - throwUsageError( - `--accountless is not supported for ${framework.name}. Run \`clerk auth login\` and use \`clerk init --app \` instead.`, - ); - } - return "keyless"; - } + if (optsAccountless) return "keyless"; if (optsLogin || hasRealAppTarget) return "authenticate"; - if (agent && !framework.supportsKeyless) return "manual"; - if (!authed && framework.supportsKeyless && (agent || isBootstrap)) return "keyless"; + if (!authed && (agent || isBootstrap)) return "keyless"; return "authenticate"; } @@ -473,9 +432,6 @@ async function runStrategy( keylessOptions: KeylessRunOptions, ): Promise { switch (strategy) { - case "manual": - printBootstrapManualSetupInfo(ctx.framework); - return; case "authenticate": await pull({ file: ctx.envFile, cwd: ctx.cwd }); return; @@ -692,10 +648,7 @@ export function registerInit(program: Program): void { .option("--name ", "Project name for --starter (skips prompt)") .option("--app ", "Application ID to link (skips interactive picker)") .option("--starter", "Create a new project from a starter template") - .option( - "--accountless", - "Force accountless development keys, even when logged in (only for supported frameworks)", - ) + .option("--accountless", "Force accountless development keys, even when logged in") .addOption(createOption("--keyless", "Deprecated alias for --accountless").hideHelp()) .option( "--login", diff --git a/packages/cli-core/src/commands/init/strategy.test.ts b/packages/cli-core/src/commands/init/strategy.test.ts index 07cabe23a..7c0d9181c 100644 --- a/packages/cli-core/src/commands/init/strategy.test.ts +++ b/packages/cli-core/src/commands/init/strategy.test.ts @@ -23,13 +23,31 @@ import { keylessTargetMod, } from "../../test/lib/init-harness.ts"; import * as promptsMod from "../../lib/prompts.ts"; +import { FRAMEWORK_MAP, NATIVE_FRAMEWORK_MAP } from "../../lib/framework.ts"; import { init } from "./index.ts"; const EXISTING_BREADCRUMB = { claimToken: "tok_existing", createdAt: "2024-01-01T00:00:00.000Z" }; +// Vue was gated off accountless while minting was going to be delegated to the +// SDKs; it is the regression case for "every framework". +const VUE_CTX: FakeCtx = { + ...FAKE_CTX, + existingClerk: false, + framework: { + dep: "vue", + name: "Vue", + sdk: "@clerk/vue", + envVar: "VITE_CLERK_PUBLISHABLE_KEY", + envFile: ".env.local", + }, + envFile: ".env.local", +}; + +const ALL_FRAMEWORKS = [...FRAMEWORK_MAP, ...NATIVE_FRAMEWORK_MAP]; + describe("init strategy", () => { - const { setup, setupBootstrapSuccess, track } = useInitHarness(); - test("blank dir with keyless framework defaults to keyless when unauthenticated", async () => { + const { setup, track } = useInitHarness(); + test("blank dir defaults to keyless when unauthenticated", async () => { setup(); mockBootstrapTo(KEYLESS_CTX); mockMiddlewareScaffold(); @@ -102,7 +120,7 @@ describe("init strategy", () => { expect(linkMod.link).not.toHaveBeenCalled(); }); - test("--accountless on a supported framework uses accountless mode without logging in", async () => { + test("--accountless uses accountless mode without logging in", async () => { setup(); mockBootstrapTo(KEYLESS_CTX); mockMiddlewareScaffold(); @@ -150,30 +168,26 @@ describe("init strategy", () => { expect(pullMod.pull).not.toHaveBeenCalled(); }); - test("--accountless on an unsupported 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({ accountless: true })).rejects.toThrow( - /--accountless is not supported for Vue/, - ); - expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); - expect(linkMod.link).not.toHaveBeenCalled(); - }); + // Every framework resolves to accountless the same way: the CLI mints the app + // itself and only needs the framework's env var names, so no SDK-level + // "keyless support" is involved. + test.each(ALL_FRAMEWORKS.map((fw) => [fw.dep, fw] as const))( + "unauthenticated agent init on %s goes accountless without logging in", + async (_dep, framework) => { + setup({ isAgent: true, email: null }); + mockExistingProject({ ...FAKE_CTX, existingClerk: false, framework }); + mockMiddlewareScaffold(); + + await init({}); + + expect(keylessMod.createAccountlessApp).toHaveBeenCalledWith(framework.dep, undefined); + expect(keylessMod.writeKeylessBreadcrumb).toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + }, + ); - test("--accountless on an existing supported project uses accountless mode", async () => { + test("--accountless on an existing project uses accountless mode", async () => { setup(); mockExistingProject(KEYLESS_CTX); mockMiddlewareScaffold(); @@ -187,7 +201,7 @@ describe("init strategy", () => { expect(loginMod.login).not.toHaveBeenCalled(); }); - test("bootstrap with keyless framework goes authenticated when already signed in", async () => { + test("bootstrap goes authenticated when already signed in", async () => { setup({ email: "user@example.com" }); mockBootstrapTo({ ...KEYLESS_CTX, existingClerk: true }); @@ -198,7 +212,7 @@ describe("init strategy", () => { expect(linkMod.link).toHaveBeenCalled(); }); - test("-y flag with keyless framework uses authenticated flow when signed in", async () => { + test("-y flag uses authenticated flow when signed in", async () => { setup({ email: "user@example.com" }); mockBootstrapTo({ ...KEYLESS_CTX, existingClerk: true }); @@ -208,7 +222,7 @@ describe("init strategy", () => { expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); }); - test("-y flag with keyless framework uses authenticated flow when CLERK_PLATFORM_API_KEY is set", async () => { + test("-y flag uses authenticated flow when CLERK_PLATFORM_API_KEY is set", async () => { setup({ apiKey: true }); mockBootstrapTo({ ...KEYLESS_CTX, existingClerk: true }); @@ -219,7 +233,7 @@ describe("init strategy", () => { expect(linkMod.link).toHaveBeenCalled(); }); - test("-y flag with keyless framework stays keyless when unauthenticated", async () => { + test("-y flag stays keyless when unauthenticated", async () => { // `-y` only skips y/n confirmations — it neither forces nor bypasses the // keyless default for unauthenticated bootstrap. setup(); @@ -234,7 +248,7 @@ describe("init strategy", () => { expect(linkMod.link).not.toHaveBeenCalled(); }); - test("-y --accountless with a supported framework uses accountless mode", async () => { + test("-y --accountless uses accountless mode", async () => { setup(); mockBootstrapTo(KEYLESS_CTX); mockMiddlewareScaffold(); @@ -246,7 +260,7 @@ describe("init strategy", () => { expect(loginMod.login).not.toHaveBeenCalled(); }); - test("agent mode with keyless framework uses keyless with breadcrumb when unauthenticated", async () => { + test("agent mode 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. @@ -299,7 +313,7 @@ describe("init strategy", () => { expect(keylessMod.createAccountlessApp).toHaveBeenCalled(); }); - test("agent mode with keyless framework + authed creates and links a real app", async () => { + test("agent mode + 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 @@ -319,7 +333,7 @@ describe("init strategy", () => { 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 () => { + test("agent mode uses linked profile as a real app target", async () => { setup({ isAgent: true, email: "user@example.com" }); mockExistingProject(KEYLESS_CTX); spyOn(config, "resolveProfile").mockResolvedValue({ @@ -334,7 +348,7 @@ describe("init strategy", () => { expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: KEYLESS_CTX.cwd }); }); - test("agent mode with keyless framework and --app uses real app flow", async () => { + test("agent mode with --app uses real app flow", async () => { setup({ isAgent: true, email: "user@example.com" }); mockExistingProject(KEYLESS_CTX); mockMiddlewareScaffold(); @@ -351,35 +365,6 @@ describe("init strategy", () => { 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); @@ -395,50 +380,22 @@ describe("init strategy", () => { }); }); - test("-y flag triggers login when unauthenticated", async () => { + test("-y on an unauthenticated bootstrap stays accountless on a formerly gated framework", async () => { setup(); - setupBootstrapSuccess(); + mockBootstrapTo(VUE_CTX); + mockMiddlewareScaffold(); 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 }); + expect(keylessMod.createAccountlessApp).toHaveBeenCalledWith("vue", undefined); + expect(heuristics.printKeylessInfo).toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); }); - 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 () => { + test("existing repo 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(context, "gatherContext").mockResolvedValue(FAKE_CTX); spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never); await init({ yes: true }); @@ -448,19 +405,13 @@ describe("init strategy", () => { expect(heuristics.printKeylessInfo).not.toHaveBeenCalled(); }); - test("existing repo with keyless framework uses authenticated flow when not signed in", async () => { + test("existing repo 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(context, "gatherContext").mockResolvedValue(KEYLESS_CTX); spyOn(scaffoldMod, "scaffold").mockResolvedValue({ actions: [{ type: "create", path: "middleware.ts", content: "", description: "" }], postInstructions: [], @@ -695,47 +646,15 @@ describe("init strategy", () => { ); }); - test("--template on a non-keyless framework in agent mode names the missing keyless support", async () => { - setup({ isAgent: true, email: "user@example.com" }); - const nonKeylessCtx: FakeCtx = { - ...FAKE_CTX, - existingClerk: false, - framework: { - dep: "vue", - name: "Vue", - sdk: "@clerk/vue", - envVar: "VITE_CLERK_PUBLISHABLE_KEY", - envFile: ".env.local", - }, - envFile: ".env.local", - }; - spyOn(context, "gatherContext").mockResolvedValue(nonKeylessCtx); - - await expect(init({ template: "b2b-saas" })).rejects.toThrow( - /does not support accountless setup/, - ); - }); - - test("--template on a non-keyless framework in human mode names the missing keyless support", async () => { + test("--template on an unauthenticated existing project in human mode points at --accountless", async () => { + // Human mode resolves an unauthenticated re-run on an existing project to + // the authenticated flow, so the guard fires before any login — and + // --accountless is valid remediation on every framework. setup({ email: null }); - const nonKeylessCtx: FakeCtx = { - ...FAKE_CTX, - existingClerk: false, - framework: { - dep: "vue", - name: "Vue", - sdk: "@clerk/vue", - envVar: "VITE_CLERK_PUBLISHABLE_KEY", - envFile: ".env.local", - }, - envFile: ".env.local", - }; - spyOn(context, "gatherContext").mockResolvedValue(nonKeylessCtx); - - // Human mode resolves an unsupported framework to the authenticated - // flow, so the guard must not suggest --accountless here. + mockExistingProject(VUE_CTX); + await expect(init({ template: "b2b-saas" })).rejects.toThrow( - /does not support accountless setup/, + /add --accountless to force an accountless app/, ); expect(loginMod.login).not.toHaveBeenCalled(); }); diff --git a/packages/cli-core/src/lib/framework.ts b/packages/cli-core/src/lib/framework.ts index 61f22481b..8d6dfa828 100644 --- a/packages/cli-core/src/lib/framework.ts +++ b/packages/cli-core/src/lib/framework.ts @@ -27,10 +27,6 @@ export interface FrameworkInfo { * with a `.env.local` convention use it (always gitignored, per-machine * overrides); frameworks without that convention fall back to `.env`. */ envFile: ".env" | ".env.local"; - /** When true, the framework's Clerk SDK supports keyless mode (auto-generated - * temporary dev keys). Frameworks without keyless support require API keys - * and must authenticate during `clerk init`. */ - supportsKeyless?: boolean; /** SDK distribution ecosystem. Defaults to "npm" when omitted. */ ecosystem?: FrameworkEcosystem; } @@ -47,7 +43,6 @@ export const FRAMEWORK_MAP: FrameworkInfo[] = [ sdk: "@clerk/nextjs", envVar: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", envFile: ".env.local", - supportsKeyless: true, }, { dep: "astro", @@ -55,7 +50,6 @@ export const FRAMEWORK_MAP: FrameworkInfo[] = [ sdk: "@clerk/astro", envVar: "PUBLIC_CLERK_PUBLISHABLE_KEY", envFile: ".env", - supportsKeyless: true, }, { dep: "nuxt", @@ -64,7 +58,6 @@ export const FRAMEWORK_MAP: FrameworkInfo[] = [ envVar: "NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY", secretKeyEnvVar: "NUXT_CLERK_SECRET_KEY", envFile: ".env", - supportsKeyless: true, }, { dep: "@tanstack/react-start", @@ -72,7 +65,6 @@ export const FRAMEWORK_MAP: FrameworkInfo[] = [ sdk: "@clerk/tanstack-react-start", envVar: "VITE_CLERK_PUBLISHABLE_KEY", envFile: ".env.local", - supportsKeyless: true, }, { dep: "react-router", @@ -80,7 +72,6 @@ export const FRAMEWORK_MAP: FrameworkInfo[] = [ sdk: "@clerk/react-router", envVar: "VITE_CLERK_PUBLISHABLE_KEY", envFile: ".env.local", - supportsKeyless: true, }, { dep: "vue", diff --git a/packages/cli-core/src/lib/keyless.test.ts b/packages/cli-core/src/lib/keyless.test.ts index 4c781b13a..03be2e1c0 100644 --- a/packages/cli-core/src/lib/keyless.test.ts +++ b/packages/cli-core/src/lib/keyless.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { stubFetch, useCaptureLog } from "../test/lib/stubs.ts"; @@ -167,6 +167,21 @@ describe("writeKeysToEnvFile", () => { expect(content).toContain("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_next"); expect(content).toContain("CLERK_SECRET_KEY=sk_test_next"); }); + + test("native framework (iOS) gets only the publishable key, in .env", async () => { + // Native .gitignore templates don't cover .env, so the secret key must not + // land there — the same rule `clerk env pull` applies. + await mkdir(join(tempDir, "MyApp.xcodeproj"), { recursive: true }); + await writeKeysToEnvFile(tempDir, { + publishableKey: "pk_test_ios", + secretKey: "sk_test_ios", + }); + + const content = await Bun.file(join(tempDir, ".env")).text(); + expect(content).toContain("CLERK_PUBLISHABLE_KEY=pk_test_ios"); + expect(content).not.toContain("sk_test_ios"); + expect(await Bun.file(join(tempDir, ".env.local")).exists()).toBe(false); + }); }); describe("createAccountlessApp", () => { diff --git a/packages/cli-core/src/lib/keyless.ts b/packages/cli-core/src/lib/keyless.ts index e8c769e2b..2b24e4f49 100644 --- a/packages/cli-core/src/lib/keyless.ts +++ b/packages/cli-core/src/lib/keyless.ts @@ -1,7 +1,13 @@ import { join } from "node:path"; import { mkdir, unlink } from "node:fs/promises"; import { getBapiBaseUrl } from "./environment.ts"; -import { detectPublishableKeyName, detectSecretKeyName, detectEnvFile } from "./framework.ts"; +import { + detectPublishableKeyName, + detectSecretKeyName, + detectEnvFile, + detectFramework, + isNpmFramework, +} from "./framework.ts"; import { parseEnvFile, mergeEnvVars, serializeEnvFile } from "./dotenv.ts"; import { BapiError } from "./errors.ts"; import { loggedFetch } from "./fetch.ts"; @@ -81,10 +87,11 @@ export async function writeKeysToEnvFile( cwd: string, keys: { publishableKey: string; secretKey: string }, ): Promise { - const [publishableKeyName, secretKeyName, envFile] = await Promise.all([ + const [publishableKeyName, secretKeyName, envFile, framework] = await Promise.all([ detectPublishableKeyName(cwd), detectSecretKeyName(cwd), detectEnvFile(cwd), + detectFramework(cwd), ]); const targetFile = join(cwd, envFile); @@ -92,9 +99,14 @@ export async function writeKeysToEnvFile( .text() .catch(() => ""); + // Native platforms (iOS/Android) configure Clerk with only the publishable + // key in client source; a secret key has no use there and their default + // .gitignore templates don't cover .env, so leave it out rather than put a + // live credential in a tracked file (as `clerk env pull` already does). + const includeSecretKey = isNpmFramework(framework ?? {}); const merged = mergeEnvVars(parseEnvFile(existingContent), { [publishableKeyName]: keys.publishableKey, - [secretKeyName]: keys.secretKey, + ...(includeSecretKey && { [secretKeyName]: keys.secretKey }), }); await Bun.write(targetFile, serializeEnvFile(merged)); diff --git a/packages/cli-core/src/test/integration/agent-mode.test.ts b/packages/cli-core/src/test/integration/agent-mode.test.ts index 50ff159d0..77873d42b 100644 --- a/packages/cli-core/src/test/integration/agent-mode.test.ts +++ b/packages/cli-core/src/test/integration/agent-mode.test.ts @@ -11,6 +11,7 @@ import { http, clerk, readConfig, + mockState, MOCK_APP, getInstance, parseEnvFile, @@ -104,7 +105,7 @@ test("unlink --yes removes the profile in agent mode", async () => { expect(config.profiles["github.com/test/project"]).toBeUndefined(); }); -test("init creates and links a real app for keyless framework when authed in agent mode", async () => { +test("init creates and links a real app when authed in agent mode", async () => { await writeNextAppProject(); const devInstance = getInstance(MOCK_APP, "development"); http.mock({ @@ -123,13 +124,47 @@ test("init creates and links a real app for keyless framework when authed in age expect(http.requests.some((r) => r.url.includes("/v1/accountless_applications"))).toBe(false); }); -test("init prints manual setup for non-keyless framework without an app target in agent mode", async () => { +test("init creates and links a real app on a formerly gated framework when authed in agent mode", async () => { await writeReactProject(); + const devInstance = getInstance(MOCK_APP, "development"); + http.mock({ + "/v1/platform/applications": MOCK_APP, + }); - const { stderr } = await clerk("--mode", "agent", "init", "--no-skills"); + await clerk("--mode", "agent", "init", "--no-skills"); - expect(stderr).toContain("clerk init --app "); - expect(http.requests).toHaveLength(0); + const env = parseEnvFile(await Bun.file(join(h.tempDir, ".env.local")).text(), ".env.local"); + expect(env.get("VITE_CLERK_PUBLISHABLE_KEY")).toBe(devInstance.publishable_key); + expect(env.get("CLERK_SECRET_KEY")).toBe(devInstance.secret_key); + + const config = await readConfig(); + expect(config.profiles["github.com/test/project"]?.appId).toBe(MOCK_APP.application_id); +}); + +test("init mints an accountless app on a formerly gated framework when unauthenticated in agent mode", async () => { + await writeReactProject(); + delete process.env.CLERK_PLATFORM_API_KEY; + mockState.storedToken = null; + http.mock({ + "/v1/accountless_applications": { + publishable_key: "pk_test_accountless", + secret_key: "sk_test_accountless", + claim_url: "https://dashboard.clerk.com/apps/claim?token=tok_react&framework=react", + }, + }); + + await clerk("--mode", "agent", "init", "--no-skills"); + + const env = parseEnvFile(await Bun.file(join(h.tempDir, ".env.local")).text(), ".env.local"); + expect(env.get("VITE_CLERK_PUBLISHABLE_KEY")).toBe("pk_test_accountless"); + expect(env.get("CLERK_SECRET_KEY")).toBe("sk_test_accountless"); + + const breadcrumb = await Bun.file(join(h.tempDir, ".clerk", "keyless.json")).json(); + expect(breadcrumb.claimToken).toBe("tok_react"); + expect(await Bun.file(join(h.tempDir, ".gitignore")).text()).toContain(".clerk/"); + expect(http.requests.find((r) => r.url.includes("/v1/accountless_applications"))?.method).toBe( + "POST", + ); }); test("init with --app uses real app flow in agent mode", async () => { diff --git a/packages/cli-core/src/test/lib/init-harness.ts b/packages/cli-core/src/test/lib/init-harness.ts index 5db6e2d9f..008bafbe2 100644 --- a/packages/cli-core/src/test/lib/init-harness.ts +++ b/packages/cli-core/src/test/lib/init-harness.ts @@ -10,6 +10,7 @@ */ import { afterEach, spyOn } from "bun:test"; +import type { FrameworkInfo } from "../../lib/framework.ts"; import { useCaptureLog } from "./stubs.ts"; export * as loginMod from "../../commands/auth/login.ts"; @@ -69,22 +70,10 @@ export const FAKE_BOOTSTRAP = { 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 type FakeCtx = Omit & { framework: FrameworkInfo }; -export const KEYLESS_CTX: FakeCtx = { - ...FAKE_CTX, - existingClerk: false, - framework: { ...FAKE_CTX.framework, supportsKeyless: true }, -}; +/** A project with nothing set up yet — what an accountless run scaffolds against. */ +export const KEYLESS_CTX: FakeCtx = { ...FAKE_CTX, existingClerk: false }; export function mockBootstrapTo(ctx: FakeCtx): void { spyOn(contextModule, "gatherContext").mockResolvedValueOnce(null).mockResolvedValueOnce(ctx);