Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/init-framework-scaffolding-gaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"clerk": minor
---

Close the `clerk init` framework scaffolding gaps.

- Expo projects get their expo-router root layout wrapped with `<ClerkProvider>` and the secure token cache, and `clerk init --starter --framework expo` bootstraps a new Expo app.
- Express and Fastify projects get `clerkMiddleware()` / `clerkPlugin` wired into the server entry file (ESM and CommonJS), plus the request type augmentation for TypeScript Express apps.
- iOS (Swift) and Android (Kotlin) projects are now detected (via Xcode project bundles and AndroidManifest.xml) and `clerk init --framework ios|android` is accepted; init links the app, pulls API keys, and prints the exact SDK setup steps.
9 changes: 8 additions & 1 deletion .claude/rules/e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ E2E tests verify that `clerk init` produces a buildable, type-safe project with

## Supported frameworks

Astro, Next.js App Router, Next.js App Router (Next 14, pinned), Next.js Pages Router, Nuxt, React (Vite), React Router, TanStack Start, Vue (Vite).
Astro, Expo, Express, Fastify, Next.js App Router, Next.js App Router (Next 14, pinned), Next.js Pages Router, Nuxt, React (Vite), React Router, TanStack Start, Vue (Vite) — plus init-behavior tests for iOS and Android.

Not every fixture runs the full build + browser pipeline; coverage depth varies by what the framework can do headless:

- **Browser-tested** (build + typecheck + Playwright sign-in): Astro, Next.js (all variants), Nuxt, React, React Router, TanStack Start, Vue.
- **Server-tested** (typecheck + live HTTP request via `runServerTests`): Express, Fastify. No UI to drive a browser through; instead the dev server is started and a request must come back with the `x-clerk-auth-status` header, proving the scaffolded middleware/plugin runs with the pulled keys. These fixtures come from hand-authored templates in `test/e2e/templates/` (no official scaffolder exists); the refresh script copies the template and pins its `latest` dependency specs.
- **Build-tested** (typecheck + `expo export --platform web`): Expo. Metro bundles the ClerkProvider-wrapped layout, so a broken scaffold fails the export; native binaries would need Xcode/Gradle.
- **Init-behavior-tested** (`test/e2e/native-init.test.ts`): iOS, Android. No package.json, so these skip the manifest/harness entirely: hand-authored marker fixtures (`test/e2e/fixtures/ios/`, `test/e2e/fixtures/android/`, never touched by the refresh script) verify detection, keys pulled into `.env`, zero project writes, and the printed SDK quickstart.

## Required env vars

Expand Down
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"unicorn/no-process-exit": "error",
"no-console": "error"
},
"ignorePatterns": ["test/e2e/fixtures/**"],
"ignorePatterns": ["test/e2e/fixtures/**", "test/e2e/templates/**"],
"overrides": [
{
"files": [
Expand Down
19 changes: 18 additions & 1 deletion packages/cli-core/src/commands/env/pull.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, beforeEach, afterEach, spyOn, mock } 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 {
Expand Down Expand Up @@ -660,4 +660,21 @@ describe("env pull", () => {
expect(content).toContain("NUXT_CLERK_SECRET_KEY=sk_test_xyz789");
expect(content).not.toMatch(/^CLERK_SECRET_KEY=/m);
});

test("native framework (iOS) omits the secret key entirely", async () => {
await setProfile(tempDir, {
workspaceId: "org_1",
appId: "app_1",
instances: { development: "ins_dev" },
});
// Replace beforeEach's Express package.json with a native Xcode project marker.
await rm(join(tempDir, "package.json"), { force: true });
await mkdir(join(tempDir, "MyApp.xcodeproj"), { recursive: true });

await runEnvPull();

const content = await Bun.file(join(tempDir, ".env")).text();
expect(content).toContain("CLERK_PUBLISHABLE_KEY=pk_test_abc123");
expect(content).not.toContain("CLERK_SECRET_KEY");
});
});
10 changes: 9 additions & 1 deletion packages/cli-core/src/commands/env/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
detectPublishableKeyName,
detectSecretKeyName,
detectEnvFile,
detectFramework,
isNpmFramework,
} from "../../lib/framework.ts";
import { CliError, ERROR_CODE, withApiContext } from "../../lib/errors.ts";
import { withGutter, withSpinner } from "../../lib/spinner.ts";
Expand Down Expand Up @@ -70,6 +72,12 @@ export async function pull(options: EnvPullOptions): Promise<void> {

const publishableKeyName = await detectPublishableKeyName(cwd);
const secretKeyName = await detectSecretKeyName(cwd);
// 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 skip writing it entirely
// rather than leaving a live credential in a tracked file.
const framework = await detectFramework(cwd);
const includeSecretKey = isNpmFramework(framework ?? {});

const file = Bun.file(targetFile);
const existingContent = (await file.exists()) ? await file.text() : "";
Expand All @@ -78,7 +86,7 @@ export async function pull(options: EnvPullOptions): Promise<void> {
const vars: Record<string, string> = {
[publishableKeyName]: matched.publishable_key,
};
if (matched.secret_key) {
if (matched.secret_key && includeSecretKey) {
vars[secretKeyName] = matched.secret_key;
}
const merged = mergeEnvVars(lines, vars);
Expand Down
62 changes: 51 additions & 11 deletions packages/cli-core/src/commands/init/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ clerk init --no-skills

## Options

| Option | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--framework <name>` | Framework to set up (skips auto-detection). Valid values: `next`, `astro`, `nuxt`, `tanstack-start`, `react-router`, `vue`, `expo`, `react`, `javascript`, `js`, `express`, `fastify` |
| `--pm <manager>` | Package manager to use. Valid values: `bun`, `pnpm`, `yarn`, `npm`. Skips the PM prompt (bootstrap) or overrides lockfile detection (existing project) |
| `--name <project-name>` | Project name for `--starter` (skips prompt). Must be lowercase, no spaces, no path separators |
| `--app <id>` | Application ID to link (skips the interactive app picker during authenticated linking) |
| `--starter` | Bootstrap a new project from a starter template (runs the framework generator, installs deps, and scaffolds Clerk) |
| `--keyless` | Use auto-generated temporary development keys instead of logging in. Only valid when bootstrapping a new project on a keyless-capable framework |
| `-y, --yes` | Skip y/n confirmation prompts. Authentication is still required — unauthenticated users are prompted to log in via the browser unless `--keyless` is also passed |
| `--no-skills` | Skip the optional agent skills install prompt at the end of init |
| Option | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--framework <name>` | Framework to set up (skips auto-detection). Valid values: `next`, `astro`, `nuxt`, `tanstack-start`, `react-router`, `vue`, `expo`, `react`, `javascript`, `js`, `express`, `fastify`, `ios`, `android` |
| `--pm <manager>` | Package manager to use. Valid values: `bun`, `pnpm`, `yarn`, `npm`. Skips the PM prompt (bootstrap) or overrides lockfile detection (existing project) |
| `--name <project-name>` | Project name for `--starter` (skips prompt). Must be lowercase, no spaces, no path separators |
| `--app <id>` | Application ID to link (skips the interactive app picker during authenticated linking) |
| `--starter` | Bootstrap a new project from a starter template (runs the framework generator, installs deps, and scaffolds Clerk) |
| `--keyless` | Use auto-generated temporary development keys instead of logging in. Only valid when bootstrapping a new project on a keyless-capable framework |
| `-y, --yes` | Skip y/n confirmation prompts. Authentication is still required — unauthenticated users are prompted to log in via the browser unless `--keyless` is also passed |
| `--no-skills` | Skip the optional agent skills install prompt at the end of init |

## Agent Mode

Expand Down Expand Up @@ -87,13 +87,22 @@ Detects the project's framework from `package.json` dependencies (checked top-to
| `express` | Express | `@clerk/express` | `CLERK_PUBLISHABLE_KEY` | No |
| `fastify` | Fastify | `@clerk/fastify` | `CLERK_PUBLISHABLE_KEY` | No |

Native mobile platforms may not have a `package.json`, so they are detected from project marker files when no npm framework matches:

| Marker files | Framework | Clerk SDK | Publishable Key Env Var |
| ------------------------------------------------------------------- | ---------------- | ------------------------------------- | ----------------------- |
| `*.xcodeproj` / `*.xcworkspace` | iOS (Swift) | `ClerkKit` (Swift Package Manager) | `CLERK_PUBLISHABLE_KEY` |
| `app/src/main/AndroidManifest.xml` / `src/main/AndroidManifest.xml` | Android (Kotlin) | `com.clerk:clerk-android-ui` (Gradle) | `CLERK_PUBLISHABLE_KEY` |

A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. For native platforms the Clerk SDK cannot be installed by a JS package manager, so init skips the SDK install step and the scaffold plan prints Swift Package Manager / Gradle install steps instead. The publishable key is configured in source code (`Clerk.configure(...)` / `Clerk.initialize(...)`), so init still pulls keys into the env file and instructs the user to copy the key over.

The **Keyless** column indicates whether the framework's Clerk SDK supports keyless mode (auto-generated temporary dev keys). Keyless mode is opt-in via `--keyless` and is only valid when bootstrapping a new project on a Yes-row framework — passing `--keyless` for a No-row framework or for an existing project exits with a usage error. By default, init authenticates the user (interactively when needed) and links a real app. In agent mode, an authenticated run on a keyless-capable framework creates a real app named after the project and links it; an unauthenticated agent run without `--keyless` prints manual setup guidance instead of selecting or creating an app.

Package manager is detected from lock files: `bun.lockb`/`bun.lock` → bun, `yarn.lock` → yarn, `pnpm-lock.yaml` → pnpm, else npm.

## Scaffolding

Scaffolding is supported for the first 9 frameworks above (through JavaScript/Vite). Expo, Express, and Fastify are detected (SDK is installed, env vars are pulled) but scaffolding is not yet supported — users are directed to the Clerk docs.
Scaffolding is supported for every detected framework. iOS and Android write no files (their SDKs are not npm packages and their build files are not safe to modify automatically) — instead they print the exact quickstart steps as post-instructions.

All scaffolding is idempotent — files are skipped if they already contain Clerk setup.

Expand Down Expand Up @@ -183,6 +192,37 @@ Nuxt's module system auto-configures middleware and auto-imports components.

If no entry file is found, a post-instruction is printed pointing to the Clerk JS quickstart.

### Expo

| Action | File | Description |
| ------------- | ----------------------- | -------------------------------------------------------------------- |
| CREATE/MODIFY | `[src/]app/_layout.tsx` | Wrap the expo-router root layout with `ClerkProvider` + `tokenCache` |

The root layout is created (with a `<Slot />`) when missing and `expo-router` is a dependency; existing layouts have their main JSX return wrapped (guard returns like `if (!loaded) return null` are left alone). Wrapping is scoped to the default export — a function declaration, an arrow function, or either reached through `export default Name` — so sibling exports like the documented `ErrorBoundary` are never wrapped by mistake. Shapes that can't be resolved (a HOC-wrapped export, a concise arrow body) are skipped with a post-instruction rather than guessed at. Post-instructions cover `npx expo install expo-secure-store` (required by `@clerk/expo/token-cache`, installed via `expo install` so the version matches the project's Expo SDK), enabling the Native API in the Dashboard, and adding sign-in/sign-up screens.

**Bootstrap (new project)**: `clerk init --starter --framework expo` scaffolds a new app via `create-expo-app`.

### Express

| Action | File | Description |
| ------ | ------------------------ | -------------------------------------------------------- |
| MODIFY | server entry (see below) | Add `clerkMiddleware()` right after `express()` creation |
| CREATE | `types/globals.d.ts` | `@clerk/express/env` type reference (TypeScript only) |

A post-instruction reminds the user that `types/globals.d.ts` must be covered by the tsconfig `include` — a config scoped to `["src"]` never loads it and the `req.auth` augmentation silently doesn't apply.

### Fastify

| Action | File | Description |
| ------ | ------------------------ | -------------------------------------------------------------- |
| MODIFY | server entry (see below) | Register `clerkPlugin` right after the `Fastify(...)` creation |

Express and Fastify share the server-entry scaffolding in [`node-server.ts`](./frameworks/node-server.ts). The entry file is resolved from `package.json#main` (ignored when it points at build output like `dist/`) and common candidates (`[src/]index|server|app|main` with `.ts/.mts/.js/.mjs/.cjs`, ordered by basename so an unrelated `src/app.ts` can't outrank a root `index.js`). The resolved path is the one named in the `--env-file` post-instruction. Both ESM (`import`) and CommonJS (`require`, including the inline `require("fastify")(...)` form) are supported; injection lands after the full creation statement, so multi-line options objects and chained calls (e.g. `.withTypeProvider()`) are safe. When no entry or creation call is found, a post-instruction with the quickstart link is printed instead.

### iOS (Swift) / Android (Kotlin)

No files are written. The scaffold plan prints the quickstart steps: SDK install (Swift Package Manager for `ClerkKit`/`ClerkKitUI`, Gradle for `com.clerk:clerk-android-*`), enabling the Native API and registering the app on the Dashboard's Native Applications page, and configuring the publishable key in source (`Clerk.configure(...)` / `Clerk.initialize(...)`) by copying it from the pulled env file.

## Agent skills install

After scaffolding (and after env keys are pulled or keyless instructions are printed), `clerk init` offers to install Clerk's agent skills via the [`skills`](https://www.npmjs.com/package/skills) CLI. The runner is detected from the project's package manager (`bunx`, `npx`, `pnpm dlx`, or `yarn dlx`), so a Bun project installs via `bunx skills add ...`, a pnpm project via `pnpm dlx skills add ...`, and so on. This step is optional and non-fatal: if no package runner is available on PATH or an install command exits non-zero, init prints a yellow warning with a runner-appropriate manual command and still exits successfully.
Expand Down
17 changes: 17 additions & 0 deletions packages/cli-core/src/commands/init/bootstrap-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,23 @@ export const BOOTSTRAP_AUTHENTICATED_REGISTRY: BootstrapEntry[] = [
"vanilla",
],
},
{
label: "Expo",
dep: "expo",
defaultProjectName: "my-clerk-expo-app",
// create-expo-app has no git flag; it skips `git init` on its own inside
// an existing repo or CI. --no-install keeps parity with the other entries
// (our own installDependencies() step runs with the selected PM).
buildCommand: (pm, name) => [
...runner(pm),
"create-expo-app@latest",
name,
"--template",
"default",
"--no-install",
"--yes",
],
},
];

/** All bootstrap-capable frameworks (keyless + authenticated). */
Expand Down
16 changes: 14 additions & 2 deletions packages/cli-core/src/commands/init/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ function entryFor(dep: string) {
describe("BOOTSTRAP_REGISTRY", () => {
const packageManagers = ["npm", "yarn", "pnpm", "bun"] as const;

test("contains all 8 supported frameworks", () => {
expect(BOOTSTRAP_REGISTRY).toHaveLength(8);
test("contains all 9 supported frameworks", () => {
expect(BOOTSTRAP_REGISTRY).toHaveLength(9);
const deps = BOOTSTRAP_REGISTRY.map((e) => e.dep);
expect(deps).toContain("next");
expect(deps).toContain("react");
Expand All @@ -29,6 +29,7 @@ describe("BOOTSTRAP_REGISTRY", () => {
expect(deps).toContain("nuxt");
expect(deps).toContain("@tanstack/react-start");
expect(deps).toContain("vite");
expect(deps).toContain("expo");
});

const REGISTRY_PM_PAIRS = BOOTSTRAP_REGISTRY.flatMap((entry) =>
Expand Down Expand Up @@ -114,6 +115,17 @@ describe("BOOTSTRAP_REGISTRY", () => {
expect(cmd).toContain("--no-git");
});

test("Expo uses create-expo-app with default template", () => {
const cmd = entryFor("expo").buildCommand("bun", "my-expo");
expect(cmd[0]).toBe("bunx");
expect(cmd).toContain("create-expo-app@latest");
expect(cmd).toContain("my-expo");
expect(cmd).toContain("--template");
expect(cmd).toContain("default");
expect(cmd).toContain("--no-install");
expect(cmd).toContain("--yes");
});

test("JavaScript uses create-vite with vanilla template", () => {
const cmd = entryFor("vite").buildCommand("npm", "my-js-app");
expect(cmd).toContain("create-vite@latest");
Expand Down
Loading