diff --git a/.changeset/init-framework-scaffolding-gaps.md b/.changeset/init-framework-scaffolding-gaps.md new file mode 100644 index 00000000..89a4084b --- /dev/null +++ b/.changeset/init-framework-scaffolding-gaps.md @@ -0,0 +1,9 @@ +--- +"clerk": minor +--- + +Close the `clerk init` framework scaffolding gaps. + +- Expo projects get their expo-router root layout wrapped with `` 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. diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 56192625..46dfa4e4 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -19,16 +19,16 @@ clerk init --no-skills ## Options -| Option | Description | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--framework ` | Framework to set up (skips auto-detection). Valid values: `next`, `astro`, `nuxt`, `tanstack-start`, `react-router`, `vue`, `expo`, `react`, `javascript`, `js`, `express`, `fastify` | -| `--pm ` | Package manager to use. Valid values: `bun`, `pnpm`, `yarn`, `npm`. Skips the PM prompt (bootstrap) or overrides lockfile detection (existing project) | -| `--name ` | Project name for `--starter` (skips prompt). Must be lowercase, no spaces, no path separators | -| `--app ` | Application ID to link (skips the interactive app picker during authenticated linking) | -| `--starter` | Bootstrap a new project from a starter template (runs the framework generator, installs deps, and scaffolds Clerk) | -| `--keyless` | Use auto-generated temporary development keys instead of logging in. Only valid when bootstrapping a new project on a keyless-capable framework | -| `-y, --yes` | Skip y/n confirmation prompts. Authentication is still required — unauthenticated users are prompted to log in via the browser unless `--keyless` is also passed | -| `--no-skills` | Skip the optional agent skills install prompt at the end of init | +| Option | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--framework ` | Framework to set up (skips auto-detection). Valid values: `next`, `astro`, `nuxt`, `tanstack-start`, `react-router`, `vue`, `expo`, `react`, `javascript`, `js`, `express`, `fastify`, `ios`, `android` | +| `--pm ` | Package manager to use. Valid values: `bun`, `pnpm`, `yarn`, `npm`. Skips the PM prompt (bootstrap) or overrides lockfile detection (existing project) | +| `--name ` | Project name for `--starter` (skips prompt). Must be lowercase, no spaces, no path separators | +| `--app ` | Application ID to link (skips the interactive app picker during authenticated linking) | +| `--starter` | Bootstrap a new project from a starter template (runs the framework generator, installs deps, and scaffolds Clerk) | +| `--keyless` | Use auto-generated temporary development keys instead of logging in. Only valid when bootstrapping a new project on a keyless-capable framework | +| `-y, --yes` | Skip y/n confirmation prompts. Authentication is still required — unauthenticated users are prompted to log in via the browser unless `--keyless` is also passed | +| `--no-skills` | Skip the optional agent skills install prompt at the end of init | ## Agent Mode @@ -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. @@ -183,6 +192,35 @@ 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 ``) 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). 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) | + +### 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`). 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. diff --git a/packages/cli-core/src/commands/init/bootstrap-registry.ts b/packages/cli-core/src/commands/init/bootstrap-registry.ts index a6041064..8f66a693 100644 --- a/packages/cli-core/src/commands/init/bootstrap-registry.ts +++ b/packages/cli-core/src/commands/init/bootstrap-registry.ts @@ -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). */ diff --git a/packages/cli-core/src/commands/init/bootstrap.test.ts b/packages/cli-core/src/commands/init/bootstrap.test.ts index 48554070..230bd94c 100644 --- a/packages/cli-core/src/commands/init/bootstrap.test.ts +++ b/packages/cli-core/src/commands/init/bootstrap.test.ts @@ -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"); @@ -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) => @@ -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"); diff --git a/packages/cli-core/src/commands/init/frameworks/android.test.ts b/packages/cli-core/src/commands/init/frameworks/android.test.ts new file mode 100644 index 00000000..3c154ea8 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/android.test.ts @@ -0,0 +1,50 @@ +import { test, expect } from "bun:test"; +import { android } from "./android.ts"; +import type { ProjectContext } from "./types.ts"; + +function makeCtx(): ProjectContext { + return { + cwd: "/tmp/android-app", + framework: { + dep: "android", + name: "Android (Kotlin)", + sdk: "com.clerk:clerk-android-ui", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "gradle" as const, + }, + typescript: false, + srcDir: false, + packageManager: "npm", + existingClerk: false, + deps: {}, + envFile: ".env", + }; +} + +test("matches only the android framework", () => { + const ctx = makeCtx(); + expect(android.matches(ctx)).toBe(true); + expect(android.matches({ ...ctx, framework: { ...ctx.framework, dep: "ios" } })).toBe(false); +}); + +test("writes no files and prints the quickstart steps", async () => { + const plan = await android.scaffold(makeCtx()); + + expect(plan.actions).toHaveLength(0); + expect(plan.postInstructions.some((i) => i.includes("com.clerk:clerk-android-ui"))).toBe(true); + expect( + plan.postInstructions.some((i) => i.includes("dashboard.clerk.com/~/native-applications")), + ).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("Clerk.initialize"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("android.permission.INTERNET"))).toBe(true); + expect( + plan.postInstructions.some((i) => i.includes("docs/android/getting-started/quickstart")), + ).toBe(true); +}); + +test("references the project's env file for the publishable key", async () => { + const plan = await android.scaffold({ ...makeCtx(), envFile: ".env.local" }); + + expect(plan.postInstructions.some((i) => i.includes(".env.local"))).toBe(true); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/android.ts b/packages/cli-core/src/commands/init/frameworks/android.ts new file mode 100644 index 00000000..ac02f41e --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/android.ts @@ -0,0 +1,33 @@ +import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; + +/** + * Android (Kotlin) support for `clerk init`. + * + * The Clerk Android SDK ships via Gradle (`com.clerk:` artifacts) and the + * 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. + * + * Docs: https://clerk.com/docs/android/getting-started/quickstart + */ +export const android: FrameworkScaffold = { + name: "Android (Kotlin)", + dep: "android", + + matches: (ctx) => ctx.framework.dep === "android", + + async scaffold(ctx: ProjectContext): Promise { + return { + actions: [], + postInstructions: [ + 'Add the Clerk Android SDK to app/build.gradle.kts: `implementation("com.clerk:clerk-android-ui:")` and `implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")` (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\``, + "Full setup guide: https://clerk.com/docs/android/getting-started/quickstart", + ], + }; + }, +}; diff --git a/packages/cli-core/src/commands/init/frameworks/expo.test.ts b/packages/cli-core/src/commands/init/frameworks/expo.test.ts new file mode 100644 index 00000000..126ea8b4 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/expo.test.ts @@ -0,0 +1,366 @@ +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { expo, wrapLastReturnWithProvider } from "./expo.ts"; +import type { FileAction, ProjectContext } from "./types.ts"; + +let tempDir: string; + +function makeCtx(overrides?: Partial): ProjectContext { + return { + cwd: tempDir, + framework: { + dep: "expo", + name: "Expo", + sdk: "@clerk/expo", + envVar: "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY", + envFile: ".env.local" as const, + }, + typescript: true, + srcDir: false, + packageManager: "npm", + existingClerk: false, + deps: { expo: "~52.0.0", "expo-router": "~4.0.0" }, + envFile: ".env", + ...overrides, + }; +} + +function findAction(actions: FileAction[], path: string): FileAction { + const action = actions.find((a) => a.path === path); + if (!action) { + const paths = actions.map((a) => a.path).join(", "); + throw new Error(`No action found for path "${path}". Available: ${paths}`); + } + return action; +} + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-expo-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +const ROUTER_LAYOUT = `import { Stack } from "expo-router"; +import { useFonts } from "expo-font"; + +export default function RootLayout() { + const [loaded] = useFonts({}); + + if (!loaded) { + return null; + } + + return ( + + + + ); +} +`; + +test("creates app/_layout.tsx when missing and expo-router is present", async () => { + const plan = await expo.scaffold(makeCtx()); + + const layout = findAction(plan.actions, "app/_layout.tsx"); + expect(layout.type).toBe("create"); + if (layout.type === "create") { + expect(layout.content).toContain('import { ClerkProvider } from "@clerk/expo"'); + expect(layout.content).toContain('import { tokenCache } from "@clerk/expo/token-cache"'); + expect(layout.content).toContain(""); + expect(layout.content).toContain("EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY"); + // Guard message points at the env file init actually writes to + expect(layout.content).toContain("Add your key to .env."); + } +}); + +test("guard message references the project's env file", async () => { + const plan = await expo.scaffold(makeCtx({ envFile: ".env.local" })); + + const layout = findAction(plan.actions, "app/_layout.tsx"); + if (layout.type === "create") { + expect(layout.content).toContain("Add your key to .env.local."); + } +}); + +test("creates src/app/_layout.tsx when srcDir is true", async () => { + const plan = await expo.scaffold(makeCtx({ srcDir: true })); + + expect(findAction(plan.actions, "src/app/_layout.tsx").type).toBe("create"); +}); + +test("creates _layout.jsx when typescript is false", async () => { + const plan = await expo.scaffold(makeCtx({ typescript: false })); + + expect(findAction(plan.actions, "app/_layout.jsx").type).toBe("create"); +}); + +test("does not create a layout without expo-router — prints post-instruction", async () => { + const plan = await expo.scaffold(makeCtx({ deps: { expo: "~52.0.0" } })); + + expect(plan.actions).toHaveLength(0); + expect(plan.postInstructions.some((i) => i.includes("ClerkProvider"))).toBe(true); +}); + +test("wraps the main return of an existing layout, not the guard return", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/_layout.tsx"), ROUTER_LAYOUT); + + const plan = await expo.scaffold(makeCtx()); + + const layout = findAction(plan.actions, "app/_layout.tsx"); + expect(layout.type).toBe("modify"); + if (layout.type === "modify") { + expect(layout.content).toContain( + "", + ); + expect(layout.content).toContain(""); + expect(layout.content).toContain("@clerk/expo/token-cache"); + expect(layout.content).toContain("process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY"); + // Guard return stays untouched + expect(layout.content).toContain("return null;"); + // The provider wraps the Stack, not the guard + const providerIdx = layout.content.indexOf(""); + expect(providerIdx).toBeLessThan(stackIdx); + } +}); + +test("adds spaced, ordered imports when modifying an existing layout", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write(join(tempDir, "app/_layout.tsx"), ROUTER_LAYOUT); + + const plan = await expo.scaffold(makeCtx()); + + const layout = findAction(plan.actions, "app/_layout.tsx"); + expect(layout.type).toBe("modify"); + if (layout.type === "modify") { + expect(layout.content).toContain('import { ClerkProvider } from "@clerk/expo"'); + expect(layout.content).toContain('import { tokenCache } from "@clerk/expo/token-cache"'); + // Provider import reads first, matching the create-path template + expect(layout.content.indexOf('from "@clerk/expo"')).toBeLessThan( + layout.content.indexOf('from "@clerk/expo/token-cache"'), + ); + } +}); + +test("wraps a single-line return without parentheses", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "app/_layout.tsx"), + `import { Slot } from "expo-router"; + +export default function RootLayout() { + return ; +} +`, + ); + + const plan = await expo.scaffold(makeCtx()); + + const layout = findAction(plan.actions, "app/_layout.tsx"); + expect(layout.type).toBe("modify"); + if (layout.type === "modify") { + expect(layout.content).toContain(""); + } +}); + +test("skips when layout already has ClerkProvider", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "app/_layout.tsx"), + `import { ClerkProvider } from "@clerk/expo"; +export default function RootLayout() { + return ; +} +`, + ); + + const plan = await expo.scaffold(makeCtx()); + + expect(findAction(plan.actions, "app/_layout.tsx")).toMatchObject({ + type: "skip", + skipReason: "Already has ClerkProvider", + }); +}); + +test("skips with reason when layout has no JSX return", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "app/_layout.tsx"), + `export default function RootLayout() { + return null; +} +`, + ); + + const plan = await expo.scaffold(makeCtx()); + + const layout = findAction(plan.actions, "app/_layout.tsx"); + expect(layout.type).toBe("skip"); +}); + +test("recommends expo install for expo-secure-store when layout is written", async () => { + const plan = await expo.scaffold(makeCtx()); + + expect(plan.postInstructions.some((i) => i.includes("npx expo install expo-secure-store"))).toBe( + true, + ); +}); + +test("does not recommend expo-secure-store when already installed", async () => { + const plan = await expo.scaffold( + makeCtx({ + deps: { expo: "~52.0.0", "expo-router": "~4.0.0", "expo-secure-store": "~14.0.0" }, + }), + ); + + expect(plan.postInstructions.some((i) => i.includes("expo-secure-store"))).toBe(false); +}); + +test("includes env var and Native API post-instructions", async () => { + const plan = await expo.scaffold(makeCtx()); + + expect(plan.postInstructions.some((i) => i.includes("EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY"))).toBe( + true, + ); + expect(plan.postInstructions.some((i) => i.includes("native-applications"))).toBe(true); +}); + +test("wrapLastReturnWithProvider returns null for non-JSX content", () => { + expect(wrapLastReturnWithProvider("const x = 1;")).toBeNull(); +}); + +test("wraps the default export's return, not a later ErrorBoundary export", () => { + const content = `import { Slot } from "expo-router"; +import { View, Text } from "react-native"; + +export default function RootLayout() { + return ( + + ); +} + +export function ErrorBoundary({ error }: { error: Error }) { + return ( + + {error.message} + + ); +} +`; + + const wrapped = wrapLastReturnWithProvider(content)!; + expect(wrapped).not.toBeNull(); + // Exactly one wrap, and it lands on the root layout's Slot + expect(wrapped.match(/")); + expect(providerIdx).toBeLessThan(wrapped.indexOf("ErrorBoundary")); + // The error boundary's JSX is untouched + expect(wrapped).toContain("return (\n "); +}); + +test("wraps a layout whose own JSX text contains an apostrophe", () => { + const content = `import { Slot } from "expo-router"; +import { Text, View } from "react-native"; + +export default function RootLayout() { + return ( + + Don't panic + + + ); +} +`; + + const wrapped = wrapLastReturnWithProvider(content)!; + expect(wrapped).not.toBeNull(); + expect(wrapped).toContain("Don't panic"); + expect(wrapped.indexOf("")); +}); + +test("wraps the root layout even when earlier JSX text contains an apostrophe", () => { + const content = `import { Slot } from "expo-router"; +import { View, Text } from "react-native"; + +function Banner() { + return ( + Don't panic + ); +} + +export default function RootLayout() { + return ( + + + + + ); +} +`; + + const wrapped = wrapLastReturnWithProvider(content)!; + expect(wrapped).not.toBeNull(); + expect(wrapped.match(/")); + expect(wrapped).toContain("Don't panic"); +}); + +test("wraps the last single-line return, not a single-line guard", () => { + const content = `import { Slot } from "expo-router"; + +export default function RootLayout() { + const isLoaded = false; + if (!isLoaded) return ; + return ; +} +`; + + const wrapped = wrapLastReturnWithProvider(content)!; + expect(wrapped).not.toBeNull(); + // The guard stays untouched; the main render gets wrapped + expect(wrapped).toContain("if (!isLoaded) return ;"); + const providerIdx = wrapped.indexOf("")); + expect(wrapped.indexOf("")).toBeGreaterThan(providerIdx); +}); + +test("inserts the key block after a multi-line import, not inside it", async () => { + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "app/_layout.tsx"), + `import { Slot } from "expo-router"; +import { + useFonts, +} from "expo-font"; + +export default function RootLayout() { + return ( + + ); +} +`, + ); + + const plan = await expo.scaffold(makeCtx()); + + const layout = findAction(plan.actions, "app/_layout.tsx"); + expect(layout.type).toBe("modify"); + if (layout.type === "modify") { + // The multi-line import survives intact… + expect(layout.content).toContain('} from "expo-font";'); + // …and the key block lands after it, not spliced between its braces + const keyIdx = layout.content.indexOf("const publishableKey"); + expect(keyIdx).toBeGreaterThan(layout.content.indexOf('} from "expo-font";')); + } +}); diff --git a/packages/cli-core/src/commands/init/frameworks/expo.ts b/packages/cli-core/src/commands/init/frameworks/expo.ts new file mode 100644 index 00000000..7060ea16 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/expo.ts @@ -0,0 +1,225 @@ +import { join } from "node:path"; +import { findFirstFile, indentBlock, insertAfterLastImport, safeAddImport } from "./helpers.js"; +import { findMatchingDelimiter, maskCommentsAndStrings } from "./source-scan.js"; +import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; + +const EXPO_QUICKSTART_URL = "https://clerk.com/docs/expo/getting-started/quickstart"; + +function missingKeyError(envFile: string): string { + return `Missing EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY. Add your key to ${envFile}.\\nRun: 1) clerk auth login 2) clerk link 3) clerk env pull — then restart the dev server.`; +} + +function publishableKeyBlock(envFile: string): string { + return ` +const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY; + +if (!publishableKey) { + throw new Error("${missingKeyError(envFile)}"); +} +`; +} + +function newLayoutContent(envFile: string): string { + return `import { ClerkProvider } from "@clerk/expo"; +import { tokenCache } from "@clerk/expo/token-cache"; +import { Slot } from "expo-router"; +${publishableKeyBlock(envFile)} +export default function RootLayout() { + return ( + + + + ); +} +`; +} + +/** + * Locate the body of the default-exported function so return-wrapping never + * targets a sibling export — expo-router layouts commonly export additional + * components (e.g. the documented `ErrorBoundary`) from the same file. + * Returns null when the default export isn't a resolvable function. + */ +function findDefaultExportBody( + content: string, + masked: string, +): { start: number; end: number } | null { + let fnIdx = masked.search(/export\s+default\s+(?:async\s+)?function\b/); + + if (fnIdx === -1) { + // `export default RootLayout;` referencing a function declared elsewhere. + const ref = /export\s+default\s+(\w+)/.exec(masked); + if (!ref) return null; + fnIdx = masked.search(new RegExp(`\\bfunction\\s+${ref[1]}\\s*\\(`)); + if (fnIdx === -1) return null; + } + + const paramsOpen = masked.indexOf("(", fnIdx); + if (paramsOpen === -1) return null; + const paramsEnd = findMatchingDelimiter(masked, paramsOpen, "(", ")"); + if (paramsEnd === null) return null; + + const bodyOpen = masked.indexOf("{", paramsEnd); + if (bodyOpen === -1) return null; + const bodyEnd = findMatchingDelimiter(masked, bodyOpen, "{", "}"); + if (bodyEnd === null) return null; + + return { start: bodyOpen, end: bodyEnd }; +} + +/** Strip surrounding blank lines and the common leading indentation so the + * wrapped JSX re-indents cleanly regardless of its original nesting depth. */ +function dedent(block: string): string { + const lines = block.split("\n"); + while (lines.length > 0 && lines[0]!.trim() === "") lines.shift(); + while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop(); + + const indents = lines + .filter((line) => line.trim().length > 0) + .map((line) => line.match(/^ */)![0].length); + const common = indents.length > 0 ? Math.min(...indents) : 0; + return lines.map((line) => line.slice(common)).join("\n"); +} + +function wrapJsx(inner: string): string { + return `( + +${indentBlock(dedent(inner), " ")} + + )`; +} + +/** + * Wrap the JSX of the last `return ( ... )` or single-line `return <... />` + * in the default export's body with . The last return is the + * layout's main render — earlier returns are guards like `if (!loaded) + * return null`. Returns null when no JSX return exists (unsupported shape — + * the caller falls back to a post-instruction). + */ +export function wrapLastReturnWithProvider(content: string): string | null { + const masked = maskCommentsAndStrings(content); + + // Scope the search to the default export so a sibling component's return + // (e.g. an ErrorBoundary export) is never wrapped by mistake. When the + // default export isn't a plain function, fall back to the whole file. + const body = findDefaultExportBody(content, masked); + const from = body?.start ?? 0; + const to = body?.end ?? content.length; + + // Searching the masked region keeps commented-out returns from matching. + const relIdx = masked.slice(from, to).lastIndexOf("return ("); + if (relIdx !== -1) { + const openIdx = from + relIdx + "return ".length; + const closeIdx = findMatchingDelimiter(masked, openIdx, "(", ")"); + if (closeIdx === null) return null; + + const inner = content.slice(openIdx + 1, closeIdx - 1); + return content.slice(0, openIdx) + wrapJsx(inner) + content.slice(closeIdx); + } + + // Single-line form: `return ;` — multi-line JSX without parens is + // invalid JS (ASI), so the statement always ends on the same line. + const singleLine = /return\s+(<.*>)\s*;?\s*$/gm; + let match: RegExpExecArray | null = null; + for (const m of content.slice(from, to).matchAll(singleLine)) { + if (masked[from + m.index] === "r") match = m; + } + if (!match) return null; + + const absIdx = from + match.index; + return ( + content.slice(0, absIdx) + + `return ${wrapJsx(match[1]!)};` + + content.slice(absIdx + match[0].length) + ); +} + +async function findLayoutFile(ctx: ProjectContext): Promise { + const base = `${ctx.srcDir ? "src/" : ""}app/_layout`; + return findFirstFile(ctx.cwd, [`${base}.tsx`, `${base}.jsx`, `${base}.js`]); +} + +function usesExpoRouter(ctx: ProjectContext): boolean { + return Boolean(ctx.deps["expo-router"]); +} + +async function scaffoldLayout(ctx: ProjectContext): Promise { + const layoutPath = await findLayoutFile(ctx); + + if (!layoutPath) { + if (!usesExpoRouter(ctx)) return null; + const ext = ctx.typescript ? "tsx" : "jsx"; + return { + type: "create", + path: `${ctx.srcDir ? "src/" : ""}app/_layout.${ext}`, + content: newLayoutContent(ctx.envFile), + description: "Create root layout with ClerkProvider and token cache", + }; + } + + const content = await Bun.file(join(ctx.cwd, layoutPath)).text(); + + if (content.includes("ClerkProvider")) { + return { type: "skip", path: layoutPath, skipReason: "Already has ClerkProvider" }; + } + + const wrapped = wrapLastReturnWithProvider(content); + if (!wrapped) { + return { + type: "skip", + path: layoutPath, + skipReason: "Root layout uses an unsupported shape for automatic ClerkProvider wrapping", + }; + } + + // magicast prepends each new import, so add in reverse of the desired + // order: ClerkProvider ends up above tokenCache, matching the create path. + let newContent = safeAddImport(wrapped, "@clerk/expo/token-cache", "tokenCache"); + newContent = safeAddImport(newContent, "@clerk/expo", "ClerkProvider"); + newContent = insertAfterLastImport(newContent, publishableKeyBlock(ctx.envFile)); + + return { + path: layoutPath, + type: "modify", + content: newContent, + description: "Wrap root layout with ClerkProvider and token cache", + }; +} + +export const expo: FrameworkScaffold = { + name: "Expo", + dep: "expo", + + matches: (ctx) => ctx.framework.dep === "expo", + + async scaffold(ctx: ProjectContext): Promise { + const layoutAction = await scaffoldLayout(ctx); + + const actions: FileAction[] = []; + const postInstructions: string[] = []; + + if (layoutAction) { + actions.push(layoutAction); + } else { + postInstructions.push( + `Wrap your app root with from @clerk/expo (with tokenCache from @clerk/expo/token-cache). See: ${EXPO_QUICKSTART_URL}`, + ); + } + + const wroteLayout = layoutAction != null && layoutAction.type !== "skip"; + if (wroteLayout && !ctx.deps["expo-secure-store"]) { + // `npx expo install` (not the package manager) so the version matches the + // project's Expo SDK — a mismatched native module breaks builds. + postInstructions.push( + "Install the secure token store (required by the token cache): `npx expo install expo-secure-store`", + ); + } + + postInstructions.push( + `Ensure ${ctx.framework.envVar} is set in your ${ctx.envFile} (pulled via \`clerk env pull\`)`, + `Add sign-in and sign-up screens, and enable the Native API at https://dashboard.clerk.com/~/native-applications — see: ${EXPO_QUICKSTART_URL}`, + ); + + return { actions, postInstructions }; + }, +}; diff --git a/packages/cli-core/src/commands/init/frameworks/express.test.ts b/packages/cli-core/src/commands/init/frameworks/express.test.ts new file mode 100644 index 00000000..9864d104 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/express.test.ts @@ -0,0 +1,357 @@ +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { express } from "./express.ts"; +import type { FileAction, ProjectContext } from "./types.ts"; + +let tempDir: string; + +function makeCtx(overrides?: Partial): ProjectContext { + return { + cwd: tempDir, + framework: { + dep: "express", + name: "Express", + sdk: "@clerk/express", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env.local" as const, + }, + typescript: true, + srcDir: false, + packageManager: "npm", + existingClerk: false, + deps: { express: "^5.0.0" }, + envFile: ".env", + ...overrides, + }; +} + +function findAction(actions: FileAction[], path: string): FileAction { + const action = actions.find((a) => a.path === path); + if (!action) { + const paths = actions.map((a) => a.path).join(", "); + throw new Error(`No action found for path "${path}". Available: ${paths}`); + } + return action; +} + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-express-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +const ESM_SERVER = `import express from "express"; + +const app = express(); +const PORT = 3000; + +app.get("/", (req, res) => { + res.send("Hello"); +}); + +app.listen(PORT, () => { + console.log(\`Listening on \${PORT}\`); +}); +`; + +test("adds clerkMiddleware() after app creation in index.ts", async () => { + await Bun.write(join(tempDir, "index.ts"), ESM_SERVER); + + const plan = await express.scaffold(makeCtx()); + + const entry = findAction(plan.actions, "index.ts"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toMatch(/import \{\s*clerkMiddleware\s*\} from "@clerk\/express"/); + expect(entry.content).toContain("app.use(clerkMiddleware());"); + // Middleware attaches right after creation, before any routes + const middlewareIdx = entry.content.indexOf("app.use(clerkMiddleware())"); + const routeIdx = entry.content.indexOf('app.get("/"'); + expect(middlewareIdx).toBeLessThan(routeIdx); + } +}); + +test("finds the entry under src/", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await Bun.write(join(tempDir, "src/server.ts"), ESM_SERVER); + + const plan = await express.scaffold(makeCtx()); + + expect(findAction(plan.actions, "src/server.ts").type).toBe("modify"); +}); + +test("prefers the package.json main entry", async () => { + await Bun.write(join(tempDir, "package.json"), JSON.stringify({ main: "custom-entry.js" })); + await Bun.write(join(tempDir, "custom-entry.js"), ESM_SERVER); + await Bun.write(join(tempDir, "index.js"), ESM_SERVER); + + const plan = await express.scaffold(makeCtx({ typescript: false })); + + expect(findAction(plan.actions, "custom-entry.js").type).toBe("modify"); +}); + +test("ignores a package.json main pointing at build output", async () => { + await Bun.write(join(tempDir, "package.json"), JSON.stringify({ main: "dist/index.js" })); + await mkdir(join(tempDir, "dist"), { recursive: true }); + await Bun.write(join(tempDir, "dist/index.js"), ESM_SERVER); + await Bun.write(join(tempDir, "index.js"), ESM_SERVER); + + const plan = await express.scaffold(makeCtx({ typescript: false })); + + expect(findAction(plan.actions, "index.js").type).toBe("modify"); + expect(plan.actions.some((a) => a.path === "dist/index.js")).toBe(false); +}); + +test("uses require() style for CommonJS files", async () => { + await Bun.write( + join(tempDir, "index.js"), + `const express = require("express"); + +const app = express(); + +app.listen(3000); +`, + ); + + const plan = await express.scaffold(makeCtx({ typescript: false })); + + const entry = findAction(plan.actions, "index.js"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toContain('const { clerkMiddleware } = require("@clerk/express");'); + expect(entry.content).not.toContain("import {"); + expect(entry.content).toContain("app.use(clerkMiddleware());"); + } +}); + +test("handles the inline-require creation form", async () => { + await Bun.write( + join(tempDir, "index.js"), + `const app = require("express")(); + +app.listen(3000); +`, + ); + + const plan = await express.scaffold(makeCtx({ typescript: false })); + + const entry = findAction(plan.actions, "index.js"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toContain('const { clerkMiddleware } = require("@clerk/express");'); + expect(entry.content).toContain("app.use(clerkMiddleware());"); + } +}); + +test("ignores a commented-out app creation and attaches to the real one", async () => { + await Bun.write( + join(tempDir, "index.ts"), + `import express from "express"; + +// Previously: const app = express(); +const server = express(); + +server.get("/", (req, res) => res.send("hi")); +server.listen(3000); +`, + ); + + const plan = await express.scaffold(makeCtx()); + + const entry = findAction(plan.actions, "index.ts"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toContain("server.use(clerkMiddleware());"); + expect(entry.content).not.toContain("app.use(clerkMiddleware());"); + // Attaches after the real creation statement, not after the comment + const attachIdx = entry.content.indexOf("server.use(clerkMiddleware())"); + const creationIdx = entry.content.indexOf("const server = express();"); + expect(attachIdx).toBeGreaterThan(creationIdx); + } +}); + +test("wires the app when an earlier line has an unpaired quote", async () => { + await Bun.write( + join(tempDir, "index.ts"), + `import express from "express"; + +const strip = (s: string) => s.replace(/"/g, ""); + +const app = express(); + +app.listen(3000); +`, + ); + + const plan = await express.scaffold(makeCtx()); + + const entry = findAction(plan.actions, "index.ts"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toContain("app.use(clerkMiddleware());"); + } +}); + +test("ignores an app creation that only appears inside a string literal", async () => { + await Bun.write( + join(tempDir, "index.ts"), + `const example = "const app = express();"; +console.log(example); +`, + ); + + const plan = await express.scaffold(makeCtx()); + + expect(findAction(plan.actions, "index.ts").type).toBe("skip"); +}); + +test("scaffolds when the entry imports an unrelated @clerk package", async () => { + await Bun.write( + join(tempDir, "index.ts"), + `import { verifyToken } from "@clerk/backend"; +import express from "express"; + +const app = express(); + +app.listen(3000); +`, + ); + + const plan = await express.scaffold(makeCtx()); + + const entry = findAction(plan.actions, "index.ts"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toContain("app.use(clerkMiddleware());"); + } +}); + +test("matches a type-annotated creation statement", async () => { + await Bun.write( + join(tempDir, "index.ts"), + `import express, { type Express } from "express"; + +const app: Express = express(); + +app.listen(3000); +`, + ); + + const plan = await express.scaffold(makeCtx()); + + const entry = findAction(plan.actions, "index.ts"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toContain("app.use(clerkMiddleware());"); + } +}); + +test("adds the ESM import with the codebase's brace spacing", async () => { + await Bun.write(join(tempDir, "index.ts"), ESM_SERVER); + + const plan = await express.scaffold(makeCtx()); + + const entry = findAction(plan.actions, "index.ts"); + if (entry.type === "modify") { + expect(entry.content).toContain('import { clerkMiddleware } from "@clerk/express"'); + } +}); + +test("prints the wiring post-instruction when no creation call is found", async () => { + await Bun.write(join(tempDir, "index.ts"), `console.log("not a server");\n`); + + const plan = await express.scaffold(makeCtx()); + + expect(plan.postInstructions.some((i) => i.includes("app.use(clerkMiddleware())"))).toBe(true); +}); + +test("omits the wiring post-instruction when the entry is already scaffolded", async () => { + await Bun.write( + join(tempDir, "index.ts"), + `import { clerkMiddleware } from "@clerk/express"; +import express from "express"; +const app = express(); +app.use(clerkMiddleware()); +`, + ); + + const plan = await express.scaffold(makeCtx()); + + expect(plan.postInstructions.some((i) => i.includes("server entry file"))).toBe(false); +}); + +test("skips when the entry already uses @clerk/express", async () => { + await Bun.write( + join(tempDir, "index.ts"), + `import { clerkMiddleware } from "@clerk/express"; +import express from "express"; +const app = express(); +app.use(clerkMiddleware()); +`, + ); + + const plan = await express.scaffold(makeCtx()); + + expect(findAction(plan.actions, "index.ts")).toMatchObject({ type: "skip" }); +}); + +test("skips with reason when no express() creation is found", async () => { + await Bun.write(join(tempDir, "index.ts"), `console.log("not a server");\n`); + + const plan = await express.scaffold(makeCtx()); + + const entry = findAction(plan.actions, "index.ts"); + expect(entry.type).toBe("skip"); + if (entry.type === "skip") { + expect(entry.skipReason).toContain("express"); + } +}); + +test("creates types/globals.d.ts for TypeScript projects", async () => { + await Bun.write(join(tempDir, "index.ts"), ESM_SERVER); + + const plan = await express.scaffold(makeCtx()); + + const types = findAction(plan.actions, "types/globals.d.ts"); + expect(types.type).toBe("create"); + if (types.type === "create") { + expect(types.content).toContain(''); + } +}); + +test("skips the types file when it already exists", async () => { + await Bun.write(join(tempDir, "index.ts"), ESM_SERVER); + await Bun.write(join(tempDir, "types/globals.d.ts"), "// existing\n"); + + const plan = await express.scaffold(makeCtx()); + + expect(findAction(plan.actions, "types/globals.d.ts").type).toBe("skip"); +}); + +test("does not create the types file for JavaScript projects", async () => { + await Bun.write(join(tempDir, "index.js"), ESM_SERVER); + + const plan = await express.scaffold(makeCtx({ typescript: false })); + + expect(plan.actions.some((a) => a.path === "types/globals.d.ts")).toBe(false); +}); + +test("prints post-instruction when no entry file exists", async () => { + const plan = await express.scaffold(makeCtx()); + + expect(plan.postInstructions.some((i) => i.includes("clerkMiddleware"))).toBe(true); +}); + +test("includes env and route-protection post-instructions", async () => { + await Bun.write(join(tempDir, "index.ts"), ESM_SERVER); + + const plan = await express.scaffold(makeCtx()); + + expect(plan.postInstructions.some((i) => i.includes("CLERK_SECRET_KEY"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("--env-file"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("getAuth"))).toBe(true); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/express.ts b/packages/cli-core/src/commands/init/frameworks/express.ts new file mode 100644 index 00000000..883ecac5 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/express.ts @@ -0,0 +1,58 @@ +import { join } from "node:path"; +import { scaffoldServerFramework, type ServerFrameworkConfig } from "./node-server.js"; +import type { FileAction, FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; + +const EXPRESS_CONFIG: ServerFrameworkConfig = { + clerkPackage: "@clerk/express", + clerkImport: "clerkMiddleware", + // Matches `express()` and the inline-require form `require("express")()`, + // with an optional type annotation (`const app: Express = express()`). + creationPattern: + /(?:const|let|var)\s+(\w+)(?:\s*:\s*[\w$.]+(?:<[^>]*>)?)?\s*=\s*(?:express|require\(\s*["']express["']\s*\))\s*\(\s*\)/, + frameworkPackage: "express", + attachStatement: (appVar) => `${appVar}.use(clerkMiddleware());`, + description: "Add clerkMiddleware() to Express app", + docsUrl: "https://clerk.com/docs/expressjs/getting-started/quickstart", + manualWiring: "Add `app.use(clerkMiddleware())` from @clerk/express to your server entry file.", +}; + +const TYPES_REFERENCE_PATH = "types/globals.d.ts"; +const TYPES_REFERENCE_CONTENT = `/// \n`; + +/** Register the Express request type augmentation (from the official quickstart). */ +async function scaffoldTypesReference(ctx: ProjectContext): Promise { + if (!ctx.typescript) return null; + + if (await Bun.file(join(ctx.cwd, TYPES_REFERENCE_PATH)).exists()) { + return { + type: "skip", + path: TYPES_REFERENCE_PATH, + skipReason: "Type reference file already exists", + }; + } + + return { + type: "create", + path: TYPES_REFERENCE_PATH, + content: TYPES_REFERENCE_CONTENT, + description: "Add @clerk/express request type augmentation", + }; +} + +export const express: FrameworkScaffold = { + name: "Express", + dep: "express", + + matches: (ctx) => ctx.framework.dep === "express", + + async scaffold(ctx: ProjectContext): Promise { + const [plan, typesAction] = await Promise.all([ + scaffoldServerFramework(ctx, EXPRESS_CONFIG), + scaffoldTypesReference(ctx), + ]); + + if (typesAction) plan.actions.push(typesAction); + + return plan; + }, +}; diff --git a/packages/cli-core/src/commands/init/frameworks/fastify.test.ts b/packages/cli-core/src/commands/init/frameworks/fastify.test.ts new file mode 100644 index 00000000..dd559493 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/fastify.test.ts @@ -0,0 +1,228 @@ +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { fastify } from "./fastify.ts"; +import type { FileAction, ProjectContext } from "./types.ts"; + +let tempDir: string; + +function makeCtx(overrides?: Partial): ProjectContext { + return { + cwd: tempDir, + framework: { + dep: "fastify", + name: "Fastify", + sdk: "@clerk/fastify", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env.local" as const, + }, + typescript: true, + srcDir: false, + packageManager: "npm", + existingClerk: false, + deps: { fastify: "^5.0.0" }, + envFile: ".env", + ...overrides, + }; +} + +function findAction(actions: FileAction[], path: string): FileAction { + const action = actions.find((a) => a.path === path); + if (!action) { + const paths = actions.map((a) => a.path).join(", "); + throw new Error(`No action found for path "${path}". Available: ${paths}`); + } + return action; +} + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-fastify-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +test("ignores a block-commented creation and registers on the real instance", async () => { + await Bun.write( + join(tempDir, "index.ts"), + `import Fastify from "fastify"; + +/* const app = Fastify(); */ +const server = Fastify(); + +server.listen({ port: 3000 }); +`, + ); + + const plan = await fastify.scaffold(makeCtx()); + + const entry = findAction(plan.actions, "index.ts"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toContain("server.register(clerkPlugin);"); + expect(entry.content).not.toContain("app.register(clerkPlugin);"); + } +}); + +test("matches a type-annotated creation statement", async () => { + await Bun.write( + join(tempDir, "index.ts"), + `import Fastify, { type FastifyInstance } from "fastify"; + +const server: FastifyInstance = Fastify(); + +server.listen({ port: 3000 }); +`, + ); + + const plan = await fastify.scaffold(makeCtx()); + + const entry = findAction(plan.actions, "index.ts"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toContain("server.register(clerkPlugin);"); + } +}); + +test("prints the wiring post-instruction when no creation call is found", async () => { + await Bun.write(join(tempDir, "index.ts"), `console.log("not a server");\n`); + + const plan = await fastify.scaffold(makeCtx()); + + expect(plan.postInstructions.some((i) => i.includes("Register `clerkPlugin`"))).toBe(true); +}); + +test("registers clerkPlugin after a multi-line Fastify() creation", async () => { + await Bun.write( + join(tempDir, "index.ts"), + `import Fastify from "fastify"; + +const fastify = Fastify({ + logger: true, +}); + +fastify.get("/", async () => ({ hello: "world" })); + +await fastify.listen({ port: 8080 }); +`, + ); + + const plan = await fastify.scaffold(makeCtx()); + + const entry = findAction(plan.actions, "index.ts"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toMatch(/import \{\s*clerkPlugin\s*\} from "@clerk\/fastify"/); + expect(entry.content).toContain("fastify.register(clerkPlugin);"); + // Injection lands after the full creation statement, not inside the options object + expect(entry.content).toContain("logger: true,\n});\nfastify.register(clerkPlugin);"); + } +}); + +test("handles the lowercase fastify() factory and custom variable name", async () => { + await Bun.write( + join(tempDir, "server.ts"), + `import fastifyFactory from "fastify"; +const server = fastifyFactory(); +`, + ); + // The factory regex matches `fastify(` / `Fastify(` — a renamed import is not matched. + await Bun.write( + join(tempDir, "index.ts"), + `import fastify from "fastify"; +const server = fastify({ logger: true }); +await server.listen({ port: 3000 }); +`, + ); + + const plan = await fastify.scaffold(makeCtx()); + + const entry = findAction(plan.actions, "index.ts"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toContain("server.register(clerkPlugin);"); + } +}); + +test("injects after a chained withTypeProvider() call", async () => { + await Bun.write( + join(tempDir, "index.ts"), + `import Fastify from "fastify"; + +const app = Fastify({ + logger: true, +}).withTypeProvider(); + +app.listen({ port: 8080 }); +`, + ); + + const plan = await fastify.scaffold(makeCtx()); + + const entry = findAction(plan.actions, "index.ts"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toContain(".withTypeProvider();\napp.register(clerkPlugin);"); + } +}); + +test("handles the multi-line inline-require creation form without splitting the statement", async () => { + await Bun.write( + join(tempDir, "index.js"), + `const fastify = require("fastify")({ + logger: true, +}); + +fastify.listen({ port: 8080 }); +`, + ); + + const plan = await fastify.scaffold(makeCtx({ typescript: false })); + + const entry = findAction(plan.actions, "index.js"); + expect(entry.type).toBe("modify"); + if (entry.type === "modify") { + expect(entry.content).toContain( + '});\nconst { clerkPlugin } = require("@clerk/fastify");\nfastify.register(clerkPlugin);', + ); + } +}); + +test("skips when the entry already uses @clerk/fastify", async () => { + await Bun.write( + join(tempDir, "index.ts"), + `import { clerkPlugin } from "@clerk/fastify"; +import Fastify from "fastify"; +const fastify = Fastify(); +fastify.register(clerkPlugin); +`, + ); + + const plan = await fastify.scaffold(makeCtx()); + + expect(findAction(plan.actions, "index.ts")).toMatchObject({ type: "skip" }); +}); + +test("skips with reason when no Fastify() creation is found", async () => { + await Bun.write(join(tempDir, "index.ts"), `console.log("not a server");\n`); + + const plan = await fastify.scaffold(makeCtx()); + + expect(findAction(plan.actions, "index.ts").type).toBe("skip"); +}); + +test("prints post-instruction when no entry file exists", async () => { + const plan = await fastify.scaffold(makeCtx()); + + expect(plan.actions).toHaveLength(0); + expect(plan.postInstructions.some((i) => i.includes("clerkPlugin"))).toBe(true); +}); + +test("includes env and route-protection post-instructions", async () => { + const plan = await fastify.scaffold(makeCtx()); + + expect(plan.postInstructions.some((i) => i.includes("CLERK_SECRET_KEY"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("getAuth"))).toBe(true); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/fastify.ts b/packages/cli-core/src/commands/init/frameworks/fastify.ts new file mode 100644 index 00000000..f12e44ce --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/fastify.ts @@ -0,0 +1,26 @@ +import { scaffoldServerFramework, type ServerFrameworkConfig } from "./node-server.js"; +import type { FrameworkScaffold } from "./types.js"; + +const FASTIFY_CONFIG: ServerFrameworkConfig = { + clerkPackage: "@clerk/fastify", + clerkImport: "clerkPlugin", + // Matches `Fastify(...)`/`fastify(...)` and the inline-require form + // `require("fastify")(...)`, with an optional type annotation + // (`const server: FastifyInstance = Fastify()`). + creationPattern: + /(?:const|let|var)\s+(\w+)(?:\s*:\s*[\w$.]+(?:<[^>]*>)?)?\s*=\s*(?:[Ff]astify|require\(\s*["']fastify["']\s*\))\s*\(/, + frameworkPackage: "fastify", + attachStatement: (appVar) => `${appVar}.register(clerkPlugin);`, + description: "Register clerkPlugin on Fastify app", + docsUrl: "https://clerk.com/docs/fastify/getting-started/quickstart", + manualWiring: "Register `clerkPlugin` from @clerk/fastify on your Fastify instance.", +}; + +export const fastify: FrameworkScaffold = { + name: "Fastify", + dep: "fastify", + + matches: (ctx) => ctx.framework.dep === "fastify", + + scaffold: (ctx) => scaffoldServerFramework(ctx, FASTIFY_CONFIG), +}; diff --git a/packages/cli-core/src/commands/init/frameworks/ios.test.ts b/packages/cli-core/src/commands/init/frameworks/ios.test.ts new file mode 100644 index 00000000..2df6f467 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/ios.test.ts @@ -0,0 +1,55 @@ +import { test, expect } from "bun:test"; +import { ios } from "./ios.ts"; +import type { ProjectContext } from "./types.ts"; + +function makeCtx(): ProjectContext { + return { + cwd: "/tmp/ios-app", + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + typescript: false, + srcDir: false, + packageManager: "npm", + existingClerk: false, + deps: {}, + envFile: ".env", + }; +} + +test("matches only the ios framework", () => { + const ctx = makeCtx(); + expect(ios.matches(ctx)).toBe(true); + expect(ios.matches({ ...ctx, framework: { ...ctx.framework, dep: "android" } })).toBe(false); +}); + +test("writes no files and prints the quickstart steps", async () => { + const plan = await ios.scaffold(makeCtx()); + + expect(plan.actions).toHaveLength(0); + expect(plan.postInstructions.some((i) => i.includes("github.com/clerk/clerk-ios"))).toBe(true); + expect( + plan.postInstructions.some((i) => i.includes("ClerkKit") && i.includes("ClerkKitUI")), + ).toBe(true); + expect( + plan.postInstructions.some((i) => i.includes("dashboard.clerk.com/~/native-applications")), + ).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("Clerk.configure"))).toBe(true); + // The official quickstart requires injecting Clerk into the SwiftUI + // environment — views read it back via @Environment(Clerk.self). + expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("docs/ios/getting-started/quickstart"))).toBe( + true, + ); +}); + +test("references the project's env file for the publishable key", async () => { + const plan = await ios.scaffold({ ...makeCtx(), envFile: ".env.local" }); + + expect(plan.postInstructions.some((i) => i.includes(".env.local"))).toBe(true); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts new file mode 100644 index 00000000..0562a84e --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -0,0 +1,34 @@ +import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; + +/** + * iOS (Swift) support for `clerk init`. + * + * The Clerk iOS SDK ships via Swift Package Manager and the publishable key is + * 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. + * + * Docs: https://clerk.com/docs/ios/getting-started/quickstart + */ +export const ios: FrameworkScaffold = { + name: "iOS (Swift)", + dep: "ios", + + matches: (ctx) => ctx.framework.dep === "ios", + + async scaffold(ctx: ProjectContext): Promise { + return { + actions: [], + postInstructions: [ + "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\``, + "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/node-server.test.ts b/packages/cli-core/src/commands/init/frameworks/node-server.test.ts new file mode 100644 index 00000000..620fd579 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/node-server.test.ts @@ -0,0 +1,58 @@ +import { test, expect, describe } from "bun:test"; +import { findStatementEnd } from "./node-server.ts"; +import { maskCommentsAndStrings } from "./source-scan.ts"; + +describe("findStatementEnd", () => { + test("ends at a semicolon on the same line", () => { + const src = `const app = express();\napp.listen(3000);`; + const end = findStatementEnd(maskCommentsAndStrings(src), 0); + expect(src.slice(0, end)).toBe("const app = express();"); + }); + + test("ends at a newline when there is no semicolon", () => { + const src = `const app = express()\napp.listen(3000)`; + const end = findStatementEnd(maskCommentsAndStrings(src), 0); + expect(src.slice(0, end)).toBe("const app = express()"); + }); + + test("spans a multi-line options object", () => { + const src = `const fastify = Fastify({\n logger: true,\n});\nnext();`; + const end = findStatementEnd(maskCommentsAndStrings(src), 0); + expect(src.slice(0, end)).toBe("const fastify = Fastify({\n logger: true,\n});"); + }); + + test("continues through a chained call on the next line", () => { + const src = `const app = Fastify({})\n .withTypeProvider();\nnext();`; + const end = findStatementEnd(maskCommentsAndStrings(src), 0); + expect(src.slice(0, end)).toBe("const app = Fastify({})\n .withTypeProvider();"); + }); + + test("ignores brackets inside string literals", () => { + const src = `const app = express();\nconsole.log("(unbalanced");`; + const end = findStatementEnd(maskCommentsAndStrings(src), 0); + expect(src.slice(0, end)).toBe("const app = express();"); + }); + + test("ignores brackets inside template literals", () => { + const src = "const x = f(`open ( paren`);\nnext();"; + const end = findStatementEnd(maskCommentsAndStrings(src), 0); + expect(src.slice(0, end)).toBe("const x = f(`open ( paren`);"); + }); + + test("ends at the newline when a trailing comment has an apostrophe", () => { + const src = `const app = express() // don't touch\napp.listen(3000)`; + const end = findStatementEnd(maskCommentsAndStrings(src), 0); + expect(src.slice(0, end)).toBe("const app = express() // don't touch"); + }); + + test("ignores brackets inside comments", () => { + const src = `const app = express() // see foo({\napp.listen(3000)`; + const end = findStatementEnd(maskCommentsAndStrings(src), 0); + expect(src.slice(0, end)).toBe("const app = express() // see foo({"); + }); + + test("returns content length for an unterminated statement", () => { + const src = `const app = express(`; + expect(findStatementEnd(maskCommentsAndStrings(src), 0)).toBe(src.length); + }); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/node-server.ts b/packages/cli-core/src/commands/init/frameworks/node-server.ts new file mode 100644 index 00000000..9c9e2cbf --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/node-server.ts @@ -0,0 +1,187 @@ +/** + * Shared scaffolding for Node.js server frameworks (Express, Fastify). + * + * Both follow the same quickstart shape: find the server entry file, add a + * Clerk import, and attach the Clerk middleware/plugin right after the app + * instance is created. Only the package name, creation pattern, and attach + * statement differ, so both scaffolders delegate here. + */ +import { join } from "node:path"; +import { safeAddImport } from "./transformations.js"; +import { maskCommentsAndStrings } from "./source-scan.js"; +import { findFirstFile } from "./helpers.js"; +import type { FileAction, ProjectContext, ScaffoldPlan } from "./types.js"; + +export type ServerFrameworkConfig = { + /** Clerk SDK package, e.g. "@clerk/express". */ + clerkPackage: string; + /** Named export to import from the Clerk package, e.g. "clerkMiddleware". */ + clerkImport: string; + /** Matches the app-creation statement and captures the variable name. */ + creationPattern: RegExp; + /** The framework package to co-locate a CJS require next to, e.g. "express". */ + frameworkPackage: string; + /** Statement attaching Clerk to the app, given the captured variable name. */ + attachStatement(appVar: string): string; + /** Human-readable description for the file action. */ + description: string; + /** Quickstart URL for this framework. */ + docsUrl: string; + /** How to wire Clerk by hand, printed when the entry couldn't be wired. */ + manualWiring: string; +}; + +/** + * The entry file's outcome. `wired` says whether Clerk was actually attached, + * so callers never have to infer it from a human-readable skip reason. + */ +type ServerEntry = { action: FileAction | null; wired: boolean }; + +/** Entry file candidates for Node server projects, most specific first. */ +const ENTRY_BASENAMES = ["index", "server", "app", "main"]; +const ENTRY_EXTS = ["ts", "mts", "js", "mjs", "cjs"]; + +function entryCandidates(): string[] { + const names = ENTRY_BASENAMES.flatMap((base) => ENTRY_EXTS.map((ext) => `${base}.${ext}`)); + return [...names.map((name) => `src/${name}`), ...names]; +} + +/** Directories that contain build output, never source to scaffold into. */ +const BUILD_DIRS = new Set(["dist", "build", "out", "lib"]); + +/** Read the package.json "main" field so custom entry points are found first. */ +async function readPackageMain(cwd: string): Promise { + try { + const pkg = await Bun.file(join(cwd, "package.json")).json(); + if (typeof pkg.main !== "string") return null; + + const main = pkg.main.replace(/^\.\//, ""); + // "main" often points at compiled output (e.g. dist/index.js) — skip it so + // we scaffold into source, not build artifacts. + if (BUILD_DIRS.has(main.split("/")[0]!)) return null; + return main; + } catch { + return null; + } +} + +async function findEntryFile(ctx: ProjectContext): Promise { + const main = await readPackageMain(ctx.cwd); + const candidates = main ? [main, ...entryCandidates()] : entryCandidates(); + return findFirstFile(ctx.cwd, candidates); +} + +/** + * Find the end of the statement starting at `startIdx` — the first `;` or + * newline at bracket depth 0 that is not followed by a chained `.` call. + * Takes masked source so brackets and terminators inside comments or string + * literals don't count; the returned index applies to the original text. + */ +export function findStatementEnd(masked: string, startIdx: number): number { + let depth = 0; + + for (let i = startIdx; i < masked.length; i++) { + const char = masked[i]!; + + if (char === "(" || char === "[" || char === "{") { + depth++; + } else if (char === ")" || char === "]" || char === "}") { + depth--; + } else if (depth === 0 && (char === ";" || char === "\n")) { + const rest = masked.slice(i + 1); + const nextChar = rest.trimStart()[0]; + // A leading `.` or `;` continues the statement (chained call / same line). + if (nextChar === "." || (char === "\n" && rest.trimStart().startsWith(";"))) continue; + return char === ";" ? i + 1 : i; + } + } + + return masked.length; +} + +function isCommonJs(masked: string): boolean { + return masked.includes("require(") && !/^\s*import\s/m.test(masked); +} + +/** Scaffold the Clerk middleware/plugin into a Node server entry file. */ +async function scaffoldServerEntry( + ctx: ProjectContext, + config: ServerFrameworkConfig, +): Promise { + const entryPath = await findEntryFile(ctx); + if (!entryPath) return { action: null, wired: false }; + + const content = await Bun.file(join(ctx.cwd, entryPath)).text(); + + // Only the framework's own SDK counts as already-configured — an unrelated + // Clerk package (e.g. @clerk/backend for manual token checks) must not + // suppress the middleware wiring. This intentionally checks raw content: + // the package name normally lives inside an import specifier or `require(...)` + // string, which masking would blank out, breaking detection of legitimately + // already-wired projects. + if (content.includes(config.clerkPackage)) { + const action: FileAction = { + type: "skip", + path: entryPath, + skipReason: `Already has ${config.clerkPackage}`, + }; + return { action, wired: true }; + } + + // A creation statement inside a comment or string (e.g. a commented-out + // `const app = express();`) must not hijack the insertion point. Matching + // runs on the real content — the pattern may legitimately span a string + // like `require("express")` — but a match *starting* in masked territory + // is commented-out/quoted code and is rejected. + const masked = maskCommentsAndStrings(content); + const creation = new RegExp(config.creationPattern.source, "g"); + const match = [...content.matchAll(creation)].find((m) => masked[m.index] === content[m.index]); + if (!match) { + const action: FileAction = { + type: "skip", + path: entryPath, + skipReason: `Could not find where the ${config.frameworkPackage} app is created`, + }; + return { action, wired: false }; + } + + const appVar = match[1]!; + const statementEnd = findStatementEnd(masked, match.index); + + // CJS files get the require right next to the attach statement — inserting + // relative to the framework's own require line could land inside a + // multi-line creation statement like `require("fastify")({\n ... })`. + const cjs = isCommonJs(masked); + const attach = cjs + ? `\nconst { ${config.clerkImport} } = require("${config.clerkPackage}");\n${config.attachStatement(appVar)}` + : `\n${config.attachStatement(appVar)}`; + const injected = content.slice(0, statementEnd) + attach + content.slice(statementEnd); + + const action: FileAction = { + path: entryPath, + type: "modify", + content: cjs ? injected : safeAddImport(injected, config.clerkPackage, config.clerkImport), + description: config.description, + }; + return { action, wired: true }; +} + +/** + * Build the full scaffold plan for a Node server framework: wire the entry + * file and print the setup instructions that wiring can't cover. + */ +export async function scaffoldServerFramework( + ctx: ProjectContext, + config: ServerFrameworkConfig, +): Promise { + const { action, wired } = await scaffoldServerEntry(ctx, config); + + return { + 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} index.js\``, + `Protect routes with \`getAuth()\` and \`clerkClient\`: ${config.docsUrl}`, + ], + }; +} diff --git a/packages/cli-core/src/commands/init/frameworks/react-router.test.ts b/packages/cli-core/src/commands/init/frameworks/react-router.test.ts index f76e831e..4b9d0fa6 100644 --- a/packages/cli-core/src/commands/init/frameworks/react-router.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/react-router.test.ts @@ -35,6 +35,44 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); +test("keeps a trailing multi-line import intact when adding the middleware export", async () => { + // The stock React Router template ends with this multi-line import; splicing + // the middleware export into its brace block produced invalid syntax. + await mkdir(join(tempDir, "app"), { recursive: true }); + await Bun.write( + join(tempDir, "app/root.tsx"), + `import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "react-router"; + +export default function Root() { + return ; +} +`, + ); + + const plan = await reactRouter.scaffold(makeCtx({ isBootstrap: true })); + const rootAction = plan.actions.find((action) => action.path === "app/root.tsx"); + + if (rootAction?.type !== "modify") { + throw new Error("Expected root action to modify app/root.tsx"); + } + + expect(rootAction.content).toContain(`} from "react-router";`); + expect(rootAction.content).toContain("export const middleware = [clerkMiddleware()];"); + // The export lands after the import block, never inside its braces + expect(rootAction.content.indexOf("export const middleware")).toBeGreaterThan( + rootAction.content.indexOf(`} from "react-router";`), + ); + expect(() => + new Bun.Transpiler({ loader: "tsx" }).transformSync(rootAction.content), + ).not.toThrow(); +}); + test("adds middleware, loader, and provider to app/root.tsx", async () => { await mkdir(join(tempDir, "app"), { recursive: true }); await Bun.write( diff --git a/packages/cli-core/src/commands/init/frameworks/source-scan.test.ts b/packages/cli-core/src/commands/init/frameworks/source-scan.test.ts new file mode 100644 index 00000000..fce5578e --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/source-scan.test.ts @@ -0,0 +1,77 @@ +import { test, expect, describe } from "bun:test"; +import { maskCommentsAndStrings } from "./source-scan.ts"; + +describe("maskCommentsAndStrings", () => { + test("masks string contents but keeps delimiters and length", () => { + const source = `const a = "hidden";\n`; + + const masked = maskCommentsAndStrings(source); + + expect(masked).toBe(`const a = " ";\n`); + expect(masked).toHaveLength(source.length); + }); + + test("masks line and block comments", () => { + const source = `// secret\nconst a = 1;\n/* more */\n`; + + const masked = maskCommentsAndStrings(source); + + expect(masked).toContain("const a = 1;"); + expect(masked).not.toContain("secret"); + expect(masked).not.toContain("more"); + }); + + // An unpaired quote must not swallow the rest of the file: real JS strings + // never span lines, so a lone quote is punctuation (JSX text, a regex). + test.each([ + ["apostrophe in JSX text", `Don't panic\nconst app = express();\n`], + ["quote inside a regex literal", `const s = t.replace(/"/g, "");\nconst app = express();\n`], + ["apostrophe inside a regex literal", `const re = /don't/;\nconst app = express();\n`], + ])("keeps code visible after %s", (_name, source) => { + expect(maskCommentsAndStrings(source)).toContain("const app = express();"); + }); + + test("still masks multi-line template literals", () => { + const source = "const t = `line one\nline two`;\nconst app = x();\n"; + + const masked = maskCommentsAndStrings(source); + + expect(masked).not.toContain("line one"); + expect(masked).toContain("const app = x();"); + }); +}); + +describe("maskCommentsAndStrings — template substitutions", () => { + test.each([ + { + name: "blanks the literal text inside a nested template substitution", + source: "const a = `outer ${`inner`} end`;\nconst REAL_CODE = 1;\n", + mustNotContain: "inner", + mustContain: "REAL_CODE", + }, + { + name: "keeps an object literal's braces from ending the substitution early", + source: "const a = `x ${({ b: 1 }).b} y`;\nconst REAL_CODE = 1;\n", + mustNotContain: "x ${", + mustContain: "({ b: 1 }).b", + }, + { + name: "handles a string literal inside a substitution", + source: 'const a = `x ${"}"} y`;\nconst REAL_CODE = 1;\n', + mustNotContain: '"}"', + mustContain: "REAL_CODE", + }, + { + name: "handles a comment inside a substitution", + source: "const a = `x ${/* } */ 1} y`;\nconst REAL_CODE = 1;\n", + mustNotContain: "} */", + mustContain: "REAL_CODE", + }, + ])("$name", ({ source, mustNotContain, mustContain }) => { + const masked = maskCommentsAndStrings(source); + + expect(masked).not.toContain(mustNotContain); + expect(masked).toContain(mustContain); + expect(masked.length).toBe(source.length); + }); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/source-scan.ts b/packages/cli-core/src/commands/init/frameworks/source-scan.ts new file mode 100644 index 00000000..49f87b81 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/source-scan.ts @@ -0,0 +1,146 @@ +/** + * Scanning support for the source-code transformations in `transformations.ts` + * and the framework scaffolders. + * + * Scaffolding means finding a place in someone's source file — the last import, + * the app-creation statement, the layout's return — and inserting code there. + * Searching raw text finds those anchors inside comments and string literals + * too, which is how a commented-out `const app = express()` ends up receiving + * the middleware. Masking removes that whole class of mistake: the returned + * string has every comment and string *body* replaced with spaces, so a plain + * `indexOf`/regex over it only ever matches real code. + * + * The invariant every caller relies on: the masked string has the same length + * and the same newline positions as the source, so an index found in the mask + * can be used directly against the original text. + */ + +/** + * Index of the quote closing the one at `openIdx`, or null when it is unpaired + * on that line. A JS string literal never contains a raw newline, so a lone + * quote is punctuation — JSX text (`Don't`) or a quote inside a regex literal + * (`/"/g`) — and must not be treated as the start of a string. + */ +function findClosingQuoteOnLine(source: string, openIdx: number, quote: string): number | null { + for (let i = openIdx + 1; i < source.length; i++) { + const char = source[i]!; + if (char === "\n") return null; + if (char === "\\") i++; + else if (char === quote) return i; + } + return null; +} + +/** + * Replace the contents of comments and string/template literals with spaces, + * preserving length and newlines (delimiters are kept). + * + * Template literals get recursive handling: a `${...}` substitution is real + * code (not masked), and it can itself contain strings, comments, nested + * template literals, and braces (e.g. an object literal) — so it's scanned + * with the same code-scanning logic, stopping at the substitution's own + * closing `}` rather than the first `}` encountered. + */ +export function maskCommentsAndStrings(source: string): string { + const out = source.split(""); + let i = 0; + + const blank = () => { + if (source[i] !== "\n") out[i] = " "; + i++; + }; + + function scanString(quote: string): void { + const closeIdx = findClosingQuoteOnLine(source, i, quote); + if (closeIdx === null) { + i++; // unpaired — punctuation (JSX text, a regex literal), not a string + return; + } + i++; // keep the opening delimiter + while (i < closeIdx) blank(); + i++; // keep the closing delimiter + } + + function scanTemplate(): void { + i++; // keep the opening backtick + while (i < source.length && source[i] !== "`") { + if (source[i] === "$" && source[i + 1] === "{") { + i += 2; // keep `${` — real code, not masked + scanCode(true); + if (i < source.length && source[i] === "}") i++; // keep the closing `}` + continue; + } + const escaped = source[i] === "\\"; + blank(); + if (escaped && i < source.length) blank(); + } + if (i < source.length) i++; // keep the closing backtick + } + + // Scans real code. When `stopAtOwnBrace` is set (inside a `${...}` + // substitution), returns as soon as it sees the `}` that closes this + // substitution — braces opened within it (e.g. `{ a: 1 }`) are tracked so + // they don't end the substitution early. + function scanCode(stopAtOwnBrace: boolean): void { + let braceDepth = 0; + while (i < source.length) { + const char = source[i]!; + const next = source[i + 1]; + + if (stopAtOwnBrace && char === "}" && braceDepth === 0) return; + + if (char === "/" && next === "/") { + while (i < source.length && source[i] !== "\n") blank(); + } else if (char === "/" && next === "*") { + blank(); + blank(); + while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) blank(); + if (i < source.length) { + blank(); + blank(); + } + } else if (char === '"' || char === "'") { + scanString(char); + } else if (char === "`") { + scanTemplate(); + } else if (char === "{") { + braceDepth++; + i++; + } else if (char === "}") { + braceDepth--; + i++; + } else { + i++; + } + } + } + + scanCode(false); + return out.join(""); +} + +/** + * Index just past the delimiter matching the one at `openIdx`, or null when it + * never closes. Takes masked source so delimiters inside comments, strings, or + * JSX text don't count toward the depth. + */ +export function findMatchingDelimiter( + masked: string, + openIdx: number, + open: string, + close: string, +): number | null { + let depth = 0; + + for (let i = openIdx; i < masked.length; i++) { + const char = masked[i]!; + + if (char === open) depth++; + else if (char === close) { + depth--; + if (depth === 0) return i + 1; + } + } + + return null; +} diff --git a/packages/cli-core/src/commands/init/frameworks/transformations.test.ts b/packages/cli-core/src/commands/init/frameworks/transformations.test.ts new file mode 100644 index 00000000..096299e6 --- /dev/null +++ b/packages/cli-core/src/commands/init/frameworks/transformations.test.ts @@ -0,0 +1,49 @@ +import { test, expect, describe } from "bun:test"; +import { insertAfterLastImport } from "./transformations.ts"; + +describe("insertAfterLastImport", () => { + test("inserts after a single-line import", () => { + const source = `import { a } from "a";\nconst x = 1;\n`; + + const result = insertAfterLastImport(source, "SNIPPET\n"); + + expect(result).toBe(`import { a } from "a";\nSNIPPET\nconst x = 1;\n`); + }); + + test("inserts after the end of a multi-line import statement", () => { + const source = `import { Slot } from "expo-router"; +import { + useFonts, +} from "expo-font"; + +export default function RootLayout() {} +`; + + const result = insertAfterLastImport(source, "\nconst key = 1;\n"); + + // The import statement survives intact… + expect(result).toContain('import {\n useFonts,\n} from "expo-font";'); + // …and the snippet lands after it, not between its braces + expect(result.indexOf("const key = 1;")).toBeGreaterThan(result.indexOf('} from "expo-font";')); + }); + + test("inserts after a trailing side-effect import", () => { + const source = `import { a } from "a";\nimport "./polyfills";\nconst x = 1;\n`; + + const result = insertAfterLastImport(source, "SNIPPET\n"); + + expect(result.indexOf("SNIPPET")).toBeGreaterThan(result.indexOf('"./polyfills"')); + expect(result.indexOf("SNIPPET")).toBeLessThan(result.indexOf("const x = 1;")); + }); + + test("ignores the word import inside a comment", () => { + const source = `import { a } from "a"; +// import { b } from "b"; (removed) +const x = 1; +`; + + const result = insertAfterLastImport(source, "SNIPPET\n"); + + expect(result.indexOf("SNIPPET")).toBeLessThan(result.indexOf("// import")); + }); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/transformations.ts b/packages/cli-core/src/commands/init/frameworks/transformations.ts index 8eff9afb..9efdcd3c 100644 --- a/packages/cli-core/src/commands/init/frameworks/transformations.ts +++ b/packages/cli-core/src/commands/init/frameworks/transformations.ts @@ -4,6 +4,7 @@ * Used by framework scaffolders for import injection, provider wrapping, and indentation. */ import { parseModule } from "magicast"; +import { maskCommentsAndStrings } from "./source-scan.js"; /** Check if file content already imports from a @clerk/ package. */ export function hasClerkImport(content: string): boolean { @@ -25,17 +26,30 @@ export function safeAddImport(content: string, source: string, imported: string) try { const mod = parseModule(content); mod.imports.$add({ from: source, imported, local: imported }); - return mod.generate().code; + // magicast builds the import node itself and prints it without brace + // spacing, and no printer option overrides that. Projects without a + // formatter would keep `import {x}`, so re-space the one statement we + // added — anchored on `from` so only an import statement can match. + return mod.generate().code.replace(`import {${imported}} from`, `import { ${imported} } from`); } catch { return `import { ${imported} } from "${source}";\n${content}`; } } +// Spans a complete import statement, including multi-line named-import blocks +// and side-effect imports (`import "./x"`), through its module specifier. +const IMPORT_STATEMENT = /^[ \t]*import\b(?:[\s\S]*?from)?\s*["'][^"'\n]*["'][ \t]*;?/gm; + /** Insert a snippet after the last import statement in a source file. */ export function insertAfterLastImport(source: string, snippet: string): string { - const lastImportIdx = source.lastIndexOf("import "); - const lineEnd = source.indexOf("\n", lastImportIdx); - if (lineEnd === -1) return source; + // Match against masked source so `import` inside comments/strings can't + // hijack the insertion point, and multi-line imports are spanned fully. + let last: RegExpExecArray | null = null; + for (const match of maskCommentsAndStrings(source).matchAll(IMPORT_STATEMENT)) last = match; + if (!last) return snippet + source; + + const lineEnd = source.indexOf("\n", last.index + last[0].length); + if (lineEnd === -1) return `${source}\n${snippet}`; return source.slice(0, lineEnd + 1) + snippet + source.slice(lineEnd + 1); } diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index a99fd2f5..2d396c03 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -932,6 +932,67 @@ describe("init", () => { expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env.local", cwd: mockCtx.cwd }); }); + test("native framework skips npm SDK install but still pulls env keys", async () => { + setup({ email: "test@test.com" }); + + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + envFile: ".env", + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], + }); + + await init({ yes: true }); + + expect(heuristics.installSdk).not.toHaveBeenCalled(); + expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: iosCtx.cwd }); + }); + + test("--framework ios without package.json does not trigger bootstrap", async () => { + setup({ email: "test@test.com" }); + + const iosFramework = { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }; + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + envFile: ".env", + framework: iosFramework, + }; + spyOn(frameworkMod, "lookupFramework").mockReturnValue(iosFramework); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(context, "hasPackageJson").mockResolvedValue(false); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], + }); + + await init({ yes: true, framework: "ios" }); + + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + expect(pullMod.pull).toHaveBeenCalled(); + }); + test("bootstrap passes project dir to link, not parent cwd", async () => { setup({ email: "test@test.com" }); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 40abe1e5..e366e5db 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -6,7 +6,12 @@ import { pull } from "../env/pull.js"; import { isAgent } from "../../mode.js"; import { dim, bold } from "../../lib/color.js"; import { throwUserAbort, throwUsageError, CliError, errorMessage } from "../../lib/errors.js"; -import { lookupFramework, FRAMEWORK_NAMES, type FrameworkInfo } from "../../lib/framework.js"; +import { + lookupFramework, + isNpmFramework, + FRAMEWORK_NAMES, + type FrameworkInfo, +} from "../../lib/framework.js"; import { resolveProfile } from "../../lib/config.js"; import { deriveProjectName } from "../../lib/project-name.js"; import { log } from "../../lib/log.js"; @@ -191,7 +196,9 @@ async function resolveProjectContext( // When --framework is provided, gatherContext will always return a truthy // context because the override skips detectFramework. Guard against this in // blank directories so the bootstrap path (e.g. create-next-app) still runs. - if (frameworkOverride && !(await hasPackageJson(cwd))) { + // Native platforms (iOS/Android) never have a package.json — a missing one + // does not mean a blank directory, so they skip the bootstrap shortcut. + if (frameworkOverride && isNpmFramework(frameworkOverride) && !(await hasPackageJson(cwd))) { return bootstrapAndDetect(cwd, frameworkOverride, overrides); } @@ -370,9 +377,11 @@ async function detectAndInstall( if (ctx.existingClerk) { log.info(dim(`${ctx.framework.sdk} is already installed`)); - } else { + } else if (isNpmFramework(ctx.framework)) { await installSdk(ctx); } + // Non-npm ecosystems (Swift Package Manager, Gradle) can't be installed by a + // package manager here — the framework's scaffold plan prints install steps. return await scaffoldAndWrite(cwd, ctx, skipConfirm); } diff --git a/packages/cli-core/src/commands/init/scaffold.ts b/packages/cli-core/src/commands/init/scaffold.ts index 2eafa6ed..4eff1ef6 100644 --- a/packages/cli-core/src/commands/init/scaffold.ts +++ b/packages/cli-core/src/commands/init/scaffold.ts @@ -7,6 +7,11 @@ import { tanstackStart } from "./frameworks/tanstack-start.js"; import { astro } from "./frameworks/astro.js"; import { vue } from "./frameworks/vue.js"; import { javascriptVite } from "./frameworks/javascript.js"; +import { expo } from "./frameworks/expo.js"; +import { express } from "./frameworks/express.js"; +import { fastify } from "./frameworks/fastify.js"; +import { ios } from "./frameworks/ios.js"; +import { android } from "./frameworks/android.js"; import { parseMajorVersion } from "./frameworks/helpers.js"; import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./frameworks/types.js"; @@ -20,6 +25,11 @@ const SCAFFOLDERS = [ astro, vue, javascriptVite, + expo, + express, + fastify, + ios, + android, ] satisfies FrameworkScaffold[]; /** diff --git a/packages/cli-core/src/lib/framework.test.ts b/packages/cli-core/src/lib/framework.test.ts index 6f794a77..2a8a7e75 100644 --- a/packages/cli-core/src/lib/framework.test.ts +++ b/packages/cli-core/src/lib/framework.test.ts @@ -1,8 +1,14 @@ import { test, expect, describe, beforeEach, afterEach } 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 { detectPublishableKeyName, detectFramework } from "./framework.ts"; +import { + detectPublishableKeyName, + detectFramework, + lookupFramework, + isNpmFramework, + FRAMEWORK_NAMES, +} from "./framework.ts"; function writePkg(dir: string, deps: Record, devDeps?: Record) { return Bun.write( @@ -154,6 +160,85 @@ describe("detectFramework", () => { await writePkg(tempDir, {}, { next: "15.0.0" }); expect((await detectFramework(tempDir))!.name).toBe("Next.js"); }); + + // --- Native platforms (no package.json) --- + + test("detects iOS via .xcodeproj bundle", async () => { + await mkdir(join(tempDir, "MyApp.xcodeproj")); + const fw = await detectFramework(tempDir); + expect(fw!.name).toBe("iOS (Swift)"); + expect(fw!.dep).toBe("ios"); + expect(fw!.ecosystem).toBe("swift"); + expect(fw!.envVar).toBe("CLERK_PUBLISHABLE_KEY"); + }); + + test("detects iOS via .xcworkspace bundle", async () => { + await mkdir(join(tempDir, "MyApp.xcworkspace")); + expect((await detectFramework(tempDir))!.dep).toBe("ios"); + }); + + test("does not detect iOS from a bare Package.swift", async () => { + await Bun.write(join(tempDir, "Package.swift"), "// swift-tools-version:6.0"); + expect(await detectFramework(tempDir)).toBeNull(); + }); + + test("detects Android via app/src/main/AndroidManifest.xml", async () => { + await Bun.write(join(tempDir, "app/src/main/AndroidManifest.xml"), ""); + const fw = await detectFramework(tempDir); + expect(fw!.name).toBe("Android (Kotlin)"); + expect(fw!.dep).toBe("android"); + expect(fw!.ecosystem).toBe("gradle"); + }); + + test("detects Android via src/main/AndroidManifest.xml", async () => { + await Bun.write(join(tempDir, "src/main/AndroidManifest.xml"), ""); + expect((await detectFramework(tempDir))!.dep).toBe("android"); + }); + + test("does not detect Android from a bare build.gradle", async () => { + await Bun.write(join(tempDir, "build.gradle.kts"), "plugins {}"); + expect(await detectFramework(tempDir)).toBeNull(); + }); + + test("does not detect iOS from a plain file named like an Xcode bundle", async () => { + // Real .xcodeproj/.xcworkspace entries are always directory bundles. + await Bun.write(join(tempDir, "notes.xcodeproj"), "not a bundle"); + expect(await detectFramework(tempDir)).toBeNull(); + }); + + test("does not detect Android when the manifest path is a directory", async () => { + await mkdir(join(tempDir, "app/src/main/AndroidManifest.xml"), { recursive: true }); + expect(await detectFramework(tempDir)).toBeNull(); + }); + + test("prefers npm framework over native markers (prebuilt Expo app)", async () => { + await writePkg(tempDir, { expo: "52.0.0", react: "18.0.0" }); + await Bun.write(join(tempDir, "app/src/main/AndroidManifest.xml"), ""); + await mkdir(join(tempDir, "MyApp.xcodeproj")); + expect((await detectFramework(tempDir))!.name).toBe("Expo"); + }); + + test("falls back to native detection when package.json has no framework dep", async () => { + await writePkg(tempDir, { lodash: "4.0.0" }); + await mkdir(join(tempDir, "MyApp.xcodeproj")); + expect((await detectFramework(tempDir))!.dep).toBe("ios"); + }); +}); + +describe("native framework lookup", () => { + test.each(["ios", "android"])("lookupFramework resolves %s", (name) => { + expect(lookupFramework(name)!.dep).toBe(name); + }); + + test.each(["ios", "android"])("FRAMEWORK_NAMES includes %s", (name) => { + expect(FRAMEWORK_NAMES).toContain(name); + }); + + test("isNpmFramework distinguishes ecosystems", () => { + expect(isNpmFramework(lookupFramework("next")!)).toBe(true); + expect(isNpmFramework(lookupFramework("ios")!)).toBe(false); + expect(isNpmFramework(lookupFramework("android")!)).toBe(false); + }); }); describe("detectPublishableKeyName", () => { diff --git a/packages/cli-core/src/lib/framework.ts b/packages/cli-core/src/lib/framework.ts index 463fbc78..fc12b4a9 100644 --- a/packages/cli-core/src/lib/framework.ts +++ b/packages/cli-core/src/lib/framework.ts @@ -4,9 +4,17 @@ */ import { join } from "node:path"; +import { readdir } from "node:fs/promises"; import { log } from "./log.ts"; +/** Where the framework's Clerk SDK is published. Drives how `clerk init` + * installs the SDK: npm frameworks run the package manager, native + * ecosystems (Swift Package Manager, Gradle) print manual install steps. */ +export type FrameworkEcosystem = "npm" | "swift" | "gradle"; + export interface FrameworkInfo { + /** npm dependency that identifies the framework, or a stable id for + * non-npm platforms (e.g. "ios", "android"). Also the `--framework` value. */ dep: string; name: string; sdk: string; @@ -23,6 +31,12 @@ export interface FrameworkInfo { * 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; +} + +export function isNpmFramework(fw: Pick): boolean { + return (fw.ecosystem ?? "npm") === "npm"; } // Order matters: more specific frameworks first (e.g. next before react, nuxt before vue) @@ -112,6 +126,43 @@ export const FRAMEWORK_MAP: FrameworkInfo[] = [ }, ]; +type NativeFrameworkEntry = FrameworkInfo & { + /** Marker paths (relative to cwd) that identify the platform. A leading + * "*" matches any directory-entry name with that suffix (e.g. "*.xcodeproj"). */ + markers: string[]; +}; + +// Native mobile platforms have no package.json, so they are detected via +// project marker files instead of npm dependencies. Only consulted when no +// npm framework matches (an Expo project with prebuilt ios/ + android/ dirs +// must still detect as Expo). +export const NATIVE_FRAMEWORK_MAP: NativeFrameworkEntry[] = [ + { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env", + ecosystem: "swift", + // Xcode project/workspace bundles only — a bare Package.swift could be a + // server-side Swift or library package, which the Clerk iOS SDK doesn't target. + markers: ["*.xcodeproj", "*.xcworkspace"], + }, + { + dep: "android", + name: "Android (Kotlin)", + sdk: "com.clerk:clerk-android-ui", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env", + ecosystem: "gradle", + // AndroidManifest.xml is the unambiguous signal — build.gradle alone would + // also match non-Android JVM projects (e.g. Spring, Kotlin backends). + markers: ["app/src/main/AndroidManifest.xml", "src/main/AndroidManifest.xml"], + }, +]; + +const ALL_FRAMEWORKS: FrameworkInfo[] = [...FRAMEWORK_MAP, ...NATIVE_FRAMEWORK_MAP]; + const FRAMEWORK_ALIASES: Record = { "tanstack-start": "@tanstack/react-start", javascript: "vite", @@ -120,10 +171,10 @@ const FRAMEWORK_ALIASES: Record = { export function lookupFramework(name: string): FrameworkInfo | null { const dep = FRAMEWORK_ALIASES[name] ?? name; - return FRAMEWORK_MAP.find((fw) => fw.dep === dep) ?? null; + return ALL_FRAMEWORKS.find((fw) => fw.dep === dep) ?? null; } -export const FRAMEWORK_NAMES = FRAMEWORK_MAP.map((fw) => { +export const FRAMEWORK_NAMES = ALL_FRAMEWORKS.map((fw) => { const alias = Object.entries(FRAMEWORK_ALIASES).find(([, v]) => v === fw.dep); return alias ? alias[0] : fw.dep; }); @@ -143,21 +194,54 @@ export async function readDeps(cwd: string): Promise | nu } } -export async function detectFramework(cwd: string): Promise { - const allDeps = await readDeps(cwd); - if (!allDeps) { - log.debug(`framework: no package.json at ${cwd} or unable to parse`); - return null; +async function matchesMarker(cwd: string, marker: string): Promise { + if (!marker.startsWith("*")) { + return Bun.file(join(cwd, marker)).exists(); } - for (const fw of FRAMEWORK_MAP) { - if (fw.dep in allDeps) { - log.debug(`framework: detected "${fw.name}" via dependency "${fw.dep}"`); + const suffix = marker.slice(1); + try { + // Wildcard markers identify bundle directories (*.xcodeproj/*.xcworkspace) + // — a stray plain file with that suffix is not a real project marker. + const entries = await readdir(cwd, { withFileTypes: true }); + return entries.some((entry) => entry.isDirectory() && entry.name.endsWith(suffix)); + } catch { + return false; + } +} + +async function detectNativeFramework(cwd: string): Promise { + for (const fw of NATIVE_FRAMEWORK_MAP) { + const results = await Promise.all(fw.markers.map((marker) => matchesMarker(cwd, marker))); + const matched = fw.markers.find((_, i) => results[i]); + if (matched !== undefined) { + log.debug(`framework: detected "${fw.name}" via marker "${matched}"`); return fw; } } + return null; +} + +export async function detectFramework(cwd: string): Promise { + const allDeps = await readDeps(cwd); + + if (allDeps) { + for (const fw of FRAMEWORK_MAP) { + if (fw.dep in allDeps) { + log.debug(`framework: detected "${fw.name}" via dependency "${fw.dep}"`); + return fw; + } + } + } else { + log.debug(`framework: no package.json at ${cwd} or unable to parse`); + } - log.debug(`framework: no match in ${cwd}/package.json dependencies`); + const native = await detectNativeFramework(cwd); + if (native) return native; + + if (allDeps) { + log.debug(`framework: no match in ${cwd}/package.json dependencies`); + } return null; }