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);